Skip to content

Widen uncovered-release candidate set to rotation ∪ recently played - #2159

Merged
jakebromberg merged 1 commit into
mainfrom
uncovered-release-list-widening
Aug 23, 2026
Merged

Widen uncovered-release candidate set to rotation ∪ recently played#2159
jakebromberg merged 1 commit into
mainfrom
uncovered-release-list-widening

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

Summary

The search handoff offered only active rotation (~300 releases). WXYC is a freeform station: most of what a DJ actually enters into the flowsheet is not a current rotation release, so the releases most likely to reach a listener's feed are exactly the ones the search crawler never sees. The candidate set becomes active rotation ∪ recently played linked albums, keeping the wire schema, the exact-match ceiling, and ADR 0013's architecture unchanged.

  • New plays.ts. fetchRecentPlays(lookbackDays) windows flowsheet (entry_type='track', album_id linked, canonical pair via the artists join); fetchAllPlayedAlbums() sources the album_plays MV for the one-time --backfill drain (37,421 albums, play-count desc, deliberately no SQL LIMIT — a LIMIT would silently stall the drain, since each run would re-select the identical top N and the anti-join would drop all of them as already-handed-off).
  • orchestrate.ts. One mode-blind fetchPlayCandidates injection; rotation-first concat + first-wins dedup; one cap (UNCOVERED_MAX_RELEASES_PER_RUN) post-anti-join and pre-DRY_RUN, with capped_out computed at the cap site as uncovered.length - capped.length. The capped list feeds renderSnapshot, writeSnapshot, publish, and recordHandoffs alike, so the file on disk, the published snapshot, and the marker rows describe an identical release set.
  • Guards. Rotation-lane guards stay hard throws in steady state and demote to log + Sentry captureError under --backfill — a rotation-source regression must not abort a drain holding 37,421 valid play-arm candidates. Zero-plays escalates without throwing.
  • Dockerfile. CMDENTRYPOINT + empty CMD so docker-level --backfill passes through instead of replacing the launcher. This deliberately relocates the default OUTPUT_PATH to the WORKDIR — which is the path writer.ts's docstring already claimed, so the conversion makes that docstring true instead of stale. Documented in the README and docs/env-vars.md.

Backfill pacing — the one coupling the wire schema can't express

Publish is a whole-file replace of a single fixed path, and markers are publish-once. Run --backfill ten times back to back and 4,000 releases are marked handed-off while only the final 400 exist at branch HEAD — the exact permanent-drop failure the publish-gated marker design exists to prevent, arriving through the front door.

So the operator contract is at most one --backfill invocation per consumer cycle, documented in the README next to the docker run recipe. The constraint disappears entirely if research-data#16's consumer walks the snapshot branch's commit history rather than reading HEAD only; that requirement is relayed on that ticket.

Measured, not extrapolated

Quantity Measured
Distinct linked albums, ever 37,421
Distinct linked albums, trailing 30 d 2,356
New albums per week (never previously played) 51–104 (mean ≈ 75)
Albums in a 30 d window replayed within the next 60 d 39.7%
Play-arm aggregate, unindexed parallel seq scan ~174 ms
Anti-join at the full 37,421-id cardinality (~219 KB array literal) 13–25 ms

That 39.7% replay rate is why the backlog gets a --backfill mode instead of being left to the sliding window: ~60% of anything the window evicts is lost for months or indefinitely.

Precondition — this is gating, not a follow-up

Marker writes are publish-gated, and PUBLISH + RESEARCH_DATA_WRITE_TOKEN are still unprovisioned. Until they land, recordHandoffs writes nothing and every weekly run re-offers the entire eligible set — post-widening that means ~2,356 albums offered every run with capped_out pinned near 1,956 indefinitely, the opposite of "a safety valve normal operation never reaches."

