From bf906954dc7b36beb5eb63be0998a4e8961957eb Mon Sep 17 00:00:00 2001 From: Jake Bromberg Date: Fri, 14 Aug 2026 12:40:17 -0700 Subject: [PATCH] =?UTF-8?q?Widen=20uncovered-release=20candidate=20set=20t?= =?UTF-8?q?o=20rotation=20=E2=88=AA=20recently=20played=20(BS#1877=20amend?= =?UTF-8?q?ment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (https://github.com/WXYC/research-data/issues/16#issuecomment-5297380930). - 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. --- CLAUDE.md | 1 + Dockerfile.uncovered-release-list | 5 +- ...earch-augmented-critic-review-discovery.md | 4 +- docs/env-vars.md | 10 +- docs/ops-cron-scheduling.md | 2 +- jobs/uncovered-release-list/README.md | 149 +++++++++-- jobs/uncovered-release-list/job.ts | 96 ++++++-- jobs/uncovered-release-list/orchestrate.ts | 233 +++++++++++++----- jobs/uncovered-release-list/package.json | 2 +- jobs/uncovered-release-list/plays.ts | 114 +++++++++ jobs/uncovered-release-list/writer.ts | 5 +- .../uncovered-release-list.spec.js | 189 +++++++++++++- .../jobs/uncovered-release-list/job.test.ts | 68 +++++ .../orchestrate.test.ts | 208 +++++++++++++++- .../jobs/uncovered-release-list/plays.test.ts | 73 ++++++ 15 files changed, 1055 insertions(+), 104 deletions(-) create mode 100644 jobs/uncovered-release-list/plays.ts create mode 100644 tests/unit/jobs/uncovered-release-list/job.test.ts create mode 100644 tests/unit/jobs/uncovered-release-list/plays.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5f8572079..b3cafaa61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ npm workspaces: | `@wxyc/concerts-poster-enrichment` | `jobs/concerts-poster-enrichment/` | Nightly cron (BS#1743 / On Tour): enrich concert rows missing a poster (`concerts.image_url IS NULL`) with the resolved headliner's Discogs artist image via LML's `getArtistDetails` (no bulk endpoint — one call per distinct headliner, job-owned limiter). Candidates dedupe by the same effective id the genre sibling uses (`COALESCE(headlining_discogs_artist_id, artists.discogs_artist_id)`); a fetched image UPDATEs every concert row that headliner is billed on, gated `WHERE image_url IS NULL` so a concurrent write can't be clobbered. No negative cache for "no Discogs image" (unlike the genre sibling's `artist_metadata` anti-join) — a no-image artist's rows stay candidates and are re-asked next run, bounded by the small upcoming-show cohort. **Hard-depends on BS#1742** (preserve-on-null `COALESCE` in the `triangle-shows-etl` / `venue-events-scraper` writers) deploying first, or the next scrape cycle nulls the poster right back out. `--backfill` front-fills existing resolved unenriched headliners once; cooperative pause defers when DJs are active. Default schedule `05 6 * * *` UTC (after the 05:55 similar-artists enrichment). | | `@wxyc/album-reviews-etl` | `jobs/album-reviews-etl/` | Nightly cron ETL (ADR 0011 / album-reviews-sheet-sync plan): mirror the ~1,650-row (and growing — the form is live) "Album Review Responses" Google Form sheet into `album_review_submissions` (migration 0119) via Sheets REST v4 + service-account JWT (`google-auth-library`, spreadsheets.readonly; env `ALBUM_REVIEWS_SHEET_ID` + `GOOGLE_SERVICE_ACCOUNT_JSON_B64`). Header-based column mapping (exact-match `Buzzwords` — the dead long-form column trap), DST-correct ET timestamp parse, UPSERT keyed on partial-unique `source_key` (`form:`; collision-proofed `nots:` reviewer-hash fallback) with an IS DISTINCT FROM `setWhere` so unchanged nightly runs report `unchanged` and never churn `last_modified`; `add_date`/`album_id` omitted from the conflict set; rows are never deleted. Post-write pure-SQL+TS link pass FKs `album_id` on singleton library matches only (0092 SQL twin over `artist_name`+`album_artist`, TS-side `normalizeAlbumTitle`), guarded `WHERE album_id IS NULL` — never overwrites. `reviewer_raw`/`social_consent_raw` are PII-internal, never emitted by the read endpoint. Run guards (zero-valid / >50%-invalid / zero-written) fail the cron loudly; `DRY_RUN` emits a locked-schema JSON report. Default schedule `50 4 * * *` UTC. | | `@wxyc/album-critic-reviews-etl` | `jobs/album-critic-reviews-etl/` | Weekly cron ETL (BS#1830, album-critic-reviews slice / ADR 0012 — the production follow-on to the one-off `scripts/seed-critic-reviews.ts` manual seed): download the `manifest-latest` release's `manifest.jsonl.gz` asset from the private `WXYC/research-data` repo (GitHub REST API, env `RESEARCH_DATA_TOKEN`), match each `CorpusItem` to a linked library album via `resolveLinkedAlbumId` (`@wxyc/database`, BS#1829) with a decoration-strip fallback (strips a trailing `(reissue\|deluxe\|remaster(ed)?\|expanded\|ep\|deluxe edition)` clause — narrower than `normalizeAlbumTitle`, deliberately preserving the exact-match ceiling), dedup to one review per album by an explicit source-preference total order (editorial head The Quietus → Tiny Mix Tapes → Bandcamp Daily, then a data-driven ranked list with a deterministic name-sorted fallback so an unranked source is never silently excluded), anti-join against already-seeded `(album_id, source_url)` pairs BEFORE calling Haiku (`claude-haiku-4-5-20251001`, env `ANTHROPIC_API_KEY`) so re-extracting an existing row can't churn the UPSERT or re-spend tokens, and UPSERT into `album_critic_reviews` (migration 0125) with an IS DISTINCT FROM `setWhere`. No cooperative pause (writes only its own table, mirroring the `album-reviews-etl` donor). Run guards (zero-parsed / zero-matched / zero-written-when-new-existed) fail the cron loudly; `DRY_RUN` makes zero LLM calls and emits a locked-schema JSON report incl. a per-source matched breakdown. Default schedule `10 7 * * 0` UTC (weekly, Sunday). | +| `@wxyc/uncovered-release-list` | `jobs/uncovered-release-list/` | Weekly cron (BS#1877, ADR 0013's "uncovered-release list handoff", widened by the "rotation ∪ recently played" amendment): computes the `(active rotation ∪ recently played) × album_critic_reviews` anti-join — active rotation releases and recently-played linked albums with zero critic reviews today — further anti-joined against `uncovered_release_search_markers` (migration 0156, the "searched, found nothing" marker), writes `uncovered-releases.jsonl`, and commits it to the private `WXYC/research-data` repo's `search` crawl mode (RD#16). Two play-arm sources (`plays.ts`): `fetchRecentPlays` (steady state, a trailing `UNCOVERED_PLAY_LOOKBACK_DAYS`-day `flowsheet` window) and `fetchAllPlayedAlbums` (`--backfill`, the `album_plays` MV, no time window, no SQL `LIMIT`). One cap knob (`UNCOVERED_MAX_RELEASES_PER_RUN`, default 400) at one position, post-anti-join, pre-DRY_RUN, in both modes. Rotation-lane guards (zero active rotation, zero resolved) hard-throw in steady state, demote to log+Sentry under `--backfill` so the drain proceeds on the play arm alone. Only a read-only anti-join plus a git write to a repo Backend-Service doesn't otherwise touch — no new outbound web-egress, no new authenticated endpoint, Project #32 freeze-compatible. Publish (`RESEARCH_DATA_WRITE_TOKEN` + `PUBLISH=true`) is **not yet provisioned**; the widened cost model's steady-state sizing depends on it (see the job's README "Precondition"). Default schedule `40 7 * * 0` UTC. | | `@wxyc/legacy-mirror-reconcile` | `jobs/legacy-mirror-reconcile/` | Recurring cron (BS#1707): self-heal tubafrenzy mirror rows orphaned when the live mirror's one-shot `res.finish` attempt was skipped (flag off at go-live, transient tubafrenzy failure, mid-show flag flip, or a BS restart mid-request). Two DB-durable, **all-or-nothing** sweeps reading the durable NULL-surrogate-key signal straight from Postgres: **Sweep 1** creates the tubafrenzy show for `shows.legacy_show_id IS NULL` + `primary_dj_id IS NOT NULL` + inside [now-WINDOW, now-SETTLE] + `NOT EXISTS` any already-mirrored entry (the guard that avoids duplicating a mid-flag-flip show tubafrenzy already server-side auto-resolved), persisting `legacy_show_id`. **Sweep 2** drives every `flowsheet.legacy_entry_id IS NULL` entry (`play_order ASC`, `mapEntryToTubafrenzy` + `isActiveRotationMatch` badge parity) of each all-or-nothing show that has a `legacy_show_id`, then `mirrorSignoffShow` if finalized. **Partially**-mirrored shows (some entries mirrored, some NULL) are NOT auto-healed — re-driving would append out of order (tubafrenzy assigns SEQUENCE server-side) — but detected + reported (structured log + Sentry warning). All payloads from `@wxyc/legacy-mirror` (byte-identical to live). Per-DJ `backend-mirror` flag gate keyed on `primary_dj_id` (POSTHOG_API_KEY unset → enabled); `pg_try_advisory_lock` single-flight on a dedicated `max:1` client (exit 0 if not acquired); cooperative pause via shared `checkLiveActivity`; the lock client is built with `maxLifetimeSeconds: 0` so postgres-js's idle-recycle can't silently drop the session lock mid-run; `finally` order PostHog `shutdown()` → advisory-unlock (when held) → lock-client `end()` → `closeDatabaseConnection()` → `closeLogger()`. Env `RECONCILE_WINDOW_HOURS` (48) / `RECONCILE_SETTLE_MINUTES` (15) / `RECONCILE_ALERT_THRESHOLD` (0), reuses shared `LIVE_ACTIVITY_*`. First `jobs/` reader of a PostHog flag. Default schedule `0 8 * * *` UTC (≈03:00 ET, static). | | `@wxyc/flowsheet-ghost-row-sweep` | `jobs/flowsheet-ghost-row-sweep/` | One-shot mechanism slice of the BS#1083 tubafrenzy-ghost-row cleanup (BS#1887): anti-joins `flowsheet.legacy_entry_id` / `rotation.legacy_rotation_id` against a pluggable `LegacyKeyspaceSource` (`loadFlowsheetIds()`/`loadRotationIds()` → `Set`; file-backed `FileKeyspaceSource` ships here, the prod dump adapter is a documented seam left to BS#1083/BS#1543) and DELETEs the orphans neither `flowsheet-etl` nor `rotation-etl`'s upsert-only writers ever remove. Membership test is in-process (`keyspace.has(legacy_id)`), not a SQL `NOT IN` — id-cursor paged SELECT, per-row Set lookup. Batched DELETE + `ANALYZE`-after per the bulk-update playbook, id-cursor resume (`GHOST_SWEEP_FLOWSHEET_AFTER_ID`/`GHOST_SWEEP_ROTATION_AFTER_ID`), cooperative pause + SIGTERM graceful stop (`streaming-url-remediation` structural donor). Cascade-verified: a swept `flowsheet` row's `flowsheet_linkage_review` child CASCADEs (migration 0067); a swept `rotation` row SET-NULLs any referencing `flowsheet.rotation_id` (migration 0097) rather than blocking or orphan-deleting a legit row. Three review-driven safety nets: an empty-keyspace floor (`GHOST_SWEEP_MIN_KEYSPACE_SIZE`, default 1) refuses the run rather than anti-join every row as a ghost on a missing/empty keyspace file; a ghost-fraction ceiling (`GHOST_SWEEP_MAX_GHOST_RATIO`, default 0.5) aborts a target — before that page's DELETE — once its running ghost fraction exceeds the ceiling, catching a _partially_-truncated keyspace the empty floor sails past; and a post-run ghost-free re-scan (execute + clean finish that actually deleted) catches a page whose DELETE appeared to commit but was lost to a crash under async commit (`DB_SYNCHRONOUS_COMMIT=off`) before the resume cursor could be trusted. Dry-run by default (`--execute` to write); this issue never runs against production data or the real tubafrenzy dump — BS#1083 is the run/close gate. | | `@wxyc/flowsheet-april-gap-import` | `jobs/flowsheet-april-gap-import/` | One-shot dry-run-by-default import (BS#2119): backfill the closed BS#351 residue — 403 `FLOWSHEET_ENTRY_PROD` rows tubafrenzy holds that Backend never received because the pre-fix ETL silently dropped every track entry with `START_TIME=0`. Default scope is the unambiguous 2026-04-16 → 2026-04-20 window (399 rows / 15 shows, `GAP_IMPORT_WINDOW_START`/`END` widen it); the 4 post-Phase-3 August rows are deliberately excluded pending a per-row provenance check (see #1543). Insert-only, `ON CONFLICT (legacy_entry_id) DO NOTHING`, never `DO UPDATE`. Reuses `jobs/flowsheet-etl`'s pure mappers (extracted to `transform.ts`/`show-id-map.ts`/`fetch-legacy.ts`'s `fetchLegacyEntriesInWindow` by this same issue, since importing `job.ts` itself self-invokes the ETL) — never `flowsheet-etl`/`legacy-linkage-resolve` directly. Batched inserts with cooperative live-DJ pause + inter-batch gap. **Four refusals before any write** (all also fire in dry-run): Backend-side id-count floor, cohort-size ceiling, an upstream `GAP_IMPORT_MIN_CANDIDATE_COUNT` floor (default 1 — zero candidates is a bad window, not an empty one; candidates come from tubafrenzy and don't depend on Backend state), and a `GAP_IMPORT_MAX_NULL_KEY_ROWS` orphan guard (default 0) counting **non-marker** rows the target shows already hold with `legacy_entry_id IS NULL` — both the cohort diff and the `ON CONFLICT` target key on that column and a unique index doesn't constrain NULLs, so a dj-site row whose back-stamp was skipped (the `legacy-mirror-reconcile` Sweep 2 orphan class) is invisible to both and would be inserted twice. `dj_name` comes from the canonical `resolveShowDjName` (`@wxyc/database` `dj-name.ts`, extracted from `flowsheet.service.ts` by the BS#2119 review), never a re-derived `COALESCE` — the donor's copy predates `dj_name_override` (BS#1321) and omits the literal-"Anonymous" filter (BS#1286). | diff --git a/Dockerfile.uncovered-release-list b/Dockerfile.uncovered-release-list index 3db90468a..87bf69d27 100644 --- a/Dockerfile.uncovered-release-list +++ b/Dockerfile.uncovered-release-list @@ -27,4 +27,7 @@ RUN npm install --omit=dev COPY --from=builder ./uncovered-release-list-builder/jobs/uncovered-release-list/dist ./jobs/uncovered-release-list/dist COPY --from=builder ./uncovered-release-list-builder/shared/database/dist ./shared/database/dist -CMD ["npm", "start", "--workspace=@wxyc/uncovered-release-list"] +# ENTRYPOINT + empty CMD so docker-level args (e.g. `--backfill`, `--dry-run`) +# pass through to the job rather than replacing the launcher. +ENTRYPOINT ["node", "/uncovered-release-list/jobs/uncovered-release-list/dist/job.js"] +CMD [] diff --git a/docs/adr/0013-search-augmented-critic-review-discovery.md b/docs/adr/0013-search-augmented-critic-review-discovery.md index f70f272ba..0867fe623 100644 --- a/docs/adr/0013-search-augmented-critic-review-discovery.md +++ b/docs/adr/0013-search-augmented-critic-review-discovery.md @@ -54,7 +54,7 @@ The cost is real and worth naming: an extra publish/fetch hop (research-data sti ## Search provider evaluation -Evaluated for the workload the ticket sizes: ~72 residual uncovered releases now, plus a few dozen new rotation adds per week — **a few hundred queries/month**, comfortably in every candidate's lowest paid (or free) tier. +Evaluated for the workload the ticket sizes, **updated for the candidate-set widening above and conditional on `jobs/uncovered-release-list`'s publish credential (`RESEARCH_DATA_WRITE_TOKEN` + `PUBLISH=true`) being provisioned** — see that job's README "Precondition" section: once markers are actually being written, steady state is the new-album rate over the widened rotation ∪ recently-played set, measured at ~75 new linked albums/week against the local prod clone (2026-04-23 PT) — **~325 searches/month**, comfortably in every candidate's lowest paid (or free) tier. Until the credential is provisioned, markers are never written and every weekly run re-offers the full ~2,356-album trailing-30-day eligible set instead, pinning the cap (`UNCOVERED_MAX_RELEASES_PER_RUN`, default 400) at its ceiling every run. Separately, a one-time historical backlog of **37,421** distinct linked albums ever played exists; draining it (`--backfill`, play-count-desc, operator-paced, bounded by the same cap) is deliberately out of scope for this design and would cost roughly $90 of Brave queries alone if fully drained. | Provider | Status (2026-07) | Cost at our volume | ToS posture | Verdict | | -------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -69,6 +69,8 @@ This spike used the WebSearch/WebFetch tools available in this environment as a ## Uncovered-release list handoff +> **Update (candidate-set widening):** the candidate set feeding this handoff was widened from active-rotation-only to **active rotation ∪ recently-played linked albums** — WXYC is a freeform station, and most of what a DJ actually enters into the flowsheet is not a current rotation release, so a rotation-only candidate set structurally misses the releases most likely to reach a listener's feed. The handoff mechanism below (committed file, dedicated marker table) is unchanged; only what feeds the anti-join changed. A `--backfill` mode drains the historical play tail via the `album_plays` materialized view, separately from the steady-state trailing window. See `jobs/uncovered-release-list/README.md` for the two arms, the run-mode table, and the publish-credential precondition the widened cost model depends on (also reflected in the sizing below). + **A committed file, refreshed by a scheduled Backend-Service job, not a live read endpoint or direct DB access.** Three options were on the table: - **Committed file (chosen).** A Backend-Service job computes `rotation × album_critic_reviews` anti-joined against already-searched releases (a new small tracking table or a `source_key` convention analogous to the ETL's `manifest:${source}` — TBD in the production ticket) and commits a small JSON/CSV file of `(artist, album, library_id)` rows to research-data (or opens a PR there) on a schedule. research-data's search crawler reads that file, same shape as `crawl_reviews.py` reading its own committed corpus for resumability. diff --git a/docs/env-vars.md b/docs/env-vars.md index de4b2747f..718b7872e 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -372,12 +372,14 @@ The album-critic-reviews ETL (`jobs/album-critic-reviews-etl/`) mirrors the `man - `ANTHROPIC_API_KEY` — Required for a non-`DRY_RUN` run (Haiku snippet extraction, `claude-haiku-4-5-20251001`). Not required under `DRY_RUN`, which makes zero LLM calls by design (the anti-join + dry-run short-circuit both precede extraction). - `DRY_RUN` — Locked truthy values: `true`, `1` (case-insensitive). Runs fetch + parse + match + dedup + anti-join and evaluates the run guards, but skips every Haiku call and every UPSERT, emitting a single locked-schema JSON report line on stdout (see `jobs/album-critic-reviews-etl/README.md`). Harmless to forget — the UPSERT is idempotent across reruns, and the anti-join means a repeated real run makes zero LLM calls for already-seeded pairs anyway. -The uncovered-release-list job (`jobs/uncovered-release-list/`) computes the `rotation × album_critic_reviews` anti-join weekly (BS#1877, ADR 0013's "uncovered-release list handoff") and commits `uncovered-releases.jsonl` to the private `WXYC/research-data` repo for its `search` crawl mode to consume. No SSH tunnel, no sync-notify. Unlike `album-critic-reviews-etl`, no external credential is required just to run — the anti-join read and the local snapshot-file write both work with only the standard `DB_*` set; a credential is needed only to actually push the snapshot (see below). +The uncovered-release-list job (`jobs/uncovered-release-list/`) computes the `(active rotation ∪ recently played) × album_critic_reviews` anti-join weekly (BS#1877, ADR 0013's "uncovered-release list handoff", widened by the "rotation ∪ recently played" amendment) and commits `uncovered-releases.jsonl` to the private `WXYC/research-data` repo for its `search` crawl mode to consume. No SSH tunnel, no sync-notify. Unlike `album-critic-reviews-etl`, no external credential is required just to run — the anti-join read and the local snapshot-file write both work with only the standard `DB_*` set; a credential is needed only to actually push the snapshot (see below). -- `OUTPUT_PATH` — Local path the snapshot file is written to (default `./output/uncovered-releases.jsonl`). +- `OUTPUT_PATH` — Local path the snapshot file is written to. Default `./output/uncovered-releases.jsonl`, relative to the job's cwd — under the container's `ENTRYPOINT` (direct `node dist/job.js`, not `npm start --workspace`), cwd is the `/uncovered-release-list` WORKDIR, so the default resolves to `/uncovered-release-list/output/uncovered-releases.jsonl`. - `PUBLISH` — Locked truthy values: `true`, `1`. Must be set, together with `RESEARCH_DATA_WRITE_TOKEN`, for the job to push to research-data. Off by default; the job still writes the local file and runs its anti-joins with `PUBLISH` unset, it just doesn't call out to GitHub and doesn't write `uncovered_release_search_markers` rows (see the job README's "Handoff" section for why marker-writing is publish-gated). -- `RESEARCH_DATA_WRITE_TOKEN` — Fine-grained PAT scoped to the private `WXYC/research-data` repo with `Contents: Read and write`, used to commit `uncovered-releases.jsonl` to that repo's `uncovered-releases-snapshot` branch via the GitHub Contents API. Deliberately separate from `RESEARCH_DATA_TOKEN` above (that one is read-only by design). Not yet provisioned as of this job's initial ship. -- `DRY_RUN` — Locked truthy values: `true`, `1`. Runs the fetch + resolve + dedup + both anti-joins and evaluates the run guards, but makes zero writes and zero network calls, emitting a single locked-schema JSON report line on stdout (see `jobs/uncovered-release-list/README.md`). +- `RESEARCH_DATA_WRITE_TOKEN` — Fine-grained PAT scoped to the private `WXYC/research-data` repo with `Contents: Read and write`, used to commit `uncovered-releases.jsonl` to that repo's `uncovered-releases-snapshot` branch via the GitHub Contents API. Deliberately separate from `RESEARCH_DATA_TOKEN` above (that one is read-only by design). Not yet provisioned as of this job's initial ship — see the job README's "Precondition" section for why the widened candidate set's cost model depends on this being provisioned. +- `UNCOVERED_PLAY_LOOKBACK_DAYS` — Trailing window (days) for the steady-state play arm (`plays.fetchRecentPlays`). Default `30`. Parsed and validated in both modes (a malformed value fails fast even under `--backfill`); its value is only read in steady state — `--backfill` drains every linked album ever played instead. +- `UNCOVERED_MAX_RELEASES_PER_RUN` — Post-anti-join cap, both modes. Default `400`. The one cap knob at one position; an operator raises it per `--backfill` invocation (e.g. `-e UNCOVERED_MAX_RELEASES_PER_RUN=2000`) since the default would collide with the backfill's one-invocation-per-consumer-cycle pacing contract — see the job README. +- `DRY_RUN` — Locked truthy values: `true`, `1`. Runs the fetch + resolve + dedup + both anti-joins and evaluates the run guards, but makes zero writes — and no network calls beyond a Sentry capture if a guard escalates (the guards carry no `DRY_RUN` exemption) — emitting a single locked-schema JSON report line on stdout (see `jobs/uncovered-release-list/README.md`). ### Flowsheet April gap import (`jobs/flowsheet-april-gap-import`, BS#2119) diff --git a/docs/ops-cron-scheduling.md b/docs/ops-cron-scheduling.md index 9ad434406..6c720cfea 100644 --- a/docs/ops-cron-scheduling.md +++ b/docs/ops-cron-scheduling.md @@ -51,7 +51,7 @@ BS#2218 also made this the first job to write `cronjob_runs.cursor_position` (mi - `concerts-artist-resolver` (05:15) — pure-SQL strict/alias resolver, no LML. (`concerts-artist-lml-resolver` at 05:35 is the LML-touching one.) - `concerts-similar-artists-enrichment` (05:55, hits semantic-index not LML), `venue-events-scraper`, `triangle-shows-etl`, `album-reviews-etl`, `legacy-mirror-reconcile` — non-LML. - `metadata-no-match-digest` (`07 15 * * *` UTC, daily) — reads `flowsheet`/`shows`/`cronjob_runs` directly and sends via SES; no `@wxyc/lml-client` dependency, cannot trip the breaker. Its `:07` past 15:00 UTC slot was picked only to avoid the `:00` slot shared by the `*/30` ETL trio (now a pair — see above) + hourly `artist-identity-etl`, a host-load courtesy unrelated to this policy. -- `album-critic-reviews-etl` (07:10 Sun) and `uncovered-release-list` (07:40 Sun) — non-LML. The latter reads Backend-Service's own Postgres only (rotation + album_critic_reviews + uncovered_release_search_markers) plus an optional GitHub Contents API push to `WXYC/research-data`; scheduled 30 min after the former so its anti-join sees that week's freshly-pulled `album_critic_reviews` rows. +- `album-critic-reviews-etl` (07:10 Sun) and `uncovered-release-list` (07:40 Sun) — non-LML. The latter reads Backend-Service's own Postgres only (rotation + flowsheet + the `album_plays` MV under `--backfill` + album_critic_reviews + uncovered_release_search_markers) plus an optional GitHub Contents API push to `WXYC/research-data`; scheduled 30 min after the former so its anti-join sees that week's freshly-pulled `album_critic_reviews` rows. ## The hourly safety net (BS#895) diff --git a/jobs/uncovered-release-list/README.md b/jobs/uncovered-release-list/README.md index 94d34ef74..66720af3e 100644 --- a/jobs/uncovered-release-list/README.md +++ b/jobs/uncovered-release-list/README.md @@ -1,23 +1,45 @@ # uncovered-release-list -Weekly cron job (BS#1877, [ADR 0013](../../docs/adr/0013-search-augmented-critic-review-discovery.md)'s "uncovered-release list handoff", sibling to [`jobs/album-critic-reviews-etl`](../album-critic-reviews-etl/README.md)): computes the `rotation × album_critic_reviews` anti-join — active rotation releases with **zero** critic reviews today — further anti-joins against releases already handed off for search at least once, writes the result as `uncovered-releases.jsonl`, and commits it to the private [`WXYC/research-data`](https://github.com/WXYC/research-data) repo, where its `search` crawl mode ([RD#16](https://github.com/WXYC/research-data/issues/16)) reads it. This is the only new Backend-Service-side surface ADR 0013's design requires — a read-only anti-join plus a git write to a repo Backend-Service doesn't otherwise touch, not a new outbound web-egress subsystem or an authenticated endpoint. Keeps the design [Project #32](https://github.com/orgs/WXYC/projects/32) freeze-compatible. +Weekly cron job (BS#1877, [ADR 0013](../../docs/adr/0013-search-augmented-critic-review-discovery.md)'s "uncovered-release list handoff", widened by the "rotation ∪ recently played" amendment, sibling to [`jobs/album-critic-reviews-etl`](../album-critic-reviews-etl/README.md)): computes the `(active rotation ∪ recently played) × album_critic_reviews` anti-join — active rotation releases and recently-played linked albums with **zero** critic reviews today — further anti-joins against releases already handed off for search at least once, writes the result as `uncovered-releases.jsonl`, and commits it to the private [`WXYC/research-data`](https://github.com/WXYC/research-data) repo, where its `search` crawl mode ([RD#16](https://github.com/WXYC/research-data/issues/16)) reads it. This is the only new Backend-Service-side surface ADR 0013's design requires — a read-only anti-join plus a git write to a repo Backend-Service doesn't otherwise touch, not a new outbound web-egress subsystem or an authenticated endpoint. Keeps the design [Project #32](https://github.com/orgs/WXYC/projects/32) freeze-compatible. + +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 a rotation-only candidate set never sees. The candidate set is **active rotation ∪ albums played recently** — two arms, `rotation.ts` (unchanged) and the new `plays.ts` — everything else about ADR 0013's design (wire schema, exact-match ceiling, handoff mechanism) is unchanged. + +## Precondition — read this first + +**The publish credential is not provisioned, and the widening's cost model depends on it.** Marker writes are publish-gated (`orchestrate.ts`), and both `PUBLISH` and `RESEARCH_DATA_WRITE_TOKEN` are unprovisioned as of this job's initial ship — see "Handoff" below and `docs/env-vars.md`. + +Until that credential lands, `recordHandoffs` writes nothing, so **every weekly run re-offers the entire eligible set**. Post-widening that means ~2,356 albums offered every run (the trailing-30-day linked-album count, measured against the local prod clone), the cap firing on every run, and `capped_out` pinned near 1,956 indefinitely — the exact opposite of "a safety valve normal operation never reaches." + +So: provisioning `PUBLISH=true` + `RESEARCH_DATA_WRITE_TOKEN` (the two-step ops task in "Handoff" below) is a **prerequisite for this widening to behave as designed**, not an independent follow-up. ## Schedule `40 7 * * 0` UTC (weekly, Sunday 07:40) from `package.json`'s `cron-schedule`, registered by deploy-base. 30 minutes after `album-critic-reviews-etl`'s `10 7 * * 0` run, so this job's anti-join reads `album_critic_reviews` **after** that week's manifest pull has landed — a release the manifest ETL just covered is excluded from this week's list rather than round-tripped needlessly. DB-only against Backend-Service's own Postgres (no LML HTTP calls), so [`docs/ops-cron-scheduling.md`](../../docs/ops-cron-scheduling.md)'s LML-spacing policy doesn't govern it (see that doc's "Excluded / DB-only" section); still placed outside the busy 04:15–06:17 UTC stack for readability, alongside the other Sunday-only jobs. +## Modes + +| Invocation | Play-arm source | Candidate window | Rotation-lane guards | +| ------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ | +| `node dist/job.js` (weekly) | `plays.fetchRecentPlays` — a trailing `UNCOVERED_PLAY_LOOKBACK_DAYS`-day window | steady state, ~2,356 eligible | hard throw on zero active rotation / zero resolved | +| `node dist/job.js --backfill` | `plays.fetchAllPlayedAlbums` — the `album_plays` MV, every linked album ever played | one-time backlog, 37,421 total | demoted to log + Sentry, drain proceeds on plays alone | +| `DRY_RUN=true node dist/job.js` | (either of the above) | zero writes; no network calls beyond a Sentry capture if a guard escalates | (either of the above) | + +`--backfill` and `DRY_RUN` compose freely (a dry-run preview of the backfill plan). + ## Environment See [`docs/env-vars.md`](../../docs/env-vars.md) for the full reference. Required: the standard `DB_*` set only — unlike `album-critic-reviews-etl`, **no external credential is required to run**; the anti-join read and the local `uncovered-releases.jsonl` write both work with zero external calls. Optional: -- `OUTPUT_PATH` — where the snapshot file is written (default `./output/uncovered-releases.jsonl`). +- `OUTPUT_PATH` — where the snapshot file is written (default `./output/uncovered-releases.jsonl`, relative to the job's cwd). Under this job's `ENTRYPOINT` (direct `node dist/job.js`, cwd is the container's `/uncovered-release-list` WORKDIR), the default resolves to `/uncovered-release-list/output/uncovered-releases.jsonl` — see "Pulling the file from a container" below. +- `UNCOVERED_PLAY_LOOKBACK_DAYS` — trailing window (days) for the steady-state play arm. Default `30`. Parsed and validated in both modes (a malformed value fails fast even under `--backfill`, matching the donor's option-struct shape); its value is only _read_ in steady state — `--backfill` drains every linked album ever played instead. +- `UNCOVERED_MAX_RELEASES_PER_RUN` — post-anti-join cap, both modes. Default `400`. **One cap knob, one cap position** — see "Cap semantics" below. - `PUBLISH` — locked truthy `true`/`1`. Must be set (together with `RESEARCH_DATA_WRITE_TOKEN`) for the job to actually push to research-data; see "Handoff" below. - `RESEARCH_DATA_WRITE_TOKEN` — a fine-grained PAT scoped to `WXYC/research-data` with `Contents: Read and write`. **Not yet provisioned as of this job's initial ship** — see "Handoff". - `DRY_RUN`, `SENTRY_DSN`. ## Snapshot contract (cross-repo interface — `WXYC/research-data`) -One JSON object per line in `uncovered-releases.jsonl`, committed to the `uncovered-releases-snapshot` branch of `WXYC/research-data`. **Locked schema** — coordinated with [`research-data#16`](https://github.com/WXYC/research-data/issues/16) (the `search` crawl-mode ticket that consumes this file): +One JSON object per line in `uncovered-releases.jsonl`, committed to the `uncovered-releases-snapshot` branch of `WXYC/research-data`. **Locked schema** — coordinated with [`research-data#16`](https://github.com/WXYC/research-data/issues/16) (the `search` crawl-mode ticket that consumes this file), **unchanged by the candidate-set widening**: ```jsonc { @@ -27,25 +49,83 @@ One JSON object per line in `uncovered-releases.jsonl`, committed to the `uncove } ``` -`artist`/`album` are the **library-canonical** pair (`library.album_title` / `artists.artist_name`, joined off the resolved `library_id`) — never the raw rotation/tubafrenzy DJ-typed snapshot text. This is the entire point of ADR 0013's design: a search-sourced review row can be written with the same canonical pair from the start, so `album-critic-reviews-etl`'s exact-match resolver hits trivially without loosening it (see the ADR's "Why Option B" section). `library_id` is `library.id`, the same key `album_critic_reviews.album_id` FKs to. An empty uncovered set still produces a (zero-byte) **local** file — a valid, meaningful "nothing new to search this cycle" artifact, not a skipped write. It is deliberately **not published**: see "Never publish an empty snapshot" below. +`artist`/`album` are the **library-canonical** pair — `rotation.ts`'s resolve loop for rotation-arm rows, `plays.ts`'s `artists`/`library` joins for play-arm rows — never the raw rotation/tubafrenzy snapshot text or a DJ-typed play string. This is the entire point of ADR 0013's design: a search-sourced review row can be written with the same canonical pair from the start, so `album-critic-reviews-etl`'s exact-match resolver hits trivially without loosening it (see the ADR's "Why Option B" section). `library_id` is `library.id`, the same key `album_critic_reviews.album_id` FKs to. An empty uncovered set still produces a (zero-byte) **local** file — a valid, meaningful "nothing new to search this cycle" artifact, not a skipped write. It is deliberately **not published**: see "Never publish an empty snapshot" below. + +## Candidate set — the two arms + +**Rotation arm** (`rotation.ts`, unchanged): every active rotation row (`kill_date IS NULL OR kill_date > CURRENT_DATE`), resolved to its library-canonical pair. + +**Play arm** (`plays.ts`, new): linked `flowsheet` plays (`entry_type = 'track' AND album_id IS NOT NULL`) — the repo's canonical definition of "a play" (also `album_plays`'s own WHERE clause and `catalog-popularity-freetext-resolve/job.ts`). Two sources, selected by run mode: + +- `fetchRecentPlays(lookbackDays)` — steady state. A trailing `UNCOVERED_PLAY_LOOKBACK_DAYS`-day window over `flowsheet`, grouped by `album_id` and ranked play-count desc. +- `fetchAllPlayedAlbums()` — `--backfill`. Reuses the existing `album_plays` materialized view (migration 0059) instead of re-aggregating 2.6M `flowsheet` rows — that MV is defined as exactly this arm's aggregate minus the time window, uniquely indexed on `album_id`, and refreshed hourly by the API container. `album_popularity` (migration 0107) is rejected for both arms: it collapses pressings into a master-level signal, which would break the `library_id` the wire contract requires. + +Both play-arm sources already know their `library.id` and join their canonical strings in the same query (via `artists`, the same canonical-artist source `rotation.ts` uses) — they return `CanonicalRelease[]` directly, with no resolve step. **Free-text (unlinked) plays are out of scope**: ~43% of music plays have `album_id IS NULL` and cannot emit the wire contract's required `library_id: int`; resolving them would mean running the DJ-typed string through a matcher, reintroducing exactly the wrong-album risk ADR 0013's canonical-pair design exists to avoid. `flowsheet_freetext_resolution` (migration 0106) is the future bridge, not built here. + +Rotation-resolved releases are concatenated first, then play-arm releases, then deduped to one row per `library.id` (first-wins) — an album in both arms keeps its rotation-arm entry (semantically free, since both arms resolve to the identical canonical pair for a given `library.id`), but this pins determinism and keeps rotation at the head of the cap ordering. ## Pipeline (`orchestrate.ts`) -1. Fetch every active rotation row (`rotation.ts`'s `fetchActiveRotationRows`) — `kill_date IS NULL OR kill_date > CURRENT_DATE`, `LEFT JOIN rotation_library_view` + `COALESCE` so both album_id-linked rows (canonical fields via the view) and snapshot-only rows (never linked; own `artist_name`/`album_title` text) are kept. Guard: 0 active rows -> throw (rotation is never genuinely empty; an empty read is a source regression). -2. Resolve each row to a `CanonicalRelease` (`resolveCanonicalRelease`), sequentially. Already-linked rows use the view's canonical fields directly; unlinked rows call `resolveLinkedAlbumId` (`@wxyc/database`, the same exact-match resolver `album-critic-reviews-etl/match.ts` uses) on the snapshot text, then fetch that resolved id's canonical fields. A miss drops the row — this job cannot emit `library_id` for a release with no library link at all. Guard: 0 resolved -> throw (evaluated regardless of `DRY_RUN`). -3. Dedup to one row per `library.id` (`dedupeByLibraryId`) — tubafrenzy permits multiple active rotation rows per release (re-bins, re-adds). -4. Two anti-joins (`antijoin.ts`): drop releases already carrying an `album_critic_reviews` row (`loadCoveredLibraryIds`), and drop releases already recorded in `uncovered_release_search_markers` (`loadHandedOffLibraryIds` — see "Found-nothing marker" below). -5. `DRY_RUN` stops here: emits a locked-schema JSON report and returns, having made zero writes and zero network calls beyond the read-only DB queries above. -6. Render (`writer.renderSnapshot`) once and write to disk (`writer.writeSnapshotFile`) — happens even when the uncovered set is empty. -7. Publish the same rendered content to research-data (`publish.ts`), **unless the set is empty** (see below). A publish failure is caught, logged, and counted — never aborts the run; the local file write already succeeded and is this run's durable artifact regardless. -8. Record handoff markers (`markers.recordHandoffs`) **only when the publish actually committed** — see "Found-nothing marker". +1. Fetch every active rotation row (`rotation.ts`'s `fetchActiveRotationRows`) — `kill_date IS NULL OR kill_date > CURRENT_DATE`, `LEFT JOIN rotation_library_view` + `COALESCE` so both album_id-linked rows (canonical fields via the view) and snapshot-only rows (never linked; own `artist_name`/`album_title` text) are kept. Guard: 0 active rows -> throw in steady state (rotation is never genuinely empty; an empty read is a source regression); demoted to log+Sentry under `--backfill` (see "Guards" below). +2. Resolve each rotation row to a `CanonicalRelease` (`resolveCanonicalRelease`), sequentially. Already-linked rows use the view's canonical fields directly; unlinked rows call `resolveLinkedAlbumId` (`@wxyc/database`, the same exact-match resolver `album-critic-reviews-etl/match.ts` uses) on the snapshot text, then fetch that resolved id's canonical fields. A miss drops the row. Guard: 0 resolved (when there were rotation rows to resolve) -> throw in steady state, evaluated regardless of `DRY_RUN`; demoted under `--backfill`. +3. Fetch the play-arm candidates via the single injected `fetchPlayCandidates` — `job.ts` already selected `fetchRecentPlays` or `fetchAllPlayedAlbums` based on `--backfill`; the orchestrator never sees which arm ran. Guard: 0 play rows -> **not** a throw — loud error-level log + unconditional Sentry capture, exit stays 0 (see "Guards" below). +4. Concatenate resolved rotation releases first, then play-arm releases (`candidate_rows = resolved + recent_play_rows`). +5. Dedup to one row per `library.id` (`dedupeByLibraryId`) — tubafrenzy permits multiple active rotation rows per release, and a release can independently surface via both arms. +6. Two anti-joins (`antijoin.ts`): drop releases already carrying an `album_critic_reviews` row (`loadCoveredLibraryIds`), and drop releases already recorded in `uncovered_release_search_markers` (`loadHandedOffLibraryIds` — see "Found-nothing marker" below). +7. Cap the uncovered set at `UNCOVERED_MAX_RELEASES_PER_RUN`, immediately after the anti-join and **before** the `DRY_RUN` branch — see "Cap semantics" below. `capped_out = uncovered.length - capped.length`, computed here, logged when it fires. +8. `DRY_RUN` stops here: emits a locked-schema JSON report and returns, having made zero writes and no network calls beyond the read-only DB queries above — plus a Sentry capture if a guard escalated, since the guards deliberately carry no `DRY_RUN` exemption. +9. Render (`writer.renderSnapshot`) once from the **capped** list and write to disk (`writer.writeSnapshotFile`) — happens even when the capped set is empty. That empty case is local-only; step 10 declines to publish it. +10. Publish the same rendered content to research-data (`publish.ts`), **unless the capped set is empty** (see "Never publish an empty snapshot" below). A publish failure is caught, logged, and counted — never aborts the run; the local file write already succeeded and is this run's durable artifact regardless. +11. Record handoff markers (`markers.recordHandoffs`) for the **capped** list **only when the publish actually committed** — see "Found-nothing marker". + +## Cap semantics + +**One cap knob, one cap position.** `UNCOVERED_MAX_RELEASES_PER_RUN` (default `400`) truncates the uncovered set immediately after the anti-join, in both steady-state and `--backfill` modes — no separate backfill limit env var, and no SQL `LIMIT` in either play-arm query. A SQL `LIMIT` in the play arm would silently stall the drain: run 1 selects the top N by play count and marks them, run 2 re-selects the _identical_ top N, `filterUncovered` drops all of them as already-handed-off, and the job emits 0 rows forever — invisible in the report, since `uncovered` is pre-cap. Capping after the anti-join makes each run advance past what the previous run consumed. + +The capped list — not the uncovered list — is the single input to **every** downstream site: `renderSnapshot`, `writeSnapshot`, `publish`, and `recordHandoffs` all consume the same array, so the file on disk, the published snapshot, and the marker rows describe the identical release set. Feeding `recordHandoffs` the uncapped list would permanently strand the truncated tail — exactly the failure mode "Backfill pacing" below exists to prevent. + +`capped_out` is computed at the cap site (`uncovered.length - capped.length`), before the `DRY_RUN` branch — never derived from `written` (which is `0` under `DRY_RUN`, since nothing is written). Logged loudly whenever it fires, and reported under `DRY_RUN` too — the exact mode an operator uses to check whether the cap is firing. + +One legibility caveat: rotation permanently occupies the head of the capped list (rotation-first concat, order-preserving dedup and anti-join), so if active rotation ever exceeded the cap, a run would emit zero play-arm rows while `capped_out` looked healthy and non-zero — a silently stalled drain. At today's ~300 active rotation against the 400 default this cannot happen; if rotation volume ever grows near the cap, compare `recent_play_rows` against what was actually written before trusting a backfill's progress. + +## One-time backlog vs. steady state + +A 30-day steady-state window on a weekly cron rolls over completely in ~4 runs — a candidate not handed off within those runs leaves the eligible set. Only **39.7%** of albums in a 30-day window get replayed within the following 60 days (measured), so most of what's evicted is lost for months or indefinitely. **Once markers are being written** (see "Precondition"), the eligible set per run is just the _new_-album rate — measured at ~75/week — so `UNCOVERED_MAX_RELEASES_PER_RUN` (400) is a safety valve, not the metering mechanism, and steady state costs ≈325 searches/month. + +The one-time historical backlog — 37,421 distinct linked albums ever played — is handled separately via `--backfill`, sourced from `album_plays` so the highest-play-count albums drain first: + +```bash +mkdir -p ./out +docker run --rm --env-file .env \ + -e UNCOVERED_MAX_RELEASES_PER_RUN=2000 \ + -v "$PWD/out:/uncovered-release-list/output" \ + --backfill +``` + +**The bind-mount is not optional while the publish credential is unprovisioned.** The snapshot file is the run's entire artifact, and `--rm` deletes the container — and its `/uncovered-release-list/output` — the moment the job exits, so without a mount there is nothing left to `docker cp` and the whole batch is lost. See "Pulling the file from a container" below. + +The `-e` override raises the cap per invocation — the plain env-file recipe alone gives an operator no cap lever, and 400/run would collide with the pacing contract below to make the 37,421-album backlog effectively undrainable. Draining all 37,421 is out of scope; backfill is prioritized and bounded on purpose. + +### Backfill pacing: one snapshot file + permanent markers = one invocation per consumer cycle + +The publish target is a single fixed path (`uncovered-releases.jsonl` on the `uncovered-releases-snapshot` branch), and each commit is a **whole-file replace**, not an append. Meanwhile `recordHandoffs` marks every published release permanently: publish-once, never retried. Combine the two and rapid repeated `--backfill` invocations have a failure mode the cap can't see: run it ten times back-to-back and 4,000 releases are marked handed-off while only the final 400 (or 2,000, or whatever the cap was set to) exist at branch HEAD. If research-data#16's consumer reads HEAD on its own cadence, the earlier batches are marked-but-never-searched — the exact permanent-drop failure the publish-gated marker design exists to prevent, arriving through the front door. + +**Operator contract: at most one `--backfill` invocation per consumer cycle** — run, confirm the consumer has processed that snapshot commit, then run the next batch. This coupling has been relayed onto [`research-data#16`](https://github.com/WXYC/research-data/issues/16) as a consumer design requirement ([the relay comment](https://github.com/WXYC/research-data/issues/16#issuecomment-5297380930)): if the consumer walks the snapshot branch's commit history instead of reading HEAD only, this pacing constraint disappears entirely. Until that ticket's design confirms that, the operator contract above is the safety. + +The weekly steady-state cron has the same coupling in principle but is benign in practice — at matched weekly cadences, each snapshot sits at HEAD for a full cycle before the next run replaces it. ### Never publish an empty snapshot -Publishing is a **whole-file replace of one fixed path**, and markers are publish-once. So an empty publish hands off nothing while overwriting the previous snapshot at research-data HEAD — whose releases are already permanently marked. If the consumer had not yet read that snapshot, those releases become marked-but-never-searched with no recovery path: precisely the failure the publish-gated marker design exists to prevent, arriving from the other direction. +The same whole-file-replace coupling, from the other direction. An empty publish hands off nothing while overwriting the previous snapshot at research-data HEAD — whose releases are already permanently marked. If the consumer had not yet read that snapshot, those releases become marked-but-never-searched with no recovery path: precisely the failure the publish-gated marker design exists to prevent. Holding the previous file instead costs at most one redundant re-read by the consumer, which is harmless — those releases are already marked, so they can never be re-offered. The job therefore logs `publish_skipped_empty` and leaves HEAD alone. The **local** write still happens unconditionally; nothing downstream reads it. +## Guards + +- **Zero active rotation** — hard throw in steady state (rotation is never genuinely empty in production; an empty read is a source regression). Demoted under `--backfill` to a loud error-level log + Sentry capture, and the run continues on the play arm alone: rotation is ~300 of ~37,721 backfill candidates, and a rotation-source regression mid-drain must not abort a run holding tens of thousands of valid play-arm candidates. +- **Zero resolved** — hard throw in steady state, evaluated regardless of `DRY_RUN` (a resolver regression must not hide behind a dry run). Demoted under `--backfill` the same way as the rotation guard above. +- **Zero recent plays** — **not a throw.** Loud error-level structured log + unconditional Sentry capture, exit stays 0, in both modes. A third guard carrying a `DRY_RUN` exemption would introduce exactly the hiding-place the other two guards exist to close — this module's convention is "evaluated regardless of `DRY_RUN`." An empty 30-day `flowsheet` window in prod is not an expected common case (it means the station stopped logging plays, or the query broke), so this escalates rather than staying silent — but it must never abort a run that may still hold valid rotation candidates. This is also what keeps the local `DRY_RUN=true` recipe below working: `dev_env/seed_db.sql` has no `flowsheet` rows, so an **error-level empty-plays log against the dev seed is expected, not a fault** — with no `SENTRY_DSN` configured locally, the capture is a no-op. + ## Found-nothing marker: a dedicated table, not a `source_key` convention ADR 0013 named two options for "so a release that came up empty isn't re-searched every cycle": a small tracking table, or a `source_key` convention analogous to `album-critic-reviews-etl`'s `manifest:${source}`. **This job uses a dedicated table** — `uncovered_release_search_markers` (migration 0156) — because every column `album_critic_reviews`' UPSERT natural key would need to carry (`source`, `source_url`, `snippet`) is `NOT NULL` and semantically "this IS a review"; recording "we looked, found nothing" there would mean inventing sentinel values for columns whose whole contract is a real review's attribution, polluting the exact table `GET /proxy/metadata/album` reads. The dedicated table keeps "has a review" (`album_critic_reviews`, real rows only) and "already handed off for search" (this table) as two independently-true, independently-anti-joined predicates. @@ -61,7 +141,7 @@ Semantics are **publish-once, never retried**: a row is written the moment a rel 1. `PUBLISH=true` — an explicit operator opt-in, not implied by the token's mere presence. 2. `RESEARCH_DATA_WRITE_TOKEN` — a fine-grained PAT scoped to `WXYC/research-data` with `Contents: Read and write`. **Deliberately a separate credential from `RESEARCH_DATA_TOKEN`** (`album-critic-reviews-etl`'s read-only manifest-fetch token) — that token's whole point, stated in its own README, is read-only; minting a distinct write-scoped token here keeps that invariant legible instead of quietly upgrading a read-only credential's effective scope. -**As of this job's initial ship, neither is provisioned.** Every run writes `uncovered-releases.jsonl` locally (an operator can pull it from the container and hand it to research-data by hand in the interim) but returns `{ attempted: false, committed: false }` from `publishSnapshot` and — per the found-nothing marker's publish-gated write above — records zero handoff markers, so nothing is silently lost by running before the token exists. Turning the real push on is a two-step ops task, not a code change: +**As of this job's initial ship, neither is provisioned.** Every run writes `uncovered-releases.jsonl` locally (an operator can pull it from the container and hand it to research-data by hand in the interim) but returns `{ attempted: false, committed: false }` from `publishSnapshot` and — per the found-nothing marker's publish-gated write above — records zero handoff markers, so nothing is silently lost by running before the token exists. **The widened candidate set's steady-state sizing (~325 searches/month) is conditional on this being provisioned** — see "Precondition" at the top of this README. Turning the real push on is a two-step ops task, not a code change: 1. **One-time, in `WXYC/research-data`:** create the `uncovered-releases-snapshot` branch (from `main`) and open a single PR from it to `main`. Every subsequent commit this module makes to that branch auto-updates the already-open PR — no branch/PR lifecycle management lives in this code. 2. **In Backend-Service's cron env:** provision `RESEARCH_DATA_WRITE_TOKEN` and set `PUBLISH=true`. @@ -73,36 +153,65 @@ Semantics are **publish-once, never retried**: a row is written the moment a rel npm run build --workspace=@wxyc/database --workspace=@wxyc/uncovered-release-list npm start --workspace=@wxyc/uncovered-release-list -# Dry run: fetch + resolve + dedup + both anti-joins + run guards, zero writes, zero network calls: +# Dry run: fetch + resolve + dedup + both anti-joins + cap + run guards; zero writes +# (network egress only if a guard escalates to Sentry): DRY_RUN=true npm start --workspace=@wxyc/uncovered-release-list + +# Dry-run preview of the backlog drain plan: +DRY_RUN=true npm start --workspace=@wxyc/uncovered-release-list -- --backfill ``` -The `DRY_RUN` report is a **locked schema** — exactly these keys, one JSON line on stdout; treat as an interface: +The `DRY_RUN` report is a **locked schema** — exactly these keys, one JSON line on stdout; treat as an interface. Widened by this amendment with four new keys (`backfill`, `recent_play_rows`, `candidate_rows`, `capped_out`) — an intentional widening of the locked interface, alongside the pre-existing keys whose _scope_ widened (`deduped`, `already_covered`, `already_handed_off` now cover both arms, not rotation alone). `backfill` is the in-band mode signal: under `--backfill`, `recent_play_rows` holds every linked album ever played, not a lookback-window count, and this key is how a reader tells the difference: ```json { "job": "uncovered-release-list", "dry_run": true, + "backfill": false, "active_rotation_rows": 0, "resolved": 0, "unresolved_dropped": 0, + "recent_play_rows": 0, + "candidate_rows": 0, "deduped": 0, "already_covered": 0, "already_handed_off": 0, - "uncovered": 0 + "uncovered": 0, + "capped_out": 0 } ``` +`active_rotation_rows`, `resolved`, `unresolved_dropped` stay rotation-scoped only (the play arm bypasses the resolve loop, so the zero-resolved throw message stays accurate as written). + +## Pulling the file from a container + +While the publish credential is unprovisioned, the local output file is the run's entire artifact. Under this job's `ENTRYPOINT` (direct `node dist/job.js`, cwd = the container's `/uncovered-release-list` WORKDIR), the default `OUTPUT_PATH` resolves to `/uncovered-release-list/output/uncovered-releases.jsonl`. + +**Bind-mount that directory — don't plan to copy it out afterward.** Every `docker run` recipe in this repo uses `--rm`, which deletes the container the instant the job exits, taking the output directory with it; a `docker cp` after the fact has nothing to copy from. Mount a host directory over the output path and the file is simply there when the run finishes: + +```bash +mkdir -p ./out +docker run --rm --env-file .env -v "$PWD/out:/uncovered-release-list/output" +cat ./out/uncovered-releases.jsonl +``` + +`docker cp` is only available if you deliberately ran **without** `--rm`, leaving the exited container around to copy from: + +```bash +docker cp :/uncovered-release-list/output/uncovered-releases.jsonl ./uncovered-releases.jsonl +``` + ## Invariants (do not weaken) - **Never delete.** The job has no delete path against any table; `uncovered_release_search_markers` is UPSERT-only, and `album_critic_reviews` is read-only from this job's side. - **Idempotent.** Re-running with `PUBLISH` off (today's default) always re-derives and re-writes the same snapshot for the same DB state — harmless. Once `PUBLISH` is on, a repeated real run makes zero NEW handoff-marker writes for releases already marked (the anti-join excludes them before they're ever re-offered). -- **Exact match only** (via the shared `resolveLinkedAlbumId`). Do not loosen to fuzzy/pg_trgm — see `album-critic-reviews-etl/README.md`'s identical invariant; loosening it here would corrupt the canonical `(artist, album)` pair the whole downstream pipeline trusts. +- **Exact match only** (via the shared `resolveLinkedAlbumId` for the rotation arm; the play arm needs no resolve step at all, since a linked play already knows its `library.id`). Do not loosen to fuzzy/pg_trgm — see `album-critic-reviews-etl/README.md`'s identical invariant; loosening it here would corrupt the canonical `(artist, album)` pair the whole downstream pipeline trusts. - **Markers are publish-gated, not write-gated.** Do not move `recordHandoffs` earlier in the pipeline (e.g., right after computing `uncovered`) — see "Found-nothing marker" for why that would silently and permanently drop releases that were never actually handed off. +- **One cap knob, one cap position.** Do not add a separate backfill limit env var or a SQL `LIMIT` in either play-arm query — see "Cap semantics" and "Backfill pacing" for why either would silently stall the drain. ## Related - [BS#1877](https://github.com/WXYC/Backend-Service/issues/1877) — this job's ticket. -- [ADR 0013](../../docs/adr/0013-search-augmented-critic-review-discovery.md) — the design this job implements one piece of ("Uncovered-release list handoff" section). +- [ADR 0013](../../docs/adr/0013-search-augmented-critic-review-discovery.md) — the design this job implements one piece of ("Uncovered-release list handoff" section, amended for the candidate-set widening). - [BS#1830](https://github.com/WXYC/Backend-Service/issues/1830) / [`jobs/album-critic-reviews-etl/README.md`](../album-critic-reviews-etl/README.md) — the structural donor and the downstream consumer of any review this job's handoff eventually produces. -- [`WXYC/research-data#16`](https://github.com/WXYC/research-data/issues/16) — the `search` crawl-mode ticket this job's output feeds; the snapshot schema above is coordinated with it. +- [`WXYC/research-data#16`](https://github.com/WXYC/research-data/issues/16) — the `search` crawl-mode ticket this job's output feeds; the snapshot schema above is coordinated with it, as is the backfill-pacing contract. diff --git a/jobs/uncovered-release-list/job.ts b/jobs/uncovered-release-list/job.ts index 1058292a9..146f29958 100644 --- a/jobs/uncovered-release-list/job.ts +++ b/jobs/uncovered-release-list/job.ts @@ -2,36 +2,46 @@ * Entry point for the uncovered-release-list job (BS#1877, ADR 0013's * "uncovered-release list handoff", sibling to `jobs/album-critic-reviews-etl`). * - * Weekly: computes the `rotation × album_critic_reviews` anti-join (current - * active rotation releases with zero critic reviews), further anti-joined - * against releases already handed off for search at least once + * Weekly: computes the `(active rotation ∪ recently played) × album_critic_reviews` + * anti-join (current active rotation releases and recently-played linked + * albums with zero critic reviews), further anti-joined against releases + * already handed off for search at least once * (`uncovered_release_search_markers`, migration 0156 — the "searched, * found nothing" marker), writes the result as `uncovered-releases.jsonl`, * and commits it to `WXYC/research-data` where the `search` crawl mode * (RD#16) reads it. See `orchestrate.ts` for the full pipeline shape and * `publish.ts` for the handoff mechanism + the credential it needs. * - * Read-only against Backend-Service's own DB (rotation + album_critic_reviews - * + the new markers table) plus one write path: a git commit to a repo - * Backend-Service doesn't otherwise touch. No new outbound web-egress beyond - * that GitHub API call, no new authenticated endpoint on Backend-Service — - * keeps this Project #32 freeze-compatible per the ADR. + * Read-only against Backend-Service's own DB (rotation + flowsheet/album_plays + * + album_critic_reviews + the markers table) plus one write path: a git + * commit to a repo Backend-Service doesn't otherwise touch. No new outbound + * web-egress beyond that GitHub API call, no new authenticated endpoint on + * Backend-Service — keeps this Project #32 freeze-compatible per the ADR. * * Run procedure: cron-registered via deploy-base's `cron-schedule` from * package.json. Container runs to completion. No cooperative pause: the job - * writes only its own table (`uncovered_release_search_markers`) plus a - * remote file, never flowsheet-adjacent, mirroring every sibling ETL's - * rationale. + * reads `flowsheet` but never writes it — no pause needed, mirroring every + * sibling ETL's rationale (it writes only its own table, + * `uncovered_release_search_markers`, plus a remote file). * * Required env: the standard `DB_*` set. Optional: `DRY_RUN`, `OUTPUT_PATH`, - * `PUBLISH`, `RESEARCH_DATA_WRITE_TOKEN`, `SENTRY_DSN`. See docs/env-vars.md. - * Unlike `album-critic-reviews-etl`, no token is REQUIRED to run — the DB - * read + local file write both work with zero external credentials; only - * the actual cross-repo push needs one. + * `PUBLISH`, `RESEARCH_DATA_WRITE_TOKEN`, `SENTRY_DSN`, + * `UNCOVERED_PLAY_LOOKBACK_DAYS`, `UNCOVERED_MAX_RELEASES_PER_RUN`. See + * docs/env-vars.md. Unlike `album-critic-reviews-etl`, no token is REQUIRED + * to run — the DB read + local file write both work with zero external + * credentials; only the actual cross-repo push needs one. + * + * `--backfill`: drop the steady-state play arm's trailing window and drain + * every linked album ever played (`plays.fetchAllPlayedAlbums`, sourced from + * the `album_plays` MV), play-count desc. Rotation-lane guards demote from a + * hard throw to a log+Sentry escalation in this mode — see orchestrate.ts. + * `UNCOVERED_MAX_RELEASES_PER_RUN` is the one cap knob in both modes; an + * operator raises it per backfill invocation (see README.md). */ -import { closeDatabaseConnection } from '@wxyc/database'; +import { closeDatabaseConnection, requirePositiveInt } from '@wxyc/database'; import { runJob, resolveDryRun } from './orchestrate.js'; import { fetchActiveRotationRows, resolveCanonicalRelease } from './rotation.js'; +import { fetchRecentPlays, fetchAllPlayedAlbums } from './plays.js'; import { loadCoveredLibraryIds, loadHandedOffLibraryIds } from './antijoin.js'; import { recordHandoffs } from './markers.js'; import { writeSnapshotFile, resolveOutputPath } from './writer.js'; @@ -40,15 +50,64 @@ import { initLogger, log, captureError, closeLogger } from './logger.js'; const JOB_NAME = 'uncovered-release-list'; +/** Trailing window (days) for the steady-state play arm. Ignored under `--backfill`. */ +export const PLAY_LOOKBACK_DAYS_ENV = 'UNCOVERED_PLAY_LOOKBACK_DAYS'; +export const PLAY_LOOKBACK_DAYS_DEFAULT = 30; + +/** Post-anti-join cap, both modes — the one cap knob. */ +export const MAX_RELEASES_PER_RUN_ENV = 'UNCOVERED_MAX_RELEASES_PER_RUN'; +export const MAX_RELEASES_PER_RUN_DEFAULT = 400; + +export interface UncoveredJobOptions { + backfill: boolean; + playLookbackDays: number; + maxReleasesPerRun: number; +} + +/** + * Pure option parsing — the one operator-facing seam for this job's mode, + * unit-testable without a run. Mirrors `concerts-poster-enrichment/job.ts`'s + * `enrichJobOptions` shape, but deliberately does NOT carry a `dryRun` + * field: this job resolves DRY_RUN from the environment (`resolveDryRun`, + * orchestrate.ts), documented as an env var in README.md and + * docs/env-vars.md — copying the donor's `--dry-run` flag would create a + * second, divergent dry-run switch. + */ +export const uncoveredJobOptions = ( + env: NodeJS.ProcessEnv = process.env, + args: string[] = process.argv +): UncoveredJobOptions => { + const ctx = { context: JOB_NAME }; + return { + backfill: args.includes('--backfill'), + playLookbackDays: requirePositiveInt( + env[PLAY_LOOKBACK_DAYS_ENV], + PLAY_LOOKBACK_DAYS_ENV, + PLAY_LOOKBACK_DAYS_DEFAULT, + ctx + ), + maxReleasesPerRun: requirePositiveInt( + env[MAX_RELEASES_PER_RUN_ENV], + MAX_RELEASES_PER_RUN_ENV, + MAX_RELEASES_PER_RUN_DEFAULT, + ctx + ), + }; +}; + const main = async (): Promise => { initLogger({ repo: 'Backend-Service', tool: JOB_NAME }); try { + const options = uncoveredJobOptions(); const dryRun = resolveDryRun(); const outputPath = resolveOutputPath(); const publishEnabled = resolvePublishEnabled(); const writeToken = resolveResearchDataWriteToken(); log('info', 'init', `${JOB_NAME} initialized`, { dry_run: dryRun, + backfill: options.backfill, + play_lookback_days: options.playLookbackDays, + max_releases_per_run: options.maxReleasesPerRun, output_path: outputPath, publish_enabled: publishEnabled, has_write_token: writeToken !== null, @@ -57,13 +116,18 @@ const main = async (): Promise => { await runJob({ fetchActiveRotation: fetchActiveRotationRows, resolveCanonical: resolveCanonicalRelease, + fetchPlayCandidates: options.backfill + ? () => fetchAllPlayedAlbums() + : () => fetchRecentPlays(options.playLookbackDays), loadCovered: loadCoveredLibraryIds, loadHandedOff: loadHandedOffLibraryIds, writeSnapshot: writeSnapshotFile, recordHandoffs, publish: (content) => publishSnapshot(content, { token: writeToken, publishEnabled }), outputPath, + maxReleasesPerRun: options.maxReleasesPerRun, dryRun, + backfill: options.backfill, }); } catch (error) { log('error', 'failed', `${JOB_NAME} failed`, { error_message: (error as Error).message }); diff --git a/jobs/uncovered-release-list/orchestrate.ts b/jobs/uncovered-release-list/orchestrate.ts index cdeda15c7..041d099f7 100644 --- a/jobs/uncovered-release-list/orchestrate.ts +++ b/jobs/uncovered-release-list/orchestrate.ts @@ -1,52 +1,88 @@ /** * Orchestrator for jobs/uncovered-release-list (BS#1877, ADR 0013's - * "uncovered-release list handoff"). + * "uncovered-release list handoff", widened by the "rotation ∪ recently + * played" amendment). * * Run shape: * 1. Fetch every active rotation row (rotation.ts's COALESCE join). - * Guard: zero rows -> throw (an empty active-rotation read is a source - * regression, not a healthy "nothing to do" week — rotation is never - * genuinely empty in production). - * 2. Resolve each row to a `CanonicalRelease` (library-canonical + * Guard: zero rows -> throw in steady state (an empty active-rotation + * read is a source regression, not a healthy "nothing to do" week — + * rotation is never genuinely empty in production). Under `--backfill` + * this demotes to a loud log + Sentry capture and the run continues on + * the play arm alone — rotation is ~300 of ~37,721 backfill candidates, + * and a rotation-source regression mid-drain must not abort a run + * holding tens of thousands of valid play-arm candidates. + * 2. Resolve each rotation row to a `CanonicalRelease` (library-canonical * `(artist, album, library_id)`), sequentially — mirrors * `album-critic-reviews-etl/orchestrate.ts`'s per-item `matchItem` * loop, avoiding a burst of concurrent connections against the shared * pool for what is a small (~300-row), infrequent (weekly) job. Guard: - * zero resolved -> throw (evaluated regardless of DRY_RUN — a resolver - * regression must not hide behind a dry run). - * 3. Dedup to one row per `library.id` (`rotation.dedupeByLibraryId`) — - * tubafrenzy permits multiple active rotation rows per release. - * 4. Two anti-joins (`antijoin.ts`): drop releases that already have an + * zero resolved (when there WERE rotation rows to resolve) -> throw in + * steady state, evaluated regardless of DRY_RUN — a resolver + * regression must not hide behind a dry run. Demotes under + * `--backfill` the same way as guard 1. + * 3. Fetch the play-arm candidates via the single injected + * `fetchPlayCandidates` — already-canonical `CanonicalRelease[]`, no + * resolve step needed (see plays.ts). This orchestrator never sees + * which arm (`fetchRecentPlays` vs `fetchAllPlayedAlbums`) is wired in; + * `job.ts` picks based on `--backfill`. Guard: zero play rows -> NOT a + * throw — loud structured log + unconditional Sentry capture, exit + * stays 0. An empty 30-day `flowsheet` window in prod is not an + * expected common case (it means the station stopped logging plays or + * the query broke), so this escalates rather than staying silent; but + * it must not abort a run that may still hold valid rotation + * candidates. This also keeps the local `DRY_RUN=true` recipe working + * against the dev seed, which has no `flowsheet` rows. + * 4. Concatenate resolved rotation releases first, then play-arm + * releases — an album in both keeps its rotation-arm entry once + * dedup runs (same `library_id`, same canonical pair, so semantically + * free), but this pins determinism and keeps rotation at the head of + * the cap ordering. + * 5. Dedup to one row per `library.id` (`rotation.dedupeByLibraryId`) — + * tubafrenzy permits multiple active rotation rows per release, and a + * release can independently surface via both arms. + * 6. Two anti-joins (`antijoin.ts`): drop releases that already have an * `album_critic_reviews` row, and drop releases already recorded in * `uncovered_release_search_markers` (already handed off at least * once — see that table's schema.ts doc comment for why this alone is * the "searched, found nothing" marker, with no live feedback needed * from research-data). - * 5. DRY_RUN stops here: emits the locked-schema JSON report on stdout - * and returns, having made zero writes and zero network calls beyond - * the read-only DB queries above. - * 6. Render the snapshot (`writer.renderSnapshot`) ONCE and write it to - * disk (`writer.writeSnapshotFile`) — happens even when the uncovered - * set is empty; an empty LOCAL `uncovered-releases.jsonl` is itself a - * meaningful, idempotent "nothing new to search this cycle" artifact, - * not a skipped step (unlike the sibling ETL's `nothing_new` early - * return, which has no file to write either way). The empty case is - * local-only: step 7 declines to PUBLISH it. - * 7. Publish (`publish.ts`) the SAME rendered content to research-data, - * UNLESS the set is empty — publishing is a whole-file replace of one - * fixed path, so an empty publish hands off nothing while destroying a - * previous snapshot whose releases are already permanently marked. + * 7. Cap: truncate the uncovered set to `maxReleasesPerRun` + * (`UNCOVERED_MAX_RELEASES_PER_RUN`), immediately after the anti-join + * and BEFORE the DRY_RUN branch — after that branch, `capped_out` + * would always report 0 in exactly the mode an operator uses to check + * whether the cap is firing. One cap knob, one cap position, in both + * modes; no separate backfill limit, no SQL `LIMIT` (see plays.ts and + * the README's "Cap placement" / backfill-pacing sections for why a + * SQL `LIMIT` would silently stall the drain). + * 8. DRY_RUN stops here: emits the locked-schema JSON report on stdout + * and returns, having made zero writes and no network calls beyond + * the read-only DB queries above — plus a Sentry capture if a guard + * escalated (the guards deliberately carry no DRY_RUN exemption). + * 9. Render the snapshot (`writer.renderSnapshot`) ONCE from the CAPPED + * list and write it to disk (`writer.writeSnapshotFile`) — happens + * even when the capped set is empty; an empty LOCAL + * `uncovered-releases.jsonl` is itself a meaningful, idempotent + * "nothing new to search this cycle" artifact, not a skipped step. + * The empty case is local-only: step 10 declines to PUBLISH it. + * 10. Publish (`publish.ts`) the SAME rendered content to research-data, + * UNLESS the capped set is empty — publishing is a whole-file replace + * of one fixed path, so an empty publish hands off nothing while + * destroying a previous snapshot whose releases are already + * permanently marked. * A publish failure (thrown) is caught and counted (`publish_error`), * never aborts the run — the local file already succeeded and is this * run's durable artifact regardless of whether the cross-repo push * landed. - * 8. Record handoff markers (`markers.recordHandoffs`) ONLY when the - * publish actually committed. Marking a release "handed off" before - * its row ever reached research-data would permanently drop it from - * every future cycle's anti-join without it ever having been searched - * — the exact failure mode the "found nothing" marker exists to - * prevent, just triggered by a disabled/failed publish instead of a - * real empty search. See `markers.ts`'s doc comment. + * 11. Record handoff markers (`markers.recordHandoffs`) for the CAPPED + * list ONLY when the publish actually committed. Marking a release + * "handed off" before its row ever reached research-data would + * permanently drop it from every future cycle's anti-join without it + * ever having been searched — the exact failure mode the "found + * nothing" marker exists to prevent. Using the capped (not uncovered) + * list here specifically prevents permanently stranding the truncated + * tail: the file on disk, the published snapshot, and the marker rows + * always describe the identical release set. * * Dependencies are injected so unit tests drive the orchestrator without a * network, a DB, or a filesystem; `job.ts` wires the real implementations. @@ -61,6 +97,7 @@ const JOB_NAME = 'uncovered-release-list'; export type FetchActiveRotationFn = () => Promise; export type ResolveCanonicalFn = (row: RotationRow) => Promise; +export type FetchPlayCandidatesFn = () => Promise; export type LoadLibraryIdSetFn = (libraryIds: number[]) => Promise>; export type WriteSnapshotFn = (content: string, path: string) => Promise<{ path: string }>; export type RecordHandoffsFn = (libraryIds: number[]) => Promise; @@ -70,11 +107,18 @@ export interface Totals { active_rotation_rows: number; resolved: number; unresolved_dropped: number; + /** Play-arm rows (already canonical) — new. */ + recent_play_rows: number; + /** `resolved + recent_play_rows`, post-concat/pre-dedup — new. */ + candidate_rows: number; deduped: number; already_covered: number; already_handed_off: number; + /** Post-anti-join, pre-cap. */ uncovered: number; - /** Lines written to the snapshot file (== `uncovered` on a real run). */ + /** `uncovered - capped`, computed at the cap site — new. */ + capped_out: number; + /** Lines written to the snapshot file (== the capped length on a real run). */ written: number; published: boolean; marked_handed_off: number; @@ -84,10 +128,13 @@ const emptyTotals = (): Totals => ({ active_rotation_rows: 0, resolved: 0, unresolved_dropped: 0, + recent_play_rows: 0, + candidate_rows: 0, deduped: 0, already_covered: 0, already_handed_off: 0, uncovered: 0, + capped_out: 0, written: 0, published: false, marked_handed_off: 0, @@ -96,14 +143,22 @@ const emptyTotals = (): Totals => ({ export interface RunOptions { fetchActiveRotation: FetchActiveRotationFn; resolveCanonical: ResolveCanonicalFn; + fetchPlayCandidates: FetchPlayCandidatesFn; loadCovered: LoadLibraryIdSetFn; loadHandedOff: LoadLibraryIdSetFn; writeSnapshot: WriteSnapshotFn; recordHandoffs: RecordHandoffsFn; publish: PublishFn; outputPath: string; + /** `UNCOVERED_MAX_RELEASES_PER_RUN` — post-anti-join cap, both modes. */ + maxReleasesPerRun: number; /** Resolved from DRY_RUN by job.ts; injectable for tests. */ dryRun?: boolean; + /** True under `--backfill`; demotes the two rotation-lane guards (zero + * active rotation, zero resolved) from a hard throw to the same + * log+Sentry escalation the zero-plays guard uses, since a backfill run + * must be able to proceed on the play arm alone. Defaults false. */ + backfill?: boolean; } /** Donor-standard DRY_RUN resolver: locked truthy set `true|1` @@ -117,15 +172,28 @@ export const resolveDryRun = (raw: string | undefined = process.env.DRY_RUN): bo export const runJob = async (opts: RunOptions): Promise => { const totals = emptyTotals(); const dryRun = opts.dryRun ?? false; + const backfill = opts.backfill ?? false; - log('info', 'started', `${JOB_NAME} starting`, { dry_run: dryRun }); + log('info', 'started', `${JOB_NAME} starting`, { dry_run: dryRun, backfill }); // 1. Fetch active rotation. const rows = await opts.fetchActiveRotation(); totals.active_rotation_rows = rows.length; if (totals.active_rotation_rows === 0) { - throw new Error('active rotation read returned 0 rows — treating as a source regression, not a healthy empty week'); + const message = 'active rotation read returned 0 rows'; + if (backfill) { + log( + 'error', + 'rotation_empty_backfill', + `${JOB_NAME}: ${message} under --backfill — rotation is ~300 of ~37,721 backfill candidates; ` + + 'continuing the drain on the play arm alone', + {} + ); + captureError(new Error(`${JOB_NAME}: ${message} under --backfill`), 'rotation_empty_backfill'); + } else { + throw new Error(`${message} — treating as a source regression, not a healthy empty week`); + } } // 2. Resolve, sequentially. @@ -136,19 +204,50 @@ export const runJob = async (opts: RunOptions): Promise => { totals.resolved = resolved.filter((release) => release !== null).length; totals.unresolved_dropped = rows.length - totals.resolved; - // Guard: zero resolved. Evaluated regardless of DRY_RUN. - if (totals.resolved === 0) { - throw new Error( - `0 of ${rows.length} active rotation rows resolved to a library.id — ` + - 'treating as a resolver regression, not a successful run' - ); + // Guard: zero resolved, only meaningful when there were rotation rows to + // resolve in the first place (rows.length === 0 is guard 1's condition, + // already handled above — checking it again here would double-escalate + // the identical root cause under --backfill). Evaluated regardless of + // DRY_RUN. + if (rows.length > 0 && totals.resolved === 0) { + const message = `0 of ${rows.length} active rotation rows resolved to a library.id`; + if (backfill) { + log( + 'error', + 'resolve_empty_backfill', + `${JOB_NAME}: ${message} under --backfill — continuing the drain on the play arm alone`, + {} + ); + captureError(new Error(`${JOB_NAME}: ${message} under --backfill`), 'resolve_empty_backfill'); + } else { + throw new Error(`${message} — treating as a resolver regression, not a successful run`); + } + } + + // 3. Fetch play-arm candidates. Mode-blind: job.ts already selected + // fetchRecentPlays or fetchAllPlayedAlbums. + const recentPlays = await opts.fetchPlayCandidates(); + totals.recent_play_rows = recentPlays.length; + + // Guard: zero play rows. NOT a throw — loud log + unconditional Sentry + // capture, exit stays 0. See the module docstring's step 3 for why this + // lane never throws (mirrors the local DRY_RUN dev-seed recipe). + if (totals.recent_play_rows === 0) { + const message = `${JOB_NAME}: play-arm candidate fetch returned 0 rows`; + log('error', 'plays_empty', message, {}); + captureError(new Error(message), 'plays_empty'); } - // 3. Dedup. - const deduped = dedupeByLibraryId(resolved); + // 4. Concat: rotation-resolved releases first, then play-arm releases. + const resolvedReleases = resolved.filter((release): release is CanonicalRelease => release !== null); + const candidates = [...resolvedReleases, ...recentPlays]; + totals.candidate_rows = candidates.length; + + // 5. Dedup. + const deduped = dedupeByLibraryId(candidates); totals.deduped = deduped.length; - // 4. Anti-joins. + // 6. Anti-joins. const libraryIds = deduped.map((release) => release.libraryId); const covered = await opts.loadCovered(libraryIds); const handedOff = await opts.loadHandedOff(libraryIds); @@ -159,25 +258,41 @@ export const runJob = async (opts: RunOptions): Promise => { const uncovered = filterUncovered(deduped, covered, handedOff); totals.uncovered = uncovered.length; - // 5. DRY_RUN stops here. + // 7. Cap — post-anti-join, pre-DRY_RUN. See the module docstring's step 7 + // and the README's "Cap placement" section for why this exact position. + const capped = uncovered.slice(0, opts.maxReleasesPerRun); + totals.capped_out = uncovered.length - capped.length; + if (totals.capped_out > 0) { + log('warn', 'capped', `${JOB_NAME}: cap fired — ${totals.capped_out} uncovered release(s) held back this run`, { + uncovered: totals.uncovered, + max_releases_per_run: opts.maxReleasesPerRun, + capped_out: totals.capped_out, + }); + } + + // 8. DRY_RUN stops here. if (dryRun) { const report = { job: JOB_NAME, dry_run: true, + backfill, active_rotation_rows: totals.active_rotation_rows, resolved: totals.resolved, unresolved_dropped: totals.unresolved_dropped, + recent_play_rows: totals.recent_play_rows, + candidate_rows: totals.candidate_rows, deduped: totals.deduped, already_covered: totals.already_covered, already_handed_off: totals.already_handed_off, uncovered: totals.uncovered, + capped_out: totals.capped_out, }; process.stdout.write(JSON.stringify(report) + '\n'); - log('info', 'finished', `${JOB_NAME} dry run done (no writes, no network calls)`, { ...totals }); + log('info', 'finished', `${JOB_NAME} dry run done (no writes)`, { ...totals }); return totals; } - if (uncovered.length === 0) { + if (capped.length === 0) { log('info', 'nothing_new', `${JOB_NAME}: no uncovered releases this cycle`, { deduped: totals.deduped, already_covered: totals.already_covered, @@ -185,13 +300,14 @@ export const runJob = async (opts: RunOptions): Promise => { }); } - // 6. Render + write once; publish and the local file share this exact string. - const content = renderSnapshot(uncovered); + // 9. Render + write once, from the CAPPED list; publish and the local + // file share this exact string. + const content = renderSnapshot(capped); const writeResult = await opts.writeSnapshot(content, opts.outputPath); - totals.written = uncovered.length; + totals.written = capped.length; log('info', 'wrote_snapshot', `${JOB_NAME}: wrote ${totals.written} row(s)`, { path: writeResult.path }); - // 7. Publish, isolated — but NEVER publish an empty snapshot. + // 10. Publish, isolated — but NEVER publish an empty snapshot. // // Publishing is a whole-file replace of one fixed path, and markers are // publish-once. An empty publish therefore buys nothing (there is no @@ -199,15 +315,20 @@ export const runJob = async (opts: RunOptions): Promise => { // research-data HEAD — whose releases are already permanently marked. If // the consumer had not yet read that snapshot, those releases become // marked-but-never-searched with no recovery path: exactly the failure - // the publish-gated marker design exists to prevent. Holding the previous - // file costs at most a redundant re-read by the consumer, which is - // harmless (its releases are already marked, so they cannot be re-offered). + // the publish-gated marker design exists to prevent, and the same hazard + // the README's "Backfill pacing" section bounds from the other direction. + // Holding the previous file costs at most a redundant re-read by the + // consumer, which is harmless (its releases are already marked, so they + // cannot be re-offered). + // + // Keyed on `capped`, not `uncovered` — `capped` is what was rendered, + // written, and would be published, so it is the set this decision is about. // // The LOCAL write above still happens unconditionally — an empty local // `uncovered-releases.jsonl` is a meaningful "nothing new this cycle" // artifact, and nothing downstream reads it. let publishOutcome: PublishOutcome; - if (uncovered.length === 0) { + if (capped.length === 0) { publishOutcome = { attempted: false, committed: false, reason: 'empty snapshot: nothing to hand off' }; log('info', 'publish_skipped_empty', `${JOB_NAME}: nothing to publish; leaving the previous snapshot in place`); } else { @@ -221,9 +342,9 @@ export const runJob = async (opts: RunOptions): Promise => { } totals.published = publishOutcome.committed; - // 8. Mark handoffs ONLY on a real commit. + // 11. Mark handoffs for the CAPPED list ONLY on a real commit. if (publishOutcome.committed) { - totals.marked_handed_off = await opts.recordHandoffs(uncovered.map((release) => release.libraryId)); + totals.marked_handed_off = await opts.recordHandoffs(capped.map((release) => release.libraryId)); } else { log( 'info', diff --git a/jobs/uncovered-release-list/package.json b/jobs/uncovered-release-list/package.json index 50e7c3ead..45e88de23 100644 --- a/jobs/uncovered-release-list/package.json +++ b/jobs/uncovered-release-list/package.json @@ -2,7 +2,7 @@ "name": "@wxyc/uncovered-release-list", "cron-schedule": "40 7 * * 0", "version": "1.0.0", - "description": "WXYC weekly job (BS#1877, ADR 0013): compute the rotation x album_critic_reviews anti-join (further anti-joined against uncovered_release_search_markers, the 'searched, found nothing' marker), write uncovered-releases.jsonl, and commit it to WXYC/research-data for the search crawl mode to consume.", + "description": "WXYC weekly job (BS#1877, ADR 0013): compute the (active rotation ∪ recently played) x album_critic_reviews anti-join (further anti-joined against uncovered_release_search_markers, the 'searched, found nothing' marker), write uncovered-releases.jsonl, and commit it to WXYC/research-data for the search crawl mode to consume. --backfill drains every linked album ever played via the album_plays MV.", "type": "module", "main": "./dist/job.js", "scripts": { diff --git a/jobs/uncovered-release-list/plays.ts b/jobs/uncovered-release-list/plays.ts new file mode 100644 index 000000000..bfacf9c95 --- /dev/null +++ b/jobs/uncovered-release-list/plays.ts @@ -0,0 +1,114 @@ +/** + * Play-arm candidate source for jobs/uncovered-release-list (BS#1877, the + * "widen the candidate set from active rotation to rotation ∪ recently + * played" amendment to ADR 0013's "uncovered-release list handoff"). 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 a rotation-only candidate set never + * sees. This module is the second candidate arm alongside `rotation.ts`. + * + * Unlike `rotation.ts`, this module returns `CanonicalRelease[]` directly — + * never `RotationRow[]`. `RotationRow` requires a non-nullable + * `rotationId: number` a play row has no value for, and a linked play + * already knows its `library.id` and joins its canonical strings in the + * same query, so it needs no `ResolveCanonicalFn` resolve step. + * + * Two arms, selected by run mode — `job.ts` wires the right one in, this + * module exports both and never itself decides which runs: + * + * - `fetchRecentPlays(lookbackDays)` — steady state. A trailing window + * over `flowsheet`, grouped and ranked by play count within the window. + * - `fetchAllPlayedAlbums()` — `--backfill`. Reuses the existing + * `album_plays` materialized view (migration 0059) instead of + * re-aggregating 2.6M `flowsheet` rows: `album_plays` is defined as + * exactly this arm's aggregate minus the time window (`SELECT album_id, + * count(*) FROM flowsheet WHERE entry_type='track' AND album_id IS NOT + * NULL GROUP BY album_id`), uniquely indexed on `album_id`, and kept + * fresh hourly by the API container (`ALBUM_PLAYS_REFRESH_INTERVAL_MS`). + * It is precisely the "ever, ranked by play count" source the backfill + * arm wants, already maintained. `album_popularity` (migration 0107) is + * rejected for both arms — it collapses pressings into a master-level + * signal, which would break the `library_id` the wire contract requires. + * + * Both arms filter `entry_type = 'track'` — this is `album_plays`'s own + * WHERE clause and the repo's canonical definition of "a play" (also + * `catalog-popularity-freetext-resolve/job.ts`). It also lets the steady- + * state arm use the only partial index on `add_time` + * (`flowsheet_track_add_time_idx ON (add_time DESC) WHERE entry_type = + * 'track'`, created by migration `0050_flowsheet-track-add-time-idx.sql`). + * Correctness is unaffected either way — + * non-track entries always have `album_id IS NULL` — but the predicate is + * included because it's canonical and it lets the index fire. + * + * Free-text (unlinked) plays are out of scope: ~43% of music plays have + * `album_id IS NULL` and cannot emit the wire contract's required + * `library_id: int`. Resolving them would mean running the DJ-typed string + * through a matcher, reintroducing exactly the wrong-album risk ADR 0013's + * canonical-pair design exists to avoid. `flowsheet_freetext_resolution` + * (migration 0106) is the future bridge, not built here. + * + * Both arms join `artists` (not `library.artist_name`'s denormalized + * column) — the same canonical-artist source `rotation.ts`'s + * `fetchCanonicalLibraryFields` uses, so every arm agrees on "canonical + * artist string" by construction. + */ +import { sql } from 'drizzle-orm'; +import { db } from '@wxyc/database'; +import { unwrapRows } from './db-utils.js'; +import type { CanonicalRelease } from './rotation.js'; + +const SCHEMA = (process.env.WXYC_SCHEMA_NAME || 'wxyc_schema').replace(/"/g, '""'); +const FLOWSHEET = sql.raw(`"${SCHEMA}"."flowsheet"`); +const LIBRARY = sql.raw(`"${SCHEMA}"."library"`); +const ARTISTS = sql.raw(`"${SCHEMA}"."artists"`); +const ALBUM_PLAYS = sql.raw(`"${SCHEMA}"."album_plays"`); + +interface RawPlayRow { + library_id: number; + artist_name: string; + album_title: string; +} + +const toCanonicalReleases = (rows: RawPlayRow[]): CanonicalRelease[] => + rows.map((row) => ({ libraryId: row.library_id, artist: row.artist_name, album: row.album_title })); + +/** + * Steady-state arm: linked plays within a trailing `lookbackDays`-day + * window, ranked play-count desc. The interval binds through the drizzle + * tagged template (`${lookbackDays}` below), not by string interpolation — + * mirrors `catalog-popularity-freetext-resolve/job.ts`'s `checkLiveActivity` + * (`now() - (interval '1 second' * ${lookbackSeconds})`). + */ +export const fetchRecentPlays = async (lookbackDays: number): Promise => { + const result: unknown = await db.execute(sql` + SELECT f."album_id" AS library_id, a."artist_name" AS artist_name, l."album_title" AS album_title, COUNT(*) AS play_count + FROM ${FLOWSHEET} f + JOIN ${LIBRARY} l ON l."id" = f."album_id" + JOIN ${ARTISTS} a ON a."id" = l."artist_id" + WHERE f."entry_type" = 'track' AND f."album_id" IS NOT NULL + AND f."add_time" >= now() - (interval '1 day' * ${lookbackDays}) + GROUP BY f."album_id", a."artist_name", l."album_title" + ORDER BY play_count DESC, f."album_id" ASC + `); + return toCanonicalReleases(unwrapRows(result)); +}; + +/** + * Backfill arm (`--backfill`): every linked album ever played, ranked + * play-count desc, sourced from the `album_plays` MV rather than + * re-aggregating `flowsheet` directly. No time window, no SQL `LIMIT` — + * capping is the orchestrator's job, at a single post-anti-join position + * (see orchestrate.ts and the README's "Cap placement" section); a SQL + * `LIMIT` here would silently stall the drain (the same top-N re-selected + * and re-anti-joined-away on every run). + */ +export const fetchAllPlayedAlbums = async (): Promise => { + const result: unknown = await db.execute(sql` + SELECT p."album_id" AS library_id, a."artist_name" AS artist_name, l."album_title" AS album_title, p."plays" AS play_count + FROM ${ALBUM_PLAYS} p + JOIN ${LIBRARY} l ON l."id" = p."album_id" + JOIN ${ARTISTS} a ON a."id" = l."artist_id" + ORDER BY p."plays" DESC, p."album_id" ASC + `); + return toCanonicalReleases(unwrapRows(result)); +}; diff --git a/jobs/uncovered-release-list/writer.ts b/jobs/uncovered-release-list/writer.ts index 50c51b38d..597331a3f 100644 --- a/jobs/uncovered-release-list/writer.ts +++ b/jobs/uncovered-release-list/writer.ts @@ -7,8 +7,9 @@ * * {"artist": , "album": , "library_id": } * - * `artist`/`album` are the library-canonical pair `rotation.ts` resolves - * (never the raw rotation/tubafrenzy snapshot text) — this is the whole + * `artist`/`album` are the library-canonical pair, from `rotation.ts`'s + * resolve loop or `plays.ts`'s joins (never the raw rotation/tubafrenzy + * snapshot text or a DJ-typed play string) — this is the whole * point of ADR 0013's design: a search-sourced review row can be written * with the SAME canonical pair from the start, so * `album-critic-reviews-etl`'s exact-match resolver hits trivially, diff --git a/tests/integration/uncovered-release-list.spec.js b/tests/integration/uncovered-release-list.spec.js index b1ecb9974..036354cb6 100644 --- a/tests/integration/uncovered-release-list.spec.js +++ b/tests/integration/uncovered-release-list.spec.js @@ -1,6 +1,7 @@ /** * Integration test for jobs/uncovered-release-list's DB-only surfaces - * (BS#1877, ADR 0013's "uncovered-release list handoff"). + * (BS#1877, ADR 0013's "uncovered-release list handoff", widened by the + * "rotation ∪ recently played" amendment). * * The unit suite (tests/unit/jobs/uncovered-release-list/*.test.ts) mocks * `db.execute` entirely, so it never exercises the real SQL shape. This @@ -22,6 +23,12 @@ * 4. ON DELETE CASCADE: dropping the library album evaporates its * uncovered_release_search_markers row, mirroring * album_critic_reviews' own cascade (critic-reviews-metadata.spec.js). + * 5. The play arm (plays.ts), both sub-arms: the steady-state windowed + * `flowsheet` aggregate (`fetchRecentPlays`) and the `--backfill` + * `album_plays` MV read (`fetchAllPlayedAlbums`), each scoped to this + * spec's own fixture rows (see `scopedWindowAggregate` / + * `scopedAlbumPlays` below for why the scoping is a deliberate, + * test-isolation-only divergence from the production queries). * * Pure SQL — does NOT import the TS job modules (babel-jest has no TS * transform registered for jest.config.json; see @@ -110,17 +117,103 @@ async function insertRotationRow(sql, { albumId = null, artistName = null, album return rows[0].id; } +// Distinct play_order base for this spec's play-arm fixtures, well clear of +// other specs' ranges (mirrors library-query-sort-plays.spec.js's 9200+ / +// catalog-popularity-freetext-resolve-enumerate.spec.js's 97671 convention). +let flowsheetPlayOrder = 98100; + +/** Insert one 'track' flowsheet row with an explicit add_time (BS#1877 + * play-arm fixtures — the caller controls whether it lands inside or + * outside a steady-state lookback window). */ +async function insertFlowsheetTrack(sql, { albumId, artistName, albumTitle, addTime }) { + flowsheetPlayOrder += 1; + const rows = await sql` + INSERT INTO ${sql(SCHEMA)}.flowsheet + (play_order, entry_type, artist_name, album_title, track_title, album_id, add_time) + VALUES + (${flowsheetPlayOrder}, 'track', ${artistName}, ${albumTitle}, 'BS1877 Track', ${albumId}, ${addTime}) + RETURNING id + `; + return rows[0].id; +} + +/** Insert one non-'track' flowsheet row (a talkset) — used to prove the + * play arm's `entry_type = 'track'` predicate excludes it from both + * sub-arms (the windowed aggregate and the album_plays MV, which shares + * the identical predicate in its own defining SELECT). */ +async function insertFlowsheetTalkset(sql, { albumId, addTime }) { + flowsheetPlayOrder += 1; + const rows = await sql` + INSERT INTO ${sql(SCHEMA)}.flowsheet (play_order, entry_type, album_id, add_time) + VALUES (${flowsheetPlayOrder}, 'talkset', ${albumId}, ${addTime}) + RETURNING id + `; + return rows[0].id; +} + +/** + * Mirrors plays.ts's fetchRecentPlays (the steady-state play arm), scoped + * to this spec's own `libraryIds` via an extra `AND f.album_id = ANY(...)` + * clause the production query does not carry. This is a *deliberate + * divergence*, not an oversight: the suite runs `--runInBand` over shared + * `flowsheet` state, so an unscoped `GROUP BY album_id` would see rows other + * specs (and other tests in this file) left behind and make the + * distinctness/ordering assertions below flaky. When plays.ts's SQL is + * edited, this query must follow, per the module header above. + */ +async function scopedWindowAggregate(sql, libraryIds, lookbackDays) { + return sql` + SELECT f."album_id" AS library_id, a."artist_name" AS artist_name, l."album_title" AS album_title, COUNT(*)::int AS play_count + FROM ${sql(SCHEMA)}.flowsheet f + JOIN ${sql(SCHEMA)}.library l ON l."id" = f."album_id" + JOIN ${sql(SCHEMA)}.artists a ON a."id" = l."artist_id" + WHERE f."entry_type" = 'track' AND f."album_id" IS NOT NULL + AND f."add_time" >= now() - (interval '1 day' * ${lookbackDays}) + AND f."album_id" = ANY(${libraryIds}) + GROUP BY f."album_id", a."artist_name", l."album_title" + ORDER BY play_count DESC, f."album_id" ASC + `; +} + +/** + * Mirrors plays.ts's fetchAllPlayedAlbums (the `--backfill` play arm, + * sourced from the `album_plays` MV), scoped to this spec's own + * `libraryIds` — same test-isolation-only divergence as + * scopedWindowAggregate above. Callers must `REFRESH MATERIALIZED VIEW + * album_plays` first via `sql.unsafe` (not a tagged template — postgres-js + * can't parameterize DDL), the same recipe + * library-query-sort-plays.spec.js and library-catalog-export.spec.js use. + */ +async function scopedAlbumPlays(sql, libraryIds) { + return sql` + SELECT p."album_id" AS library_id, a."artist_name" AS artist_name, l."album_title" AS album_title, p."plays" AS play_count + FROM ${sql(SCHEMA)}.album_plays p + JOIN ${sql(SCHEMA)}.library l ON l."id" = p."album_id" + JOIN ${sql(SCHEMA)}.artists a ON a."id" = l."artist_id" + WHERE p."album_id" = ANY(${libraryIds}) + ORDER BY p."plays" DESC, p."album_id" ASC + `; +} + describe('uncovered-release-list DB-only surfaces (real PG)', () => { let sql; const insertedArtistIds = []; const insertedLibraryIds = []; const insertedRotationIds = []; + const insertedFlowsheetIds = []; beforeAll(() => { sql = getTestDb(); }); afterAll(async () => { + if (insertedFlowsheetIds.length > 0) { + // Delete the play-arm fixture rows before touching library — flowsheet's + // album_id FK is ON DELETE SET NULL, so this ordering isn't required for + // referential integrity, but doing it first keeps the album_plays MV + // refresh below meaningful (nothing left to roll off). + await sql`DELETE FROM ${sql(SCHEMA)}.flowsheet WHERE id = ANY(${insertedFlowsheetIds})`; + } if (insertedRotationIds.length > 0) { await sql`DELETE FROM ${sql(SCHEMA)}.rotation WHERE id = ANY(${insertedRotationIds})`; } @@ -136,6 +229,11 @@ describe('uncovered-release-list DB-only surfaces (real PG)', () => { if (insertedArtistIds.length > 0) { await sql`DELETE FROM ${sql(SCHEMA)}.artists WHERE id = ANY(${insertedArtistIds})`; } + // Drop any now-stale backfill-arm fixture rows from the MV so they can't + // leak into a later test's read (mirrors library-catalog-export.spec.js's + // / library-query-sort-plays.spec.js's convention). Harmless no-op when + // this spec's backfill test never ran (or ran but nothing was refreshed). + await sql.unsafe(`REFRESH MATERIALIZED VIEW "${SCHEMA}".album_plays`); }); test('COALESCE join: an album_id-linked active row resolves canonical fields via rotation_library_view', async () => { @@ -259,4 +357,93 @@ describe('uncovered-release-list DB-only surfaces (real PG)', () => { `; expect(after[0].n).toBe(0); }); + + test('play arm — steady state: scopedWindowAggregate groups by album_id, excludes plays outside the lookback window and non-track entries, orders play_count desc', async () => { + const artistId = await insertArtist(sql, 'BS1877 Play Steady Artist'); + insertedArtistIds.push(artistId); + const albumA = await insertLibraryAlbum(sql, artistId, 'BS1877 Play Steady Album A'); + const albumB = await insertLibraryAlbum(sql, artistId, 'BS1877 Play Steady Album B'); + const albumC = await insertLibraryAlbum(sql, artistId, 'BS1877 Play Steady Album C Outside Window'); + insertedLibraryIds.push(albumA, albumB, albumC); + + const withinWindow = new Date(); + const outsideWindow = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000); // 40d ago, outside a 30d lookback + + for (let i = 0; i < 3; i += 1) { + insertedFlowsheetIds.push( + await insertFlowsheetTrack(sql, { + albumId: albumA, + artistName: 'BS1877 Play Steady Artist', + albumTitle: 'BS1877 Play Steady Album A', + addTime: withinWindow, + }) + ); + } + insertedFlowsheetIds.push( + await insertFlowsheetTrack(sql, { + albumId: albumB, + artistName: 'BS1877 Play Steady Artist', + albumTitle: 'BS1877 Play Steady Album B', + addTime: withinWindow, + }) + ); + for (let i = 0; i < 2; i += 1) { + insertedFlowsheetIds.push( + await insertFlowsheetTrack(sql, { + albumId: albumC, + artistName: 'BS1877 Play Steady Artist', + albumTitle: 'BS1877 Play Steady Album C Outside Window', + addTime: outsideWindow, + }) + ); + } + // A non-track entry within the window on album A must not inflate its count. + insertedFlowsheetIds.push(await insertFlowsheetTalkset(sql, { albumId: albumA, addTime: withinWindow })); + + const rows = await scopedWindowAggregate(sql, [albumA, albumB, albumC], 30); + + // Album C is outside the 30d window entirely; A outranks B on play_count. + expect(rows.map((r) => r.library_id)).toEqual([albumA, albumB]); + const byId = new Map(rows.map((r) => [r.library_id, r])); + expect(byId.get(albumA).play_count).toBe(3); + expect(byId.get(albumA).artist_name).toBe('BS1877 Play Steady Artist'); + expect(byId.get(albumA).album_title).toBe('BS1877 Play Steady Album A'); + expect(byId.get(albumB).play_count).toBe(1); + }); + + test('play arm — backfill: scopedAlbumPlays sources the album_plays MV (no time window), excludes an album with only a non-track entry', async () => { + const artistId = await insertArtist(sql, 'BS1877 Play Backfill Artist'); + insertedArtistIds.push(artistId); + const albumD = await insertLibraryAlbum(sql, artistId, 'BS1877 Play Backfill Album D'); + const albumE = await insertLibraryAlbum(sql, artistId, 'BS1877 Play Backfill Album E Talkset Only'); + insertedLibraryIds.push(albumD, albumE); + + // Well outside any steady-state lookback window — the backfill arm has + // no time predicate at all and must still see these plays. + const longAgo = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000); + + for (let i = 0; i < 5; i += 1) { + insertedFlowsheetIds.push( + await insertFlowsheetTrack(sql, { + albumId: albumD, + artistName: 'BS1877 Play Backfill Artist', + albumTitle: 'BS1877 Play Backfill Album D', + addTime: longAgo, + }) + ); + } + insertedFlowsheetIds.push(await insertFlowsheetTalkset(sql, { albumId: albumE, addTime: longAgo })); + + await sql.unsafe(`REFRESH MATERIALIZED VIEW "${SCHEMA}".album_plays`); + + const rows = await scopedAlbumPlays(sql, [albumD, albumE]); + + // Album E never appears: its only flowsheet row is a talkset, and + // album_plays' own defining SELECT carries entry_type='track' — no MV + // row exists at all for an album with zero track plays. + expect(rows.map((r) => r.library_id)).toEqual([albumD]); + expect(rows[0].play_count).toBe(5); + expect(rows[0].artist_name).toBe('BS1877 Play Backfill Artist'); + expect(rows[0].album_title).toBe('BS1877 Play Backfill Album D'); + }); }); diff --git a/tests/unit/jobs/uncovered-release-list/job.test.ts b/tests/unit/jobs/uncovered-release-list/job.test.ts new file mode 100644 index 000000000..1bfc7ad37 --- /dev/null +++ b/tests/unit/jobs/uncovered-release-list/job.test.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for jobs/uncovered-release-list job.ts (BS#1877, the "widen the + * candidate set" amendment) — the option parsing that selects the job's + * mode. + * + * `uncoveredJobOptions` maps the `--backfill` argv flag and the two new env + * knobs (`UNCOVERED_PLAY_LOOKBACK_DAYS`, `UNCOVERED_MAX_RELEASES_PER_RUN`) + * to the options `runJob` acts on. It is the one operator-facing seam for + * this job's mode — pinning it keeps that seam honest without a DB or + * network. Donor: `tests/unit/jobs/concerts-poster-enrichment/job.test.ts`, + * whose header states the "one operator-facing seam" rationale this mirrors. + * Deliberately does NOT test a `dryRun` field — this job has none; + * `resolveDryRun` (orchestrate.ts) is the sole DRY_RUN switch. + * + * `@wxyc/database` is auto-mocked by the unit jest config's moduleNameMapper + * (its `requirePositiveInt` re-exports the real validator), and importing + * job.ts is inert under jest because NODE_ENV==='test' gates the + * `void main()` auto-invoke. + */ +import { + uncoveredJobOptions, + PLAY_LOOKBACK_DAYS_DEFAULT, + PLAY_LOOKBACK_DAYS_ENV, + MAX_RELEASES_PER_RUN_DEFAULT, + MAX_RELEASES_PER_RUN_ENV, +} from '../../../../jobs/uncovered-release-list/job'; + +const argv = (...flags: string[]): string[] => ['node', 'job.js', ...flags]; + +describe('uncoveredJobOptions (BS#1877)', () => { + it('defaults to non-backfill with the documented lookback + cap', () => { + const opts = uncoveredJobOptions({}, argv()); + + expect(opts).toEqual({ + backfill: false, + playLookbackDays: PLAY_LOOKBACK_DAYS_DEFAULT, + maxReleasesPerRun: MAX_RELEASES_PER_RUN_DEFAULT, + }); + }); + + it('sets backfill only for the --backfill flag', () => { + expect(uncoveredJobOptions({}, argv('--backfill')).backfill).toBe(true); + expect(uncoveredJobOptions({}, argv()).backfill).toBe(false); + // A near-miss flag must NOT enable backfill. + expect(uncoveredJobOptions({}, argv('--backfil')).backfill).toBe(false); + }); + + it('carries no dryRun field — DRY_RUN stays env-resolved via resolveDryRun', () => { + const opts = uncoveredJobOptions({}, argv()); + expect(opts).not.toHaveProperty('dryRun'); + }); + + it('reads the play-lookback env override and rejects a non-positive value', () => { + expect(uncoveredJobOptions({ [PLAY_LOOKBACK_DAYS_ENV]: '14' }, argv()).playLookbackDays).toBe(14); + expect(() => uncoveredJobOptions({ [PLAY_LOOKBACK_DAYS_ENV]: '0' }, argv())).toThrow(PLAY_LOOKBACK_DAYS_ENV); + expect(() => uncoveredJobOptions({ [PLAY_LOOKBACK_DAYS_ENV]: '2weeks' }, argv())).toThrow(PLAY_LOOKBACK_DAYS_ENV); + }); + + it('reads the max-releases-per-run env override and rejects a non-positive value', () => { + expect(uncoveredJobOptions({ [MAX_RELEASES_PER_RUN_ENV]: '2000' }, argv()).maxReleasesPerRun).toBe(2000); + expect(() => uncoveredJobOptions({ [MAX_RELEASES_PER_RUN_ENV]: '-1' }, argv())).toThrow(MAX_RELEASES_PER_RUN_ENV); + }); + + it('honors an operator raising the cap for a --backfill invocation', () => { + const opts = uncoveredJobOptions({ [MAX_RELEASES_PER_RUN_ENV]: '2000' }, argv('--backfill')); + expect(opts).toEqual({ backfill: true, playLookbackDays: PLAY_LOOKBACK_DAYS_DEFAULT, maxReleasesPerRun: 2000 }); + }); +}); diff --git a/tests/unit/jobs/uncovered-release-list/orchestrate.test.ts b/tests/unit/jobs/uncovered-release-list/orchestrate.test.ts index c648c8158..c460612c9 100644 --- a/tests/unit/jobs/uncovered-release-list/orchestrate.test.ts +++ b/tests/unit/jobs/uncovered-release-list/orchestrate.test.ts @@ -58,6 +58,7 @@ const makeOpts = (overrides: Partial = {}): TestOpts => { const opts: TestOpts = { fetchActiveRotation: () => Promise.resolve([row()]), resolveCanonical: (r) => Promise.resolve(releaseFor(r)), + fetchPlayCandidates: () => Promise.resolve([]), loadCovered: () => Promise.resolve(new Set()), loadHandedOff: () => Promise.resolve(new Set()), writeSnapshot: (content, path) => { @@ -73,6 +74,7 @@ const makeOpts = (overrides: Partial = {}): TestOpts => { return Promise.resolve(committedPublish); }, outputPath: './output/uncovered-releases.jsonl', + maxReleasesPerRun: 400, writeCalls, publishCalls, recordHandoffsCalls, @@ -228,24 +230,228 @@ describe('runJob — DRY_RUN', () => { expect(opts.recordHandoffsCalls).toHaveLength(0); expect(totals).toMatchObject({ deduped: 2, already_covered: 1, uncovered: 1, written: 0, published: false }); - const reportLine = stdoutSpy.mock.calls.map((c) => String(c[0])).find((line) => line.includes('"uncovered"')); + // The report literal is the only stdout line starting with `{"job":` — + // logger lines start with `{"timestamp":` and the cap-fired warn line + // also carries an `"uncovered"` field, so an .includes() match would + // find the wrong line and pass vacuously. + const reportLine = stdoutSpy.mock.calls.map((c) => String(c[0])).find((line) => line.startsWith('{"job":')); if (reportLine === undefined) throw new Error('no dry-run report line written to stdout'); const report = JSON.parse(reportLine.trim()); expect(report).toEqual({ job: 'uncovered-release-list', dry_run: true, + backfill: false, active_rotation_rows: 2, resolved: 2, unresolved_dropped: 0, + recent_play_rows: 0, + candidate_rows: 2, deduped: 2, already_covered: 1, already_handed_off: 0, uncovered: 1, + capped_out: 0, }); } finally { stdoutSpy.mockRestore(); } }); + + it('capped_out is non-zero under DRY_RUN when the cap fires — the exact mode an operator uses to check it', async () => { + const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + const rows = [1, 2, 3].map((id) => row({ rotationId: id, libraryId: id })); + const opts = makeOpts({ + fetchActiveRotation: () => Promise.resolve(rows), + resolveCanonical: (r) => Promise.resolve(releaseFor(r)), + maxReleasesPerRun: 1, + dryRun: true, + }); + + const totals = await runJob(opts); + + expect(totals.uncovered).toBe(3); + expect(totals.capped_out).toBe(2); + expect(opts.writeCalls).toHaveLength(0); + + // The report literal is the only stdout line starting with `{"job":` — + // logger lines start with `{"timestamp":` and the cap-fired warn line + // also carries an `"uncovered"` field, so an .includes() match would + // find the wrong line and pass vacuously. + const reportLine = stdoutSpy.mock.calls.map((c) => String(c[0])).find((line) => line.startsWith('{"job":')); + if (reportLine === undefined) throw new Error('no dry-run report line written to stdout'); + expect(JSON.parse(reportLine.trim()).capped_out).toBe(2); + } finally { + stdoutSpy.mockRestore(); + } + }); +}); + +describe('runJob — play arm: concat + dedup precedence', () => { + it('an album in both arms keeps its rotation-arm canonical fields (rotation-first, first-wins dedup)', async () => { + const rotationRelease: CanonicalRelease = { libraryId: 7, artist: 'Rotation Artist', album: 'Rotation Album' }; + const playRelease: CanonicalRelease = { + libraryId: 7, + artist: 'Play Artist (must lose)', + album: 'Play Album (must lose)', + }; + const opts = makeOpts({ + fetchActiveRotation: () => Promise.resolve([row({ rotationId: 1, libraryId: 7 })]), + resolveCanonical: () => Promise.resolve(rotationRelease), + fetchPlayCandidates: () => Promise.resolve([playRelease]), + }); + + const totals = await runJob(opts); + + expect(totals.recent_play_rows).toBe(1); + expect(totals.candidate_rows).toBe(2); // 1 resolved + 1 play, post-concat/pre-dedup + expect(totals.deduped).toBe(1); + expect(JSON.parse(opts.writeCalls[0].content.trim())).toEqual({ + artist: 'Rotation Artist', + album: 'Rotation Album', + library_id: 7, + }); + }); + + it('includes a play-arm-only release (no rotation counterpart) in the candidate set', async () => { + const playOnly: CanonicalRelease = { libraryId: 55, artist: 'Play Only Artist', album: 'Play Only Album' }; + const opts = makeOpts({ fetchPlayCandidates: () => Promise.resolve([playOnly]) }); + + const totals = await runJob(opts); + + expect(totals.recent_play_rows).toBe(1); + expect(totals.deduped).toBe(2); // default rotation row (library.id 42) + play-only (55) + const ids = opts.writeCalls[0].content + .trim() + .split('\n') + .map((line) => JSON.parse(line).library_id) + .sort((a, b) => a - b); + expect(ids).toEqual([42, 55]); + }); +}); + +describe('runJob — cap', () => { + it('truncates the uncovered set at maxReleasesPerRun and reports capped_out at the cap site', async () => { + const rows = [1, 2, 3, 4, 5].map((id) => row({ rotationId: id, libraryId: id })); + const opts = makeOpts({ + fetchActiveRotation: () => Promise.resolve(rows), + resolveCanonical: (r) => Promise.resolve(releaseFor(r)), + maxReleasesPerRun: 2, + }); + + const totals = await runJob(opts); + + expect(totals.uncovered).toBe(5); + expect(totals.capped_out).toBe(3); + expect(totals.written).toBe(2); + expect(opts.writeCalls[0].content.trim().split('\n')).toHaveLength(2); + }); + + it('capped_out is 0 when the cap does not fire', async () => { + const totals = await runJob(makeOpts({ maxReleasesPerRun: 400 })); + expect(totals.capped_out).toBe(0); + }); + + it('the capped list — not the uncovered list — is the single input to render/write/publish/recordHandoffs', async () => { + const rows = [1, 2, 3].map((id) => row({ rotationId: id, libraryId: id })); + const opts = makeOpts({ + fetchActiveRotation: () => Promise.resolve(rows), + resolveCanonical: (r) => Promise.resolve(releaseFor(r)), + maxReleasesPerRun: 1, + }); + + const totals = await runJob(opts); + + expect(totals.uncovered).toBe(3); + expect(totals.capped_out).toBe(2); + const writtenIds = opts.writeCalls[0].content + .trim() + .split('\n') + .map((line) => JSON.parse(line).library_id); + const publishedIds = opts.publishCalls[0] + .trim() + .split('\n') + .map((line) => JSON.parse(line).library_id); + expect(writtenIds).toEqual([1]); // first release in dedup order (rotationId 1) + expect(publishedIds).toEqual(writtenIds); // write + publish share the identical rendered content + expect(opts.recordHandoffsCalls).toEqual([writtenIds]); // markers written for the capped set only + }); +}); + +describe('runJob — zero play rows (non-throwing escalation)', () => { + it('does not throw; logs a loud error-level plays_empty step and the run still completes', async () => { + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const opts = makeOpts({ fetchPlayCandidates: () => Promise.resolve([]) }); + + const totals = await runJob(opts); + + expect(totals.recent_play_rows).toBe(0); + expect(totals.published).toBe(true); // run completed normally, exit stays 0 + + const lines = stderrSpy.mock.calls.map((c) => String(c[0])); + expect(lines.some((line) => line.includes('"step":"plays_empty"'))).toBe(true); + } finally { + stderrSpy.mockRestore(); + } + }); +}); + +describe('runJob — rotation-lane guard demotion under --backfill', () => { + it('demotes the zero-active-rotation guard to log+Sentry and continues the drain on the play arm alone', async () => { + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const playOnly: CanonicalRelease = { libraryId: 9, artist: 'Play Only', album: 'Play Only Album' }; + const opts = makeOpts({ + fetchActiveRotation: () => Promise.resolve([]), + fetchPlayCandidates: () => Promise.resolve([playOnly]), + backfill: true, + }); + + const totals = await runJob(opts); + + expect(totals.active_rotation_rows).toBe(0); + expect(totals.resolved).toBe(0); + expect(totals.recent_play_rows).toBe(1); + expect(totals.uncovered).toBe(1); + expect(totals.published).toBe(true); // did not throw; the run completed on the play arm alone + + const lines = stderrSpy.mock.calls.map((c) => String(c[0])); + expect(lines.some((line) => line.includes('"step":"rotation_empty_backfill"'))).toBe(true); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('demotes the zero-resolved guard to log+Sentry and continues the drain on the play arm alone', async () => { + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const playOnly: CanonicalRelease = { libraryId: 11, artist: 'Play Only', album: 'Play Only Album' }; + const opts = makeOpts({ + resolveCanonical: () => Promise.resolve(null), + fetchPlayCandidates: () => Promise.resolve([playOnly]), + backfill: true, + }); + + const totals = await runJob(opts); + + expect(totals.resolved).toBe(0); + expect(totals.unresolved_dropped).toBe(1); + expect(totals.recent_play_rows).toBe(1); + expect(totals.uncovered).toBe(1); + expect(totals.published).toBe(true); // did not throw + + const lines = stderrSpy.mock.calls.map((c) => String(c[0])); + expect(lines.some((line) => line.includes('"step":"resolve_empty_backfill"'))).toBe(true); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('does NOT demote in steady state (backfill unset) — both rotation-lane guards still hard-throw', async () => { + await expect(runJob(makeOpts({ fetchActiveRotation: () => Promise.resolve([]) }))).rejects.toThrow(/0 rows/i); + await expect(runJob(makeOpts({ resolveCanonical: () => Promise.resolve(null) }))).rejects.toThrow(/resolved/i); + }); }); describe('runJob — nothing-new steady state', () => { diff --git a/tests/unit/jobs/uncovered-release-list/plays.test.ts b/tests/unit/jobs/uncovered-release-list/plays.test.ts new file mode 100644 index 000000000..f3860a8f1 --- /dev/null +++ b/tests/unit/jobs/uncovered-release-list/plays.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for uncovered-release-list's plays.ts (BS#1877, the "widen the + * candidate set" amendment). Scoped to canonical-field mapping and row + * pass-through only, deliberately asserting no SQL shape: `db.execute` is + * fully mocked, mirroring `rotation.test.ts`'s stated convention ("The + * COALESCE join's SQL shape itself (real Postgres) is pinned by the + * integration spec, not here"). + */ +import { db } from '@wxyc/database'; +import { fetchRecentPlays, fetchAllPlayedAlbums } from '../../../../jobs/uncovered-release-list/plays'; +import type { CanonicalRelease } from '../../../../jobs/uncovered-release-list/rotation'; + +const mockExecute = db.execute as jest.Mock; + +beforeEach(() => { + mockExecute.mockReset(); +}); + +describe('fetchRecentPlays', () => { + it('maps raw rows to CanonicalRelease, dropping play_count', async () => { + mockExecute.mockResolvedValueOnce([ + { library_id: 42, artist_name: 'Tony Allen', album_title: 'What goes up', play_count: 7 }, + { library_id: 99, artist_name: 'Setting', album_title: 'Setting', play_count: 3 }, + ]); + + const releases = await fetchRecentPlays(30); + + expect(releases).toEqual([ + { libraryId: 42, artist: 'Tony Allen', album: 'What goes up' }, + { libraryId: 99, artist: 'Setting', album: 'Setting' }, + ]); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array for an empty result', async () => { + mockExecute.mockResolvedValueOnce([]); + expect(await fetchRecentPlays(30)).toEqual([]); + }); + + it('supports the node-postgres { rows } driver shape as well as a bare array', async () => { + mockExecute.mockResolvedValueOnce({ + rows: [{ library_id: 5, artist_name: 'A', album_title: 'B', play_count: 1 }], + }); + + const releases = await fetchRecentPlays(30); + expect(releases).toEqual([{ libraryId: 5, artist: 'A', album: 'B' }]); + }); +}); + +describe('fetchAllPlayedAlbums', () => { + it('maps raw rows to CanonicalRelease, dropping play_count, with zero args', async () => { + mockExecute.mockResolvedValueOnce([ + { + library_id: 1, + artist_name: 'Duke Ellington & John Coltrane', + album_title: 'Duke Ellington & John Coltrane', + play_count: 120, + }, + ]); + + const releases = await fetchAllPlayedAlbums(); + + expect(releases).toEqual([ + { libraryId: 1, artist: 'Duke Ellington & John Coltrane', album: 'Duke Ellington & John Coltrane' }, + ]); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array for an empty result', async () => { + mockExecute.mockResolvedValueOnce([]); + expect(await fetchAllPlayedAlbums()).toEqual([]); + }); +});