Steady-state sizing (~75 new albums/week ≈ 325 searches/month, inside Brave's free tier) and the ADR line-57 rewrite are both stated as conditional on that credential.

Stack

Last link of a three-PR chain, split out of #1879's original 3,137-line form:

  1. Schema — uncovered_release_search_markers (migration 0146).
  2. Add uncovered-release-list job: rotation x album_critic_reviews handoff to research-data #1879 — the job, rotation-only, Closes #1877.
  3. This PR — the widening.

The split preserves the property that made landing this on the same branch the right call: nothing downstream consumes the snapshot yet (research-data#16 is unbuilt) and the publish credential is unprovisioned, so while #1879 is merged and this is not, the cron runs rotation-only, writes no markers, and publishes nothing. The invariant is that all three land before the credential is provisioned.

Out of scope

Free-text (unlinked) plays — ~43% of music plays have album_id IS NULL, and resolving them would mean running a DJ-typed string through a matcher, reintroducing exactly the wrong-album risk ADR 0013's canonical-pair design avoids. Also: per-insert live search, long-tail dedup ranking (#1878), draining the full 37,421-album tail, and provisioning the publish credential itself.

Test plan

  • plays.test.ts (new) — canonical-field mapping and row pass-through; asserts no SQL shape, per this suite's convention that SQL shape is pinned by the integration spec.
  • job.test.ts (new) — uncoveredJobOptions env parses and --backfill.
  • orchestrate.test.ts — concat + dedup precedence, cap truncation, capped_out non-zero under DRY_RUN, the capped list reaching all four downstream sites, the non-throwing empty-plays path, rotation-guard demotion under --backfill.
  • tests/integration/uncovered-release-list.spec.js — one case per arm, both fixture-scoped (scoped* helpers) since the suite runs --runInBand over shared flowsheet state; backfill case refreshes the MV via sql.unsafe first.
  • npm run typecheck, npm run lint (0 errors), npm run format:check, npm run test:unit
  • npm run ci:testmock — full Docker CI pipeline green

Closes #2157.

@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch 2 times, most recently from 90036ee to f5ba32c Compare August 15, 2026 01:33
@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch from f5ba32c to 9450908 Compare August 23, 2026 04:57
@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch 2 times, most recently from a8abe99 to a829401 Compare August 23, 2026 17:38
@jakebromberg jakebromberg reopened this Aug 23, 2026
@jakebromberg

Copy link
Copy Markdown
Member Author

Rebased onto the updated uncovered-release-list-job. Clean.

Verified locally on the rebased tree: build, typecheck (plus the per-job tsc --noEmit), lint (0 errors), format:check, and 8164 unit tests across 470 suites.

Briefly closed and reopened while probing whether GitHub would let the stack be retargeted to main — it will not, at any position in the stack. No content change; the head is the same rebased commit.

Merge last, after #2158 and #1879, from the UI.

@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch from a829401 to a76a8c7 Compare August 23, 2026 18:00
Base automatically changed from uncovered-release-list-job to main August 23, 2026 18:06
@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch from a76a8c7 to e64d9a8 Compare August 23, 2026 18:06
…BS#1877 amendment)

The search handoff offered only active rotation (~300 releases), but most of what DJs actually play — and what listeners see — is not current rotation. The candidate set is now active rotation ∪ recently played linked albums, keeping the wire schema, exact-match ceiling, and ADR 0013 architecture unchanged.

- New plays.ts: fetchRecentPlays(lookbackDays) windows flowsheet (entry_type='track', album_id linked, canonical pair via the artists join); fetchAllPlayedAlbums() sources the album_plays MV for the one-time --backfill drain (37,421 albums, play-count desc, deliberately no SQL LIMIT — a LIMIT would silently stall the drain against the handed-off anti-join).
- orchestrate.ts: single mode-blind fetchPlayCandidates injection; rotation-first concat + first-wins dedup; one cap (UNCOVERED_MAX_RELEASES_PER_RUN) post-anti-join and pre-DRY_RUN, capped_out computed at the cap site; the capped list feeds render/write/publish/recordHandoffs alike; rotation-lane guards stay hard throws in steady state and demote to log + Sentry captureError under --backfill; zero-plays escalates without throwing; locked DRY_RUN report widened with backfill, recent_play_rows, candidate_rows, capped_out.
- Dockerfile: CMD → ENTRYPOINT + empty CMD so docker-level --backfill passes through; this deliberately relocates the default OUTPUT_PATH to the WORKDIR (documented).
- Backfill pacing: single-path whole-file publish + publish-once markers means at most one --backfill invocation per consumer cycle until research-data#16 walks the snapshot branch history; requirement relayed on that ticket (WXYC/research-data#16 (comment)).
- Docs: README rewrite (modes, cap semantics, pacing, guards, locked report), ADR 0013 line-57 sizing (credential-conditional), env-vars, ops-cron-scheduling, package description, workspace CLAUDE.md row, stale header sweeps.
- Tests: unit coverage for both arms, options parsing, cap/dedup/guard behavior (report matched on its unique line prefix, not a shared token); integration cases for both arms with sql.unsafe MV refresh and fixture-scoped scoped* helpers.

Steady-state cost after PUBLISH + RESEARCH_DATA_WRITE_TOKEN are provisioned: ~75 new albums/week ≈ 325 searches/month. Measured: play aggregate ~174 ms unindexed; anti-join 13–25 ms at the full 37,421-id cardinality.

Code-review fixes folded in: the backfill and pull-from-container recipes now bind-mount the output directory instead of promising a docker cp that --rm makes impossible (with the credential unprovisioned that file is the run's only artifact, so the old recipe silently lost the batch); the empty-snapshot publish skip from the parent commit is re-keyed onto the capped list, which is what actually gets rendered, written, and published; and plays.ts cites the migration that creates flowsheet_track_add_time_idx rather than a schema.ts line range that had already rotted onto an unrelated block comment.

Renumbers this PR's references to the marker migration from 0146 to 0156, matching the schema PR beneath it: main landed its own 0146 (library-delete-denylist, BS#2112) while this stack sat open. Touched job.ts's header comment (resolved against this branch's widened prose, which is kept) and the job's CLAUDE.md registration row. The surviving "migration 0146" in jobs/library-etl/README.md is correct as-is — it refers to main's denylist migration, not this one.
@jakebromberg
jakebromberg force-pushed the uncovered-release-list-widening branch 2 times, most recently from 1ce9be8 to bf90695 Compare August 23, 2026 18:16
@jakebromberg
jakebromberg merged commit a0fb920 into main Aug 23, 2026
6 checks passed
@jakebromberg
jakebromberg deleted the uncovered-release-list-widening branch August 23, 2026 18:27
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.

Widen uncovered-release search candidate set from active rotation to rotation ∪ recently played

1 participant