From c250b340e62c4468b600d9ca2d0350ef795b6f45 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 14:09:07 -0500 Subject: [PATCH 1/6] fix(songstats-backfill): exponential backoff on 429 + defer instead of churn (chat#1797 #1, #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-catalog drain hammered Songstats past its per-second rate limit (~79% 429s) and counted every 429 as a quota hit, prematurely capping the drain. Fix the rate-limit handling and add observability: - new fetchSongstatsWithBackoff: bounded exponential backoff on 429/408/5xx (retries the same request, ~15s cap, well within a step's duration), flags retriesExhausted when still rejected. - backfillTrackStep: 200 -> write/done/hit; 404/4xx -> done/hit (terminal); backoff exhausted -> DEFER (leave `pending`, record NO quota hit) so the next drain retries — Songstats is the rate authority, no phantom 429 spend. - songstatsBackfillWorkflow: stop the run on the first deferral (Songstats saturated; rest stay pending) instead of churning failed->reclaim->429. - per-step + per-batch logging (chat#1797 #3) so a drain is traceable in Vercel. 11 new/updated unit tests; workflows+songstats+research suite 317 green; tsc/lint clean. --- .../__tests__/backfillTrackStep.test.ts | 97 ++++++++----------- app/workflows/backfillTrackStep.ts | 56 ++++++----- app/workflows/songstatsBackfillWorkflow.ts | 28 ++++-- .../fetchSongstatsWithBackoff.test.ts | 81 ++++++++++++++++ lib/songstats/fetchSongstatsWithBackoff.ts | 63 ++++++++++++ 5 files changed, 235 insertions(+), 90 deletions(-) create mode 100644 lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts create mode 100644 lib/songstats/fetchSongstatsWithBackoff.ts diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index ebe77370..a26fc6e8 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { backfillTrackStep } from "../backfillTrackStep"; -import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; -vi.mock("@/lib/songstats/fetchSongstats", () => ({ fetchSongstats: vi.fn() })); +vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ + fetchSongstatsWithBackoff: vi.fn(), +})); vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); @@ -22,22 +24,20 @@ const ROW = { id: "q1", song: "USA2P2015959" } as never; describe("backfillTrackStep", () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); vi.mocked(upsertSongMeasurements).mockResolvedValue([] as never); }); - it("writes the historic series as songstats measurements, records spend, marks done", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ + it("writes the historic series, records the spend, marks done on 200", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 200, + attempts: 1, + retriesExhausted: false, data: { stats: [ { source: "spotify", - data: { - history: [ - { date: "2025-01-01", streams_total: 1008736324 }, - { date: "2026-01-01", streams_total: 1330251464 }, - ], - }, + data: { history: [{ date: "2025-01-01", streams_total: 1008736324 }] }, }, ], }, @@ -45,30 +45,11 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - expect(fetchSongstats).toHaveBeenCalledWith("tracks/historic_stats", { + expect(fetchSongstatsWithBackoff).toHaveBeenCalledWith("tracks/historic_stats", { isrc: "USA2P2015959", source: "spotify", }); - expect(upsertSongMeasurements).toHaveBeenCalledWith([ - { - song: "USA2P2015959", - platform: "spotify", - metric: "platform_displayed_play_count", - value: 1008736324, - captured_at: "2025-01-01T00:00:00.000Z", - data_source: "songstats", - raw_ref: "songstats-backfill", - }, - { - song: "USA2P2015959", - platform: "spotify", - metric: "platform_displayed_play_count", - value: 1330251464, - captured_at: "2026-01-01T00:00:00.000Z", - data_source: "songstats", - raw_ref: "songstats-backfill", - }, - ]); + expect(upsertSongMeasurements).toHaveBeenCalled(); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, purpose: "backfill USA2P2015959", @@ -77,56 +58,56 @@ describe("backfillTrackStep", () => { expect(result).toEqual({ ok: true, hitsSpent: 1 }); }); - it("marks a transient upstream error (429) as failed (reclaimable) and records the spend", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); - - const result = await backfillTrackStep(ROW); - - // transient -> 'failed' so the daily reclaim sweep returns it to 'pending' - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "failed" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (failed 429)", + it("DEFERS (pending, no quota hit, signals stop) when backoff is exhausted on 429", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 429, + attempts: 6, + retriesExhausted: true, + data: {}, }); - expect(upsertSongMeasurements).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); - }); - - it("marks a transient 5xx as failed (reclaimable)", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 504, data: {} }); const result = await backfillTrackStep(ROW); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "failed" }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + // left pending for the next drain; NO ledger hit (Songstats consumed nothing) + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "pending" }); + expect(insertSongstatsQuotaLedger).not.toHaveBeenCalled(); + expect(upsertSongMeasurements).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: false, hitsSpent: 0, deferred: true }); }); - it("marks a permanent client error (403) as done so reclaim never recycles it", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 403, data: {} }); + it("marks a definitive 404 (no history) as done and records the spend", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 404, + attempts: 1, + retriesExhausted: false, + data: {}, + }); const result = await backfillTrackStep(ROW); - // non-retryable 4xx (not 408/429) is terminal -> 'done', not 'failed' expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, - purpose: "backfill USA2P2015959 (terminal 403)", + purpose: "backfill USA2P2015959 (no data 404)", }); expect(result).toEqual({ ok: false, hitsSpent: 1 }); }); - it("marks a definitive 404 (no history exists) as done so it is never retried", async () => { - vi.mocked(fetchSongstats).mockResolvedValue({ status: 404, data: {} }); + it("marks a permanent 4xx (403) as done (terminal) and records the spend", async () => { + vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ + status: 403, + attempts: 1, + retriesExhausted: false, + data: {}, + }); const result = await backfillTrackStep(ROW); - // terminal no-data -> 'done', not 'failed' — the reclaim sweep must not resurrect it expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, - purpose: "backfill USA2P2015959 (no data 404)", + purpose: "backfill USA2P2015959 (terminal 403)", }); - expect(upsertSongMeasurements).not.toHaveBeenCalled(); expect(result).toEqual({ ok: false, hitsSpent: 1 }); }); }); diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index 3247ff4c..1e4c1c48 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -1,4 +1,4 @@ -import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; @@ -8,40 +8,47 @@ import { Tables } from "@/types/database.types"; const METRIC = "platform_displayed_play_count"; /** - * Backfill one claimed queue row: fetch the track's Songstats historic series - * (one quota hit — recorded win or lose), write each point as a permanent - * `songstats`-labeled measurement, and close the row. Failures mark the row - * failed without failing the run — the next row may still succeed. + * Backfill one claimed queue row, with bounded exponential backoff on Songstats' + * rate limit (Songstats is the rate authority — see chat#1797): + * - **200** → write each history point as a permanent `songstats` measurement, + * record the spend, mark `done`. + * - **404 / other 4xx** → a real request with a definitive answer; terminal, so + * mark `done` (404 = no history) and record the spend. + * - **backoff exhausted** (still 429/5xx after retries) → **defer**: leave the row + * `pending` for the next drain, consume no quota, and signal the workflow to + * stop (`deferred`) — Songstats is saturated right now. * * @param row - The claimed queue row (already in_progress) - * @returns ok + hits spent (always 1; the hit is consumed even on failure) + * @returns ok + hitsSpent (0 when deferred) + `deferred` when Songstats is saturated */ export async function backfillTrackStep( row: Tables<"songstats_backfill_queue">, -): Promise<{ ok: boolean; hitsSpent: number }> { +): Promise<{ ok: boolean; hitsSpent: number; deferred?: boolean }> { "use step"; - const result = await fetchSongstats("tracks/historic_stats", { + const result = await fetchSongstatsWithBackoff("tracks/historic_stats", { isrc: row.song, source: "spotify", }); - if (result.status !== 200) { - const status = result.status; - const isNoData = status === 404; - // Only transient errors are retryable: 408 (timeout), 429 (quota), any 5xx. - const isRetryable = status === 408 || status === 429 || status >= 500; - - // `failed` is reclaimable (the daily sweep returns it to `pending`, bounded - // by the rolling-window budget). 404 no-data and other permanent 4xx are - // terminal → `done`, so reclaim never recycles a track that can't succeed. - const nextStatus = isRetryable ? "failed" : "done"; - - let outcome = `terminal ${status}`; - if (isNoData) outcome = "no data 404"; - else if (isRetryable) outcome = `failed ${status}`; + if (result.retriesExhausted) { + // Rate-limited past the backoff bound — leave it for the next run, spend nothing. + console.log( + `[backfill] ${row.song} deferred (rate-limited ${result.status} after ${result.attempts} tries)`, + ); + await updateSongstatsBackfillQueue(row.id, { status: "pending" }); + return { ok: false, hitsSpent: 0, deferred: true }; + } - await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song} (${outcome})` }); - await updateSongstatsBackfillQueue(row.id, { status: nextStatus }); + if (result.status !== 200) { + const noData = result.status === 404; + console.log( + `[backfill] ${row.song} done (${noData ? "no data 404" : `terminal ${result.status}`})`, + ); + await insertSongstatsQuotaLedger({ + hits: 1, + purpose: `backfill ${row.song} (${noData ? "no data 404" : `terminal ${result.status}`})`, + }); + await updateSongstatsBackfillQueue(row.id, { status: "done" }); return { ok: false, hitsSpent: 1 }; } @@ -67,6 +74,7 @@ export async function backfillTrackStep( }); await upsertSongMeasurements(rows); + console.log(`[backfill] ${row.song} done (${rows.length} points written)`); await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song}` }); await updateSongstatsBackfillQueue(row.id, { status: "done" }); return { ok: true, hitsSpent: 1 }; diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index 0a1960bf..67c1a6e4 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -5,11 +5,13 @@ import { backfillTrackStep } from "@/app/workflows/backfillTrackStep"; const BATCH_SIZE = 25; /** - * Durable Songstats backfill drain (recoupable/chat#1791 write path): check - * the rolling-window budget, claim value-ranked rows via the SKIP LOCKED RPC, - * backfill each track's historic series into the measurement store, and stop - * when the queue or the budget is dry. Every quota hit converts into - * permanent owned data (fetch-once: captured history is never refetched). + * Durable Songstats backfill drain (recoupable/chat#1791 write path): claim + * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic + * series, with per-track exponential backoff handling Songstats' rate limit + * (chat#1797). **Stops as soon as a track defers** — Songstats still + * rate-limiting it past the backoff bound — leaving the rest `pending` for the + * next drain instead of hammering a saturated API. Every successful hit converts + * into permanent owned data (fetch-once: captured history is never refetched). */ export async function songstatsBackfillWorkflow() { "use workflow"; @@ -17,13 +19,20 @@ export async function songstatsBackfillWorkflow() { let budget = await getBackfillBudgetStep(); let backfilled = 0; let failed = 0; + let deferred = false; - while (budget > 0) { + drain: while (budget > 0) { const rows = await claimBackfillRowsStep(Math.min(budget, BATCH_SIZE)); if (rows.length === 0) break; + console.log(`[songstats-backfill] claimed ${rows.length} rows`); for (const row of rows) { const result = await backfillTrackStep(row); + if (result.deferred) { + // Songstats is saturated — stop now; remaining claimed rows stay pending. + deferred = true; + break drain; + } budget -= result.hitsSpent; if (result.ok) backfilled += 1; else failed += 1; @@ -31,6 +40,9 @@ export async function songstatsBackfillWorkflow() { } } - console.log(`[songstats-backfill] done: ${backfilled} backfilled, ${failed} failed`); - return { backfilled, failed }; + console.log( + `[songstats-backfill] done: ${backfilled} backfilled, ${failed} terminal` + + (deferred ? ", deferred (rate-limited)" : ""), + ); + return { backfilled, failed, deferred }; } diff --git a/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts new file mode 100644 index 00000000..1dda52b4 --- /dev/null +++ b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchSongstatsWithBackoff } from "../fetchSongstatsWithBackoff"; +import { fetchSongstats } from "../fetchSongstats"; + +vi.mock("../fetchSongstats", () => ({ fetchSongstats: vi.fn() })); + +const noSleep = vi.fn().mockResolvedValue(undefined); + +describe("fetchSongstatsWithBackoff", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns immediately on 200 with no retries or sleeps", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 200, data: { ok: true } }); + + const r = await fetchSongstatsWithBackoff( + "tracks/historic_stats", + { isrc: "I" }, + { sleep: noSleep }, + ); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(noSleep).not.toHaveBeenCalled(); + expect(r).toMatchObject({ status: 200, attempts: 1, retriesExhausted: false }); + }); + + it("does NOT retry a non-retryable status (404)", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 404, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep }); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(r).toMatchObject({ status: 404, retriesExhausted: false }); + }); + + it("backs off and retries on 429, succeeding on a later attempt", async () => { + vi.mocked(fetchSongstats) + .mockResolvedValueOnce({ status: 429, data: {} }) + .mockResolvedValueOnce({ status: 429, data: {} }) + .mockResolvedValueOnce({ status: 200, data: { ok: true } }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, baseMs: 100 }); + + expect(fetchSongstats).toHaveBeenCalledTimes(3); + expect(noSleep).toHaveBeenCalledTimes(2); + // exponential: 100 then 200 + expect(noSleep).toHaveBeenNthCalledWith(1, 100); + expect(noSleep).toHaveBeenNthCalledWith(2, 200); + expect(r).toMatchObject({ status: 200, attempts: 3, retriesExhausted: false }); + }); + + it("gives up after maxRetries on persistent 429 and flags retriesExhausted", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, maxRetries: 3 }); + + expect(fetchSongstats).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + expect(noSleep).toHaveBeenCalledTimes(3); + expect(r).toMatchObject({ status: 429, retriesExhausted: true }); + }); + + it("treats 5xx and 408 as retryable too", async () => { + vi.mocked(fetchSongstats) + .mockResolvedValueOnce({ status: 503, data: {} }) + .mockResolvedValueOnce({ status: 200, data: {} }); + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep, maxRetries: 2 }); + expect(fetchSongstats).toHaveBeenCalledTimes(2); + expect(r).toMatchObject({ status: 200, retriesExhausted: false }); + }); + + it("caps the backoff at maxMs", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); + await fetchSongstatsWithBackoff("p", undefined, { + sleep: noSleep, + baseMs: 1000, + maxMs: 1500, + maxRetries: 3, + }); + // 1000, 2000->capped 1500, 4000->capped 1500 + expect(noSleep.mock.calls.map(c => c[0])).toEqual([1000, 1500, 1500]); + }); +}); diff --git a/lib/songstats/fetchSongstatsWithBackoff.ts b/lib/songstats/fetchSongstatsWithBackoff.ts new file mode 100644 index 00000000..0110469d --- /dev/null +++ b/lib/songstats/fetchSongstatsWithBackoff.ts @@ -0,0 +1,63 @@ +import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import type { ProxyResult } from "@/lib/research/ProxyResult"; + +// Short, in-step backoff for Songstats' per-second rate limit (total ~15s, well +// within a workflow step's duration). Persistent rejection defers the row to the +// next drain run rather than sleeping for minutes inside one invocation. +const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_BASE_MS = 1000; +const DEFAULT_MAX_MS = 8_000; + +/** Transient statuses worth retrying: 408 timeout, 429 rate limit, any 5xx. */ +const isRetryable = (status: number) => status === 408 || status === 429 || status >= 500; + +const realSleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export type FetchSongstatsBackoffOptions = { + maxRetries?: number; + baseMs?: number; + maxMs?: number; + /** Injectable for tests; defaults to a real timer. */ + sleep?: (ms: number) => Promise; +}; + +export type BackoffResult = ProxyResult & { + /** Total attempts made (1 + retries). */ + attempts: number; + /** True when the final result is still retryable after exhausting retries. */ + retriesExhausted: boolean; +}; + +/** + * Call Songstats with bounded exponential backoff on transient rejections + * (429 rate limit, 408, 5xx). Retries the **same** request up to `maxRetries` + * with `min(maxMs, baseMs * 2^attempt)` waits; a 200 or any non-retryable status + * (e.g. 404) returns immediately. When backoff is exhausted and the call is + * still being rejected, `retriesExhausted` is true so the caller can defer the + * work (leave it `pending`) rather than burn it — Songstats is the rate + * authority, not a local quota ledger. + * + * @param path - Songstats enterprise path (e.g. `tracks/historic_stats`). + * @param queryParams - Query params forwarded to Songstats. + * @param options - Backoff tuning + an injectable `sleep`. + */ +export async function fetchSongstatsWithBackoff( + path: string, + queryParams?: Record, + options: FetchSongstatsBackoffOptions = {}, +): Promise { + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const baseMs = options.baseMs ?? DEFAULT_BASE_MS; + const maxMs = options.maxMs ?? DEFAULT_MAX_MS; + const sleep = options.sleep ?? realSleep; + + let result = await fetchSongstats(path, queryParams); + let retries = 0; + while (isRetryable(result.status) && retries < maxRetries) { + await sleep(Math.min(maxMs, baseMs * 2 ** retries)); + result = await fetchSongstats(path, queryParams); + retries += 1; + } + + return { ...result, attempts: retries + 1, retriesExhausted: isRetryable(result.status) }; +} From c493c1df366e65165e317528118a087e4f53ba02 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 14:15:19 -0500 Subject: [PATCH 2/6] refactor(songstats): remove local quota ledger, defer to Songstats rate authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullet 2 of chat#1797. Drops the local quota ledger + budget gate — Songstats is the source of truth for rate limits. The drain now relies on per-track bounded backoff (PR1) and stops as soon as a track defers (still rate-limited past the backoff bound), leaving the rest pending for the next run. - Remove getBackfillBudgetStep + its test - Remove insertSongstatsQuotaLedger / selectSongstatsQuotaSpent + tests - backfillTrackStep: no ledger write; returns {ok, deferred?} - songstatsBackfillWorkflow: drop budget gate; drain-until-empty-or-deferred Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/backfillTrackStep.test.ts | 34 +++++------------- .../__tests__/getBackfillBudgetStep.test.ts | 32 ----------------- app/workflows/backfillTrackStep.ts | 29 +++++++-------- app/workflows/getBackfillBudgetStep.ts | 21 ----------- app/workflows/songstatsBackfillWorkflow.ts | 27 +++++++------- .../insertSongstatsQuotaLedger.test.ts | 32 ----------------- .../selectSongstatsQuotaSpent.test.ts | 35 ------------------- .../insertSongstatsQuotaLedger.ts | 19 ---------- .../selectSongstatsQuotaSpent.ts | 23 ------------ 9 files changed, 32 insertions(+), 220 deletions(-) delete mode 100644 app/workflows/__tests__/getBackfillBudgetStep.test.ts delete mode 100644 app/workflows/getBackfillBudgetStep.ts delete mode 100644 lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts delete mode 100644 lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts delete mode 100644 lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts delete mode 100644 lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index a26fc6e8..b7252322 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -3,7 +3,6 @@ import { backfillTrackStep } from "../backfillTrackStep"; import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; -import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ @@ -12,9 +11,6 @@ vi.mock("@/lib/songstats/fetchSongstatsWithBackoff", () => ({ vi.mock("@/lib/supabase/song_measurements/upsertSongMeasurements", () => ({ upsertSongMeasurements: vi.fn(), })); -vi.mock("@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger", () => ({ - insertSongstatsQuotaLedger: vi.fn(), -})); vi.mock("@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue", () => ({ updateSongstatsBackfillQueue: vi.fn(), })); @@ -28,7 +24,7 @@ describe("backfillTrackStep", () => { vi.mocked(upsertSongMeasurements).mockResolvedValue([] as never); }); - it("writes the historic series, records the spend, marks done on 200", async () => { + it("writes the historic series and marks done on 200", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 200, attempts: 1, @@ -50,15 +46,11 @@ describe("backfillTrackStep", () => { source: "spotify", }); expect(upsertSongMeasurements).toHaveBeenCalled(); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959", - }); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); - expect(result).toEqual({ ok: true, hitsSpent: 1 }); + expect(result).toEqual({ ok: true }); }); - it("DEFERS (pending, no quota hit, signals stop) when backoff is exhausted on 429", async () => { + it("DEFERS (pending, signals stop) when backoff is exhausted on 429", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 429, attempts: 6, @@ -68,14 +60,12 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - // left pending for the next drain; NO ledger hit (Songstats consumed nothing) expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "pending" }); - expect(insertSongstatsQuotaLedger).not.toHaveBeenCalled(); expect(upsertSongMeasurements).not.toHaveBeenCalled(); - expect(result).toEqual({ ok: false, hitsSpent: 0, deferred: true }); + expect(result).toEqual({ ok: false, deferred: true }); }); - it("marks a definitive 404 (no history) as done and records the spend", async () => { + it("marks a definitive 404 (no history) as done", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 404, attempts: 1, @@ -86,14 +76,10 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (no data 404)", - }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + expect(result).toEqual({ ok: false }); }); - it("marks a permanent 4xx (403) as done (terminal) and records the spend", async () => { + it("marks a permanent 4xx (403) as done (terminal)", async () => { vi.mocked(fetchSongstatsWithBackoff).mockResolvedValue({ status: 403, attempts: 1, @@ -104,10 +90,6 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); - expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ - hits: 1, - purpose: "backfill USA2P2015959 (terminal 403)", - }); - expect(result).toEqual({ ok: false, hitsSpent: 1 }); + expect(result).toEqual({ ok: false }); }); }); diff --git a/app/workflows/__tests__/getBackfillBudgetStep.test.ts b/app/workflows/__tests__/getBackfillBudgetStep.test.ts deleted file mode 100644 index 6c3fb815..00000000 --- a/app/workflows/__tests__/getBackfillBudgetStep.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBackfillBudgetStep } from "../getBackfillBudgetStep"; - -import { selectSongstatsQuotaSpent } from "@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent"; - -vi.mock("@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent", () => ({ - selectSongstatsQuotaSpent: vi.fn(), -})); - -describe("getBackfillBudgetStep", () => { - beforeEach(() => { - vi.clearAllMocks(); - delete process.env.SONGSTATS_QUOTA_LIMIT; - delete process.env.SONGSTATS_QUOTA_RESERVE; - }); - - it("computes limit - reserve - spent over the rolling 30d window", async () => { - vi.mocked(selectSongstatsQuotaSpent).mockResolvedValue(300); - - const budget = await getBackfillBudgetStep(); - - expect(budget).toBe(1000 - 100 - 300); - const since = vi.mocked(selectSongstatsQuotaSpent).mock.calls[0][0]; - const ageDays = (Date.now() - new Date(since).getTime()) / 86400000; - expect(Math.round(ageDays)).toBe(30); - }); - - it("never returns negative", async () => { - vi.mocked(selectSongstatsQuotaSpent).mockResolvedValue(5000); - expect(await getBackfillBudgetStep()).toBe(0); - }); -}); diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index 1e4c1c48..3342d773 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -1,6 +1,5 @@ import { fetchSongstatsWithBackoff } from "@/lib/songstats/fetchSongstatsWithBackoff"; import { upsertSongMeasurements } from "@/lib/supabase/song_measurements/upsertSongMeasurements"; -import { insertSongstatsQuotaLedger } from "@/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger"; import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; import { historicStatsPayloadSchema } from "@/lib/research/playcounts/historicStatsPayloadSchema"; import { Tables } from "@/types/database.types"; @@ -9,21 +8,22 @@ const METRIC = "platform_displayed_play_count"; /** * Backfill one claimed queue row, with bounded exponential backoff on Songstats' - * rate limit (Songstats is the rate authority — see chat#1797): + * rate limit (Songstats is the rate authority — see chat#1797; there is no local + * quota ledger): * - **200** → write each history point as a permanent `songstats` measurement, - * record the spend, mark `done`. + * mark `done`. * - **404 / other 4xx** → a real request with a definitive answer; terminal, so - * mark `done` (404 = no history) and record the spend. + * mark `done` (404 = no history). * - **backoff exhausted** (still 429/5xx after retries) → **defer**: leave the row - * `pending` for the next drain, consume no quota, and signal the workflow to - * stop (`deferred`) — Songstats is saturated right now. + * `pending` for the next drain and signal the workflow to stop (`deferred`) — + * Songstats is saturated right now. * * @param row - The claimed queue row (already in_progress) - * @returns ok + hitsSpent (0 when deferred) + `deferred` when Songstats is saturated + * @returns `ok` (true on a written backfill) + `deferred` when Songstats is saturated */ export async function backfillTrackStep( row: Tables<"songstats_backfill_queue">, -): Promise<{ ok: boolean; hitsSpent: number; deferred?: boolean }> { +): Promise<{ ok: boolean; deferred?: boolean }> { "use step"; const result = await fetchSongstatsWithBackoff("tracks/historic_stats", { isrc: row.song, @@ -31,12 +31,12 @@ export async function backfillTrackStep( }); if (result.retriesExhausted) { - // Rate-limited past the backoff bound — leave it for the next run, spend nothing. + // Rate-limited past the backoff bound — leave it for the next run. console.log( `[backfill] ${row.song} deferred (rate-limited ${result.status} after ${result.attempts} tries)`, ); await updateSongstatsBackfillQueue(row.id, { status: "pending" }); - return { ok: false, hitsSpent: 0, deferred: true }; + return { ok: false, deferred: true }; } if (result.status !== 200) { @@ -44,12 +44,8 @@ export async function backfillTrackStep( console.log( `[backfill] ${row.song} done (${noData ? "no data 404" : `terminal ${result.status}`})`, ); - await insertSongstatsQuotaLedger({ - hits: 1, - purpose: `backfill ${row.song} (${noData ? "no data 404" : `terminal ${result.status}`})`, - }); await updateSongstatsBackfillQueue(row.id, { status: "done" }); - return { ok: false, hitsSpent: 1 }; + return { ok: false }; } const parsed = historicStatsPayloadSchema.safeParse(result.data); @@ -75,7 +71,6 @@ export async function backfillTrackStep( await upsertSongMeasurements(rows); console.log(`[backfill] ${row.song} done (${rows.length} points written)`); - await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song}` }); await updateSongstatsBackfillQueue(row.id, { status: "done" }); - return { ok: true, hitsSpent: 1 }; + return { ok: true }; } diff --git a/app/workflows/getBackfillBudgetStep.ts b/app/workflows/getBackfillBudgetStep.ts deleted file mode 100644 index 866ee760..00000000 --- a/app/workflows/getBackfillBudgetStep.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { selectSongstatsQuotaSpent } from "@/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent"; - -const DEFAULT_QUOTA_LIMIT = 1000; -const DEFAULT_QUOTA_RESERVE = 100; -const WINDOW_DAYS = 30; - -/** - * Remaining Songstats budget for this run: plan limit minus a reserve (kept - * for request-path fallbacks and manual research) minus hits spent in the - * rolling 30-day window. Never negative. - * - * @returns Hits the backfill worker may spend now - */ -export async function getBackfillBudgetStep(): Promise { - "use step"; - const limit = Number(process.env.SONGSTATS_QUOTA_LIMIT) || DEFAULT_QUOTA_LIMIT; - const reserve = Number(process.env.SONGSTATS_QUOTA_RESERVE) || DEFAULT_QUOTA_RESERVE; - const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString(); - const spent = await selectSongstatsQuotaSpent(since); - return Math.max(0, limit - reserve - spent); -} diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index 67c1a6e4..4fd79b52 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -1,4 +1,3 @@ -import { getBackfillBudgetStep } from "@/app/workflows/getBackfillBudgetStep"; import { claimBackfillRowsStep } from "@/app/workflows/claimBackfillRowsStep"; import { backfillTrackStep } from "@/app/workflows/backfillTrackStep"; @@ -7,22 +6,22 @@ const BATCH_SIZE = 25; /** * Durable Songstats backfill drain (recoupable/chat#1791 write path): claim * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic - * series, with per-track exponential backoff handling Songstats' rate limit - * (chat#1797). **Stops as soon as a track defers** — Songstats still - * rate-limiting it past the backoff bound — leaving the rest `pending` for the - * next drain instead of hammering a saturated API. Every successful hit converts - * into permanent owned data (fetch-once: captured history is never refetched). + * series. There is **no local quota ledger or budget gate** (chat#1797) — + * Songstats is the rate authority: per-track exponential backoff absorbs the + * rate limit, and the run **stops as soon as a track defers** (still + * rate-limited past the backoff bound), leaving the rest `pending` for the next + * drain. Otherwise it drains until the queue has no claimable `pending` rows. + * Every backfill converts into permanent owned data (fetch-once). */ export async function songstatsBackfillWorkflow() { "use workflow"; - let budget = await getBackfillBudgetStep(); let backfilled = 0; - let failed = 0; + let terminal = 0; let deferred = false; - drain: while (budget > 0) { - const rows = await claimBackfillRowsStep(Math.min(budget, BATCH_SIZE)); + drain: while (true) { + const rows = await claimBackfillRowsStep(BATCH_SIZE); if (rows.length === 0) break; console.log(`[songstats-backfill] claimed ${rows.length} rows`); @@ -33,16 +32,14 @@ export async function songstatsBackfillWorkflow() { deferred = true; break drain; } - budget -= result.hitsSpent; if (result.ok) backfilled += 1; - else failed += 1; - if (budget <= 0) break; + else terminal += 1; } } console.log( - `[songstats-backfill] done: ${backfilled} backfilled, ${failed} terminal` + + `[songstats-backfill] done: ${backfilled} backfilled, ${terminal} terminal` + (deferred ? ", deferred (rate-limited)" : ""), ); - return { backfilled, failed, deferred }; + return { backfilled, terminal, deferred }; } diff --git a/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts b/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts deleted file mode 100644 index 4f7d5c2a..00000000 --- a/lib/supabase/songstats_quota_ledger/__tests__/insertSongstatsQuotaLedger.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { insertSongstatsQuotaLedger } from "../insertSongstatsQuotaLedger"; -import supabase from "../../serverClient"; - -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - const mockRpc = vi.fn(); - return { default: { from: mockFrom, rpc: mockRpc } }; -}); - -describe("insertSongstatsQuotaLedger", () => { - beforeEach(() => vi.clearAllMocks()); - - it("records spent hits", async () => { - const insert = vi.fn().mockResolvedValue({ error: null }); - vi.mocked(supabase.from).mockReturnValue({ insert } as never); - - await insertSongstatsQuotaLedger({ hits: 1, purpose: "backfill USA2P2015959" }); - - expect(supabase.from).toHaveBeenCalledWith("songstats_quota_ledger"); - expect(insert).toHaveBeenCalledWith([{ hits: 1, purpose: "backfill USA2P2015959" }]); - }); - - it("throws on insert error (spend must be recorded)", async () => { - const insert = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - vi.mocked(supabase.from).mockReturnValue({ insert } as never); - - await expect(insertSongstatsQuotaLedger({ hits: 1 })).rejects.toThrow( - "Failed to record songstats quota spend: boom", - ); - }); -}); diff --git a/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts b/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts deleted file mode 100644 index 6d59c626..00000000 --- a/lib/supabase/songstats_quota_ledger/__tests__/selectSongstatsQuotaSpent.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { selectSongstatsQuotaSpent } from "../selectSongstatsQuotaSpent"; -import supabase from "../../serverClient"; - -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - const mockRpc = vi.fn(); - return { default: { from: mockFrom, rpc: mockRpc } }; -}); - -describe("selectSongstatsQuotaSpent", () => { - beforeEach(() => vi.clearAllMocks()); - - it("sums hits spent since the window start", async () => { - const gte = vi.fn().mockResolvedValue({ data: [{ hits: 3 }, { hits: 7 }], error: null }); - const select = vi.fn().mockReturnValue({ gte }); - vi.mocked(supabase.from).mockReturnValue({ select } as never); - - const result = await selectSongstatsQuotaSpent("2026-05-12T00:00:00Z"); - - expect(supabase.from).toHaveBeenCalledWith("songstats_quota_ledger"); - expect(gte).toHaveBeenCalledWith("spent_at", "2026-05-12T00:00:00Z"); - expect(result).toBe(10); - }); - - it("treats errors as full quota spent (fail safe — do not overspend)", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - const gte = vi.fn().mockResolvedValue({ data: null, error: { message: "boom" } }); - const select = vi.fn().mockReturnValue({ gte }); - vi.mocked(supabase.from).mockReturnValue({ select } as never); - - expect(await selectSongstatsQuotaSpent("2026-05-12T00:00:00Z")).toBe(Number.MAX_SAFE_INTEGER); - consoleError.mockRestore(); - }); -}); diff --git a/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts b/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts deleted file mode 100644 index 777c42a7..00000000 --- a/lib/supabase/songstats_quota_ledger/insertSongstatsQuotaLedger.ts +++ /dev/null @@ -1,19 +0,0 @@ -import supabase from "../serverClient"; -import { TablesInsert } from "@/types/database.types"; - -/** - * Record Songstats quota spend. Throws on failure — unrecorded spend would - * let the worker silently blow the rolling-window budget. - * - * @param entry - hits spent, optional purpose/account attribution - * @throws Error if the insert fails - */ -export async function insertSongstatsQuotaLedger( - entry: TablesInsert<"songstats_quota_ledger">, -): Promise { - const { error } = await supabase.from("songstats_quota_ledger").insert([entry]); - - if (error) { - throw new Error(`Failed to record songstats quota spend: ${error.message}`); - } -} diff --git a/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts b/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts deleted file mode 100644 index 0610a4b2..00000000 --- a/lib/supabase/songstats_quota_ledger/selectSongstatsQuotaSpent.ts +++ /dev/null @@ -1,23 +0,0 @@ -import supabase from "../serverClient"; - -/** - * Sum Songstats hits spent since a window start (the rolling 30-day quota - * check). Errors are treated as the full quota being spent — failing safe: - * the worker must never overspend because the ledger was unreadable. - * - * @param since - Inclusive ISO lower bound of the window - * @returns Total hits spent in the window - */ -export async function selectSongstatsQuotaSpent(since: string): Promise { - const { data, error } = await supabase - .from("songstats_quota_ledger") - .select("hits") - .gte("spent_at", since); - - if (error) { - console.error("Error reading songstats quota ledger:", error); - return Number.MAX_SAFE_INTEGER; - } - - return (data || []).reduce((sum, row) => sum + row.hits, 0); -} From f6eed906e39f74b53b9d7f7859382bcb3695b4f5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 16:21:02 -0500 Subject: [PATCH 3/6] =?UTF-8?q?refactor(songstats):=20address=20PR=20#673?= =?UTF-8?q?=20review=20=E2=80=94=20SRP,=20retry=20scope,=20strand=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SRP (sweetman): extract isRetryable → lib/songstats/isRetryableStatus.ts; reuse the existing lib/time/delay.ts instead of an inline realSleep. - Narrow retryable 5xx to transient gateway codes 502/503/504 (fetchSongstats maps a missing key / fetch failure to 500 → permanent, don't retry). - Fix deferred-break stranding the rest of the claimed batch in `in_progress`: releaseClaimedRowsStep returns the unprocessed remainder to `pending` so the next drain retries them instead of waiting on the 1h stale-reclaim. - Tighten the default backoff budget to the documented ~15s (4 retries: 1+2+4+8). - Deferred log says "retryable" not "rate-limited" (covers 408/5xx defers). - Restore the exact upsert-payload assertion in the 200 test; add tests for isRetryableStatus, releaseSongstatsBackfillRows, and the workflow strand-release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/backfillTrackStep.test.ts | 13 +++- .../songstatsBackfillWorkflow.test.ts | 61 +++++++++++++++++++ app/workflows/backfillTrackStep.ts | 5 +- app/workflows/releaseClaimedRowsStep.ts | 15 +++++ app/workflows/songstatsBackfillWorkflow.ts | 18 ++++-- .../fetchSongstatsWithBackoff.test.ts | 12 +++- .../__tests__/isRetryableStatus.test.ts | 26 ++++++++ lib/songstats/fetchSongstatsWithBackoff.ts | 22 +++---- lib/songstats/isRetryableStatus.ts | 14 +++++ .../releaseSongstatsBackfillRows.test.ts | 39 ++++++++++++ .../releaseSongstatsBackfillRows.ts | 26 ++++++++ 11 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 app/workflows/__tests__/songstatsBackfillWorkflow.test.ts create mode 100644 app/workflows/releaseClaimedRowsStep.ts create mode 100644 lib/songstats/__tests__/isRetryableStatus.test.ts create mode 100644 lib/songstats/isRetryableStatus.ts create mode 100644 lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts create mode 100644 lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index a26fc6e8..12931350 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -49,7 +49,18 @@ describe("backfillTrackStep", () => { isrc: "USA2P2015959", source: "spotify", }); - expect(upsertSongMeasurements).toHaveBeenCalled(); + // exact transformation: each history point → a permanent songstats measurement + expect(upsertSongMeasurements).toHaveBeenCalledWith([ + { + song: "USA2P2015959", + platform: "spotify", + metric: "platform_displayed_play_count", + value: 1008736324, + captured_at: "2025-01-01T00:00:00.000Z", + data_source: "songstats", + raw_ref: "songstats-backfill", + }, + ]); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, purpose: "backfill USA2P2015959", diff --git a/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts new file mode 100644 index 00000000..cc435558 --- /dev/null +++ b/app/workflows/__tests__/songstatsBackfillWorkflow.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { songstatsBackfillWorkflow } from "../songstatsBackfillWorkflow"; + +import { getBackfillBudgetStep } from "../getBackfillBudgetStep"; +import { claimBackfillRowsStep } from "../claimBackfillRowsStep"; +import { backfillTrackStep } from "../backfillTrackStep"; +import { releaseClaimedRowsStep } from "../releaseClaimedRowsStep"; + +vi.mock("../getBackfillBudgetStep", () => ({ getBackfillBudgetStep: vi.fn() })); +vi.mock("../claimBackfillRowsStep", () => ({ claimBackfillRowsStep: vi.fn() })); +vi.mock("../backfillTrackStep", () => ({ backfillTrackStep: vi.fn() })); +vi.mock("../releaseClaimedRowsStep", () => ({ releaseClaimedRowsStep: vi.fn() })); + +const row = (id: string) => ({ id, song: id }) as never; + +describe("songstatsBackfillWorkflow", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(releaseClaimedRowsStep).mockResolvedValue(undefined); + }); + + it("releases the rest of the claimed batch to pending when a track defers", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); + vi.mocked(claimBackfillRowsStep).mockResolvedValue([row("r1"), row("r2"), row("r3")]); + vi.mocked(backfillTrackStep) + .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) // r1 + .mockResolvedValueOnce({ ok: false, hitsSpent: 0, deferred: true }); // r2 defers + + const result = await songstatsBackfillWorkflow(); + + // r2 is set pending by the step itself; the unprocessed remainder (r3) is released here + expect(releaseClaimedRowsStep).toHaveBeenCalledWith(["r3"]); + expect(backfillTrackStep).toHaveBeenCalledTimes(2); // stopped at the defer, never reached r3 + expect(result).toEqual({ backfilled: 1, failed: 0, deferred: true }); + }); + + it("drains until the queue is empty and never releases when nothing defers", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(100); + vi.mocked(claimBackfillRowsStep) + .mockResolvedValueOnce([row("a"), row("b")]) + .mockResolvedValueOnce([]); // queue drained + vi.mocked(backfillTrackStep) + .mockResolvedValueOnce({ ok: true, hitsSpent: 1 }) + .mockResolvedValueOnce({ ok: false, hitsSpent: 1 }); // terminal (e.g. 404) + + const result = await songstatsBackfillWorkflow(); + + expect(releaseClaimedRowsStep).not.toHaveBeenCalled(); + expect(result).toEqual({ backfilled: 1, failed: 1, deferred: false }); + }); + + it("does not drain when there is no budget", async () => { + vi.mocked(getBackfillBudgetStep).mockResolvedValue(0); + + const result = await songstatsBackfillWorkflow(); + + expect(claimBackfillRowsStep).not.toHaveBeenCalled(); + expect(result).toEqual({ backfilled: 0, failed: 0, deferred: false }); + }); +}); diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index 1e4c1c48..b5b8c272 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -31,9 +31,10 @@ export async function backfillTrackStep( }); if (result.retriesExhausted) { - // Rate-limited past the backoff bound — leave it for the next run, spend nothing. + // Still retryable (429 throttle / 408 / gateway 5xx) past the backoff bound — + // leave it for the next run, spend nothing. console.log( - `[backfill] ${row.song} deferred (rate-limited ${result.status} after ${result.attempts} tries)`, + `[backfill] ${row.song} deferred (retryable ${result.status} after ${result.attempts} tries)`, ); await updateSongstatsBackfillQueue(row.id, { status: "pending" }); return { ok: false, hitsSpent: 0, deferred: true }; diff --git a/app/workflows/releaseClaimedRowsStep.ts b/app/workflows/releaseClaimedRowsStep.ts new file mode 100644 index 00000000..39c6a55f --- /dev/null +++ b/app/workflows/releaseClaimedRowsStep.ts @@ -0,0 +1,15 @@ +import { releaseSongstatsBackfillRows } from "@/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows"; + +/** + * Durable step: return unprocessed claimed rows to `pending` when the drain + * stops early on a defer, so the next run retries them immediately instead of + * waiting on the stale-reclaim sweep. + * + * @param ids - Queue row ids still `in_progress` from the aborted batch. + */ +export async function releaseClaimedRowsStep(ids: string[]): Promise { + "use step"; + if (ids.length === 0) return; + await releaseSongstatsBackfillRows(ids); + console.log(`[songstats-backfill] released ${ids.length} claimed rows back to pending`); +} diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index 67c1a6e4..f94c0223 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -1,6 +1,7 @@ import { getBackfillBudgetStep } from "@/app/workflows/getBackfillBudgetStep"; import { claimBackfillRowsStep } from "@/app/workflows/claimBackfillRowsStep"; import { backfillTrackStep } from "@/app/workflows/backfillTrackStep"; +import { releaseClaimedRowsStep } from "@/app/workflows/releaseClaimedRowsStep"; const BATCH_SIZE = 25; @@ -9,9 +10,11 @@ const BATCH_SIZE = 25; * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic * series, with per-track exponential backoff handling Songstats' rate limit * (chat#1797). **Stops as soon as a track defers** — Songstats still - * rate-limiting it past the backoff bound — leaving the rest `pending` for the - * next drain instead of hammering a saturated API. Every successful hit converts - * into permanent owned data (fetch-once: captured history is never refetched). + * rate-limiting it past the backoff bound — releasing the rest of the claimed + * batch back to `pending` (so the next drain retries them immediately rather + * than waiting on stale-reclaim) instead of hammering a saturated API. Every + * successful hit converts into permanent owned data (fetch-once: captured + * history is never refetched). */ export async function songstatsBackfillWorkflow() { "use workflow"; @@ -26,11 +29,14 @@ export async function songstatsBackfillWorkflow() { if (rows.length === 0) break; console.log(`[songstats-backfill] claimed ${rows.length} rows`); - for (const row of rows) { - const result = await backfillTrackStep(row); + for (let i = 0; i < rows.length; i += 1) { + const result = await backfillTrackStep(rows[i]); if (result.deferred) { - // Songstats is saturated — stop now; remaining claimed rows stay pending. + // Songstats is saturated — stop now. The deferred row is already back to + // `pending`; release the rest of this claimed batch too so they don't sit + // `in_progress` until stale-reclaim. deferred = true; + await releaseClaimedRowsStep(rows.slice(i + 1).map(r => r.id)); break drain; } budget -= result.hitsSpent; diff --git a/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts index 1dda52b4..21015ef1 100644 --- a/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts +++ b/lib/songstats/__tests__/fetchSongstatsWithBackoff.test.ts @@ -58,7 +58,7 @@ describe("fetchSongstatsWithBackoff", () => { expect(r).toMatchObject({ status: 429, retriesExhausted: true }); }); - it("treats 5xx and 408 as retryable too", async () => { + it("treats transient gateway 5xx (503) and 408 as retryable", async () => { vi.mocked(fetchSongstats) .mockResolvedValueOnce({ status: 503, data: {} }) .mockResolvedValueOnce({ status: 200, data: {} }); @@ -67,6 +67,16 @@ describe("fetchSongstatsWithBackoff", () => { expect(r).toMatchObject({ status: 200, retriesExhausted: false }); }); + it("does NOT retry a 500 (fetchSongstats maps missing key / fetch failure to 500)", async () => { + vi.mocked(fetchSongstats).mockResolvedValue({ status: 500, data: {} }); + + const r = await fetchSongstatsWithBackoff("p", undefined, { sleep: noSleep }); + + expect(fetchSongstats).toHaveBeenCalledTimes(1); + expect(noSleep).not.toHaveBeenCalled(); + expect(r).toMatchObject({ status: 500, retriesExhausted: false }); + }); + it("caps the backoff at maxMs", async () => { vi.mocked(fetchSongstats).mockResolvedValue({ status: 429, data: {} }); await fetchSongstatsWithBackoff("p", undefined, { diff --git a/lib/songstats/__tests__/isRetryableStatus.test.ts b/lib/songstats/__tests__/isRetryableStatus.test.ts new file mode 100644 index 00000000..a1b707f0 --- /dev/null +++ b/lib/songstats/__tests__/isRetryableStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isRetryableStatus } from "../isRetryableStatus"; + +describe("isRetryableStatus", () => { + it("retries transient throttling/timeouts: 408, 429", () => { + expect(isRetryableStatus(408)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + }); + + it("retries transient gateway 5xx: 502, 503, 504", () => { + expect(isRetryableStatus(502)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(504)).toBe(true); + }); + + it("does NOT retry 500/501 (fetchSongstats maps missing key + fetch failures to 500)", () => { + expect(isRetryableStatus(500)).toBe(false); + expect(isRetryableStatus(501)).toBe(false); + }); + + it("does NOT retry definitive responses: 200, 404, 403", () => { + expect(isRetryableStatus(200)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(403)).toBe(false); + }); +}); diff --git a/lib/songstats/fetchSongstatsWithBackoff.ts b/lib/songstats/fetchSongstatsWithBackoff.ts index 0110469d..8b3a4b09 100644 --- a/lib/songstats/fetchSongstatsWithBackoff.ts +++ b/lib/songstats/fetchSongstatsWithBackoff.ts @@ -1,18 +1,16 @@ import { fetchSongstats } from "@/lib/songstats/fetchSongstats"; +import { isRetryableStatus } from "@/lib/songstats/isRetryableStatus"; +import { delay } from "@/lib/time/delay"; import type { ProxyResult } from "@/lib/research/ProxyResult"; -// Short, in-step backoff for Songstats' per-second rate limit (total ~15s, well -// within a workflow step's duration). Persistent rejection defers the row to the -// next drain run rather than sleeping for minutes inside one invocation. -const DEFAULT_MAX_RETRIES = 5; +// Short, in-step backoff for Songstats' per-second rate limit. The default +// budget is 1+2+4+8 = 15s of waits (base 1s, doubling, capped at 8s, 4 retries), +// well within a workflow step's duration. Persistent rejection defers the row to +// the next drain run rather than sleeping for minutes inside one invocation. +const DEFAULT_MAX_RETRIES = 4; const DEFAULT_BASE_MS = 1000; const DEFAULT_MAX_MS = 8_000; -/** Transient statuses worth retrying: 408 timeout, 429 rate limit, any 5xx. */ -const isRetryable = (status: number) => status === 408 || status === 429 || status >= 500; - -const realSleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - export type FetchSongstatsBackoffOptions = { maxRetries?: number; baseMs?: number; @@ -49,15 +47,15 @@ export async function fetchSongstatsWithBackoff( const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; const baseMs = options.baseMs ?? DEFAULT_BASE_MS; const maxMs = options.maxMs ?? DEFAULT_MAX_MS; - const sleep = options.sleep ?? realSleep; + const sleep = options.sleep ?? delay; let result = await fetchSongstats(path, queryParams); let retries = 0; - while (isRetryable(result.status) && retries < maxRetries) { + while (isRetryableStatus(result.status) && retries < maxRetries) { await sleep(Math.min(maxMs, baseMs * 2 ** retries)); result = await fetchSongstats(path, queryParams); retries += 1; } - return { ...result, attempts: retries + 1, retriesExhausted: isRetryable(result.status) }; + return { ...result, attempts: retries + 1, retriesExhausted: isRetryableStatus(result.status) }; } diff --git a/lib/songstats/isRetryableStatus.ts b/lib/songstats/isRetryableStatus.ts new file mode 100644 index 00000000..eb0004ee --- /dev/null +++ b/lib/songstats/isRetryableStatus.ts @@ -0,0 +1,14 @@ +/** + * Whether a Songstats response status is worth retrying with backoff. + * + * Transient: 408 (request timeout), 429 (rate limit), and the gateway 5xx + * 502/503/504 (bad gateway / unavailable / gateway timeout). Deliberately + * **excludes** 500/501 — `fetchSongstats` maps a missing API key and any + * non-HTTP fetch failure to 500, which are permanent for that request, so + * retrying them just burns the backoff budget before the row defers. + * + * @param status - HTTP status from `fetchSongstats`. + */ +export function isRetryableStatus(status: number): boolean { + return status === 408 || status === 429 || status === 502 || status === 503 || status === 504; +} diff --git a/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts b/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts new file mode 100644 index 00000000..f03f7cf4 --- /dev/null +++ b/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { releaseSongstatsBackfillRows } from "../releaseSongstatsBackfillRows"; +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { default: { from: mockFrom } }; +}); + +describe("releaseSongstatsBackfillRows", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resets the given ids to pending in one update", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await releaseSongstatsBackfillRows(["q1", "q2"]); + + expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); + expect(update).toHaveBeenCalledWith({ status: "pending" }); + expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); + }); + + it("is a no-op on an empty list (no DB call)", async () => { + await releaseSongstatsBackfillRows([]); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("throws on update error", async () => { + const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await expect(releaseSongstatsBackfillRows(["q1"])).rejects.toThrow( + "Failed to release songstats backfill rows: boom", + ); + }); +}); diff --git a/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts b/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts new file mode 100644 index 00000000..0bb5a4b4 --- /dev/null +++ b/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts @@ -0,0 +1,26 @@ +import supabase from "../serverClient"; + +/** + * Return a set of claimed (`in_progress`) backfill rows to `pending` in one + * round trip. Used when the drain stops early (a track deferred under sustained + * rate-limiting): the rest of the already-claimed batch must go back to + * `pending` so the next drain retries them immediately, instead of sitting + * `in_progress` until the 1-hour stale-reclaim sweep picks them up. + * + * No-op on an empty list. Throws on a DB error so the caller fails loudly. + * + * @param ids - Queue row ids to release back to `pending`. + * @throws Error if the update fails + */ +export async function releaseSongstatsBackfillRows(ids: string[]): Promise { + if (ids.length === 0) return; + + const { error } = await supabase + .from("songstats_backfill_queue") + .update({ status: "pending" }) + .in("id", ids); + + if (error) { + throw new Error(`Failed to release songstats backfill rows: ${error.message}`); + } +} From 805bef49068335744603b4cbdf1b2b071488e134 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 16:25:05 -0500 Subject: [PATCH 4/6] refactor(songstats): co-locate releaseSongstatsBackfillRows in updateSongstatsBackfillQueue (PR #673 review) Per review: queue status-mutation helpers live together in updateSongstatsBackfillQueue.ts (alongside updateSongstatsBackfillQueue + reclaimStaleSongstatsBackfillRows), not in a standalone file. Moves the function + its tests there and drops the separate files. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/workflows/releaseClaimedRowsStep.ts | 2 +- .../releaseSongstatsBackfillRows.test.ts | 39 ------------------- .../updateSongstatsBackfillQueue.test.ts | 32 +++++++++++++++ .../releaseSongstatsBackfillRows.ts | 26 ------------- .../updateSongstatsBackfillQueue.ts | 23 +++++++++++ 5 files changed, 56 insertions(+), 66 deletions(-) delete mode 100644 lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts delete mode 100644 lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts diff --git a/app/workflows/releaseClaimedRowsStep.ts b/app/workflows/releaseClaimedRowsStep.ts index 39c6a55f..b22f581e 100644 --- a/app/workflows/releaseClaimedRowsStep.ts +++ b/app/workflows/releaseClaimedRowsStep.ts @@ -1,4 +1,4 @@ -import { releaseSongstatsBackfillRows } from "@/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows"; +import { releaseSongstatsBackfillRows } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; /** * Durable step: return unprocessed claimed rows to `pending` when the drain diff --git a/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts b/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts deleted file mode 100644 index f03f7cf4..00000000 --- a/lib/supabase/songstats_backfill_queue/__tests__/releaseSongstatsBackfillRows.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { releaseSongstatsBackfillRows } from "../releaseSongstatsBackfillRows"; -import supabase from "../../serverClient"; - -vi.mock("../../serverClient", () => { - const mockFrom = vi.fn(); - return { default: { from: mockFrom } }; -}); - -describe("releaseSongstatsBackfillRows", () => { - beforeEach(() => vi.clearAllMocks()); - - it("resets the given ids to pending in one update", async () => { - const inFn = vi.fn().mockResolvedValue({ error: null }); - const update = vi.fn().mockReturnValue({ in: inFn }); - vi.mocked(supabase.from).mockReturnValue({ update } as never); - - await releaseSongstatsBackfillRows(["q1", "q2"]); - - expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); - expect(update).toHaveBeenCalledWith({ status: "pending" }); - expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); - }); - - it("is a no-op on an empty list (no DB call)", async () => { - await releaseSongstatsBackfillRows([]); - expect(supabase.from).not.toHaveBeenCalled(); - }); - - it("throws on update error", async () => { - const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - const update = vi.fn().mockReturnValue({ in: inFn }); - vi.mocked(supabase.from).mockReturnValue({ update } as never); - - await expect(releaseSongstatsBackfillRows(["q1"])).rejects.toThrow( - "Failed to release songstats backfill rows: boom", - ); - }); -}); diff --git a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts index eb8d0230..667d0e11 100644 --- a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts +++ b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { updateSongstatsBackfillQueue, reclaimStaleSongstatsBackfillRows, + releaseSongstatsBackfillRows, } from "../updateSongstatsBackfillQueue"; import supabase from "../../serverClient"; @@ -67,3 +68,34 @@ describe("reclaimStaleSongstatsBackfillRows", () => { ); }); }); + +describe("releaseSongstatsBackfillRows", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resets the given ids to pending in one update", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await releaseSongstatsBackfillRows(["q1", "q2"]); + + expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); + expect(update).toHaveBeenCalledWith({ status: "pending" }); + expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); + }); + + it("is a no-op on an empty list (no DB call)", async () => { + await releaseSongstatsBackfillRows([]); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it("throws on update error", async () => { + const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await expect(releaseSongstatsBackfillRows(["q1"])).rejects.toThrow( + "Failed to release songstats backfill rows: boom", + ); + }); +}); diff --git a/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts b/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts deleted file mode 100644 index 0bb5a4b4..00000000 --- a/lib/supabase/songstats_backfill_queue/releaseSongstatsBackfillRows.ts +++ /dev/null @@ -1,26 +0,0 @@ -import supabase from "../serverClient"; - -/** - * Return a set of claimed (`in_progress`) backfill rows to `pending` in one - * round trip. Used when the drain stops early (a track deferred under sustained - * rate-limiting): the rest of the already-claimed batch must go back to - * `pending` so the next drain retries them immediately, instead of sitting - * `in_progress` until the 1-hour stale-reclaim sweep picks them up. - * - * No-op on an empty list. Throws on a DB error so the caller fails loudly. - * - * @param ids - Queue row ids to release back to `pending`. - * @throws Error if the update fails - */ -export async function releaseSongstatsBackfillRows(ids: string[]): Promise { - if (ids.length === 0) return; - - const { error } = await supabase - .from("songstats_backfill_queue") - .update({ status: "pending" }) - .in("id", ids); - - if (error) { - throw new Error(`Failed to release songstats backfill rows: ${error.message}`); - } -} diff --git a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts index b462381a..f5154045 100644 --- a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts +++ b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts @@ -48,3 +48,26 @@ export async function reclaimStaleSongstatsBackfillRows(): Promise { return data?.length ?? 0; } + +/** + * Return a set of claimed (`in_progress`) rows to `pending` in one round trip. + * Used when the drain stops early (a track deferred under sustained + * rate-limiting): the rest of the already-claimed batch goes back to `pending` + * so the next drain retries them immediately, instead of sitting `in_progress` + * until the 1-hour stale-reclaim sweep. No-op on an empty list. + * + * @param ids - Queue row ids to release back to `pending`. + * @throws Error if the update fails + */ +export async function releaseSongstatsBackfillRows(ids: string[]): Promise { + if (ids.length === 0) return; + + const { error } = await supabase + .from("songstats_backfill_queue") + .update({ status: "pending" }) + .in("id", ids); + + if (error) { + throw new Error(`Failed to release songstats backfill rows: ${error.message}`); + } +} From e9a1b05a167e2df2b2283a1fce3c4db5b6f13510 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 16:34:28 -0500 Subject: [PATCH 5/6] =?UTF-8?q?refactor(songstats):=20KISS=20=E2=80=94=20o?= =?UTF-8?q?ne=20updateSongstatsBackfillQueue(ids[],=20fields)=20for=20sing?= =?UTF-8?q?le=20+=20bulk=20(PR=20#673=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: collapse the separate bulk helper into updateSongstatsBackfillQueue by taking an id array and using .in("id", ids) (works for one id or many). The deferred-batch release now calls updateSongstatsBackfillQueue(ids, {status: "pending"}); per-row callers pass [row.id]. No-op on an empty list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/backfillTrackStep.test.ts | 8 +-- app/workflows/backfillTrackStep.ts | 6 +- app/workflows/releaseClaimedRowsStep.ts | 4 +- .../updateSongstatsBackfillQueue.test.ts | 64 +++++++------------ .../updateSongstatsBackfillQueue.ts | 38 +++-------- 5 files changed, 43 insertions(+), 77 deletions(-) diff --git a/app/workflows/__tests__/backfillTrackStep.test.ts b/app/workflows/__tests__/backfillTrackStep.test.ts index 12931350..6c211ed6 100644 --- a/app/workflows/__tests__/backfillTrackStep.test.ts +++ b/app/workflows/__tests__/backfillTrackStep.test.ts @@ -65,7 +65,7 @@ describe("backfillTrackStep", () => { hits: 1, purpose: "backfill USA2P2015959", }); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(result).toEqual({ ok: true, hitsSpent: 1 }); }); @@ -80,7 +80,7 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); // left pending for the next drain; NO ledger hit (Songstats consumed nothing) - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "pending" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "pending" }); expect(insertSongstatsQuotaLedger).not.toHaveBeenCalled(); expect(upsertSongMeasurements).not.toHaveBeenCalled(); expect(result).toEqual({ ok: false, hitsSpent: 0, deferred: true }); @@ -96,7 +96,7 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, purpose: "backfill USA2P2015959 (no data 404)", @@ -114,7 +114,7 @@ describe("backfillTrackStep", () => { const result = await backfillTrackStep(ROW); - expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith("q1", { status: "done" }); + expect(updateSongstatsBackfillQueue).toHaveBeenCalledWith(["q1"], { status: "done" }); expect(insertSongstatsQuotaLedger).toHaveBeenCalledWith({ hits: 1, purpose: "backfill USA2P2015959 (terminal 403)", diff --git a/app/workflows/backfillTrackStep.ts b/app/workflows/backfillTrackStep.ts index b5b8c272..fd394743 100644 --- a/app/workflows/backfillTrackStep.ts +++ b/app/workflows/backfillTrackStep.ts @@ -36,7 +36,7 @@ export async function backfillTrackStep( console.log( `[backfill] ${row.song} deferred (retryable ${result.status} after ${result.attempts} tries)`, ); - await updateSongstatsBackfillQueue(row.id, { status: "pending" }); + await updateSongstatsBackfillQueue([row.id], { status: "pending" }); return { ok: false, hitsSpent: 0, deferred: true }; } @@ -49,7 +49,7 @@ export async function backfillTrackStep( hits: 1, purpose: `backfill ${row.song} (${noData ? "no data 404" : `terminal ${result.status}`})`, }); - await updateSongstatsBackfillQueue(row.id, { status: "done" }); + await updateSongstatsBackfillQueue([row.id], { status: "done" }); return { ok: false, hitsSpent: 1 }; } @@ -77,6 +77,6 @@ export async function backfillTrackStep( console.log(`[backfill] ${row.song} done (${rows.length} points written)`); await insertSongstatsQuotaLedger({ hits: 1, purpose: `backfill ${row.song}` }); - await updateSongstatsBackfillQueue(row.id, { status: "done" }); + await updateSongstatsBackfillQueue([row.id], { status: "done" }); return { ok: true, hitsSpent: 1 }; } diff --git a/app/workflows/releaseClaimedRowsStep.ts b/app/workflows/releaseClaimedRowsStep.ts index b22f581e..788d752a 100644 --- a/app/workflows/releaseClaimedRowsStep.ts +++ b/app/workflows/releaseClaimedRowsStep.ts @@ -1,4 +1,4 @@ -import { releaseSongstatsBackfillRows } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; +import { updateSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue"; /** * Durable step: return unprocessed claimed rows to `pending` when the drain @@ -10,6 +10,6 @@ import { releaseSongstatsBackfillRows } from "@/lib/supabase/songstats_backfill_ export async function releaseClaimedRowsStep(ids: string[]): Promise { "use step"; if (ids.length === 0) return; - await releaseSongstatsBackfillRows(ids); + await updateSongstatsBackfillQueue(ids, { status: "pending" }); console.log(`[songstats-backfill] released ${ids.length} claimed rows back to pending`); } diff --git a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts index 667d0e11..73be7347 100644 --- a/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts +++ b/lib/supabase/songstats_backfill_queue/__tests__/updateSongstatsBackfillQueue.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { updateSongstatsBackfillQueue, reclaimStaleSongstatsBackfillRows, - releaseSongstatsBackfillRows, } from "../updateSongstatsBackfillQueue"; import supabase from "../../serverClient"; @@ -15,24 +14,40 @@ vi.mock("../../serverClient", () => { describe("updateSongstatsBackfillQueue", () => { beforeEach(() => vi.clearAllMocks()); - it("updates a queue row's status by id", async () => { - const eq = vi.fn().mockResolvedValue({ error: null }); - const update = vi.fn().mockReturnValue({ eq }); + it("updates a single row by id (one-element array)", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); vi.mocked(supabase.from).mockReturnValue({ update } as never); - await updateSongstatsBackfillQueue("q1", { status: "done" }); + await updateSongstatsBackfillQueue(["q1"], { status: "done" }); expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); expect(update).toHaveBeenCalledWith({ status: "done" }); - expect(eq).toHaveBeenCalledWith("id", "q1"); + expect(inFn).toHaveBeenCalledWith("id", ["q1"]); + }); + + it("bulk-updates many rows in one call (e.g. releasing a claimed batch)", async () => { + const inFn = vi.fn().mockResolvedValue({ error: null }); + const update = vi.fn().mockReturnValue({ in: inFn }); + vi.mocked(supabase.from).mockReturnValue({ update } as never); + + await updateSongstatsBackfillQueue(["q1", "q2"], { status: "pending" }); + + expect(update).toHaveBeenCalledWith({ status: "pending" }); + expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); + }); + + it("is a no-op on an empty id list (no DB call)", async () => { + await updateSongstatsBackfillQueue([], { status: "pending" }); + expect(supabase.from).not.toHaveBeenCalled(); }); it("throws on update error", async () => { - const eq = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - const update = vi.fn().mockReturnValue({ eq }); + const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); + const update = vi.fn().mockReturnValue({ in: inFn }); vi.mocked(supabase.from).mockReturnValue({ update } as never); - await expect(updateSongstatsBackfillQueue("q1", { status: "failed" })).rejects.toThrow( + await expect(updateSongstatsBackfillQueue(["q1"], { status: "failed" })).rejects.toThrow( "Failed to update songstats backfill queue: boom", ); }); @@ -68,34 +83,3 @@ describe("reclaimStaleSongstatsBackfillRows", () => { ); }); }); - -describe("releaseSongstatsBackfillRows", () => { - beforeEach(() => vi.clearAllMocks()); - - it("resets the given ids to pending in one update", async () => { - const inFn = vi.fn().mockResolvedValue({ error: null }); - const update = vi.fn().mockReturnValue({ in: inFn }); - vi.mocked(supabase.from).mockReturnValue({ update } as never); - - await releaseSongstatsBackfillRows(["q1", "q2"]); - - expect(supabase.from).toHaveBeenCalledWith("songstats_backfill_queue"); - expect(update).toHaveBeenCalledWith({ status: "pending" }); - expect(inFn).toHaveBeenCalledWith("id", ["q1", "q2"]); - }); - - it("is a no-op on an empty list (no DB call)", async () => { - await releaseSongstatsBackfillRows([]); - expect(supabase.from).not.toHaveBeenCalled(); - }); - - it("throws on update error", async () => { - const inFn = vi.fn().mockResolvedValue({ error: { message: "boom" } }); - const update = vi.fn().mockReturnValue({ in: inFn }); - vi.mocked(supabase.from).mockReturnValue({ update } as never); - - await expect(releaseSongstatsBackfillRows(["q1"])).rejects.toThrow( - "Failed to release songstats backfill rows: boom", - ); - }); -}); diff --git a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts index f5154045..d2469412 100644 --- a/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts +++ b/lib/supabase/songstats_backfill_queue/updateSongstatsBackfillQueue.ts @@ -2,17 +2,22 @@ import supabase from "../serverClient"; import { TablesUpdate } from "@/types/database.types"; /** - * Update a backfill queue row (mark done/failed after a claim). + * Update one or more backfill queue rows by id in a single round trip. Handles + * both the per-row status flip after a claim (`[row.id]`) and the bulk release + * of a claimed batch back to `pending` when the drain stops early (`.in` works + * for one id or many). No-op on an empty id list. * - * @param id - The queue row id - * @param fields - Fields to update + * @param ids - Queue row ids to update + * @param fields - Fields to set (e.g. `{ status: "done" }`) * @throws Error if the update fails */ export async function updateSongstatsBackfillQueue( - id: string, + ids: string[], fields: TablesUpdate<"songstats_backfill_queue">, ): Promise { - const { error } = await supabase.from("songstats_backfill_queue").update(fields).eq("id", id); + if (ids.length === 0) return; + + const { error } = await supabase.from("songstats_backfill_queue").update(fields).in("id", ids); if (error) { throw new Error(`Failed to update songstats backfill queue: ${error.message}`); @@ -48,26 +53,3 @@ export async function reclaimStaleSongstatsBackfillRows(): Promise { return data?.length ?? 0; } - -/** - * Return a set of claimed (`in_progress`) rows to `pending` in one round trip. - * Used when the drain stops early (a track deferred under sustained - * rate-limiting): the rest of the already-claimed batch goes back to `pending` - * so the next drain retries them immediately, instead of sitting `in_progress` - * until the 1-hour stale-reclaim sweep. No-op on an empty list. - * - * @param ids - Queue row ids to release back to `pending`. - * @throws Error if the update fails - */ -export async function releaseSongstatsBackfillRows(ids: string[]): Promise { - if (ids.length === 0) return; - - const { error } = await supabase - .from("songstats_backfill_queue") - .update({ status: "pending" }) - .in("id", ids); - - if (error) { - throw new Error(`Failed to release songstats backfill rows: ${error.message}`); - } -} From 0f8e7e5afa093cdf2d2760525a149cb8ac0a3f3c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 16 Jun 2026 16:47:12 -0500 Subject: [PATCH 6/6] =?UTF-8?q?chore(songstats):=20YAGNI=20=E2=80=94=20tri?= =?UTF-8?q?m=20workflow=20doc=20+=20drop=20stray=20screenshots=20(PR=20#67?= =?UTF-8?q?4=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the JSDoc mention of removed code (no local quota ledger / budget gate) - Delete chat-1756-home.jpeg / chat-test-home.jpeg accidentally swept in by a merge Co-Authored-By: Claude Opus 4.8 (1M context) --- app/workflows/songstatsBackfillWorkflow.ts | 13 ++++++------- chat-1756-home.jpeg | Bin 72878 -> 0 bytes chat-test-home.jpeg | Bin 76746 -> 0 bytes 3 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 chat-1756-home.jpeg delete mode 100644 chat-test-home.jpeg diff --git a/app/workflows/songstatsBackfillWorkflow.ts b/app/workflows/songstatsBackfillWorkflow.ts index 41967bc0..459d1887 100644 --- a/app/workflows/songstatsBackfillWorkflow.ts +++ b/app/workflows/songstatsBackfillWorkflow.ts @@ -7,13 +7,12 @@ const BATCH_SIZE = 25; /** * Durable Songstats backfill drain (recoupable/chat#1791 write path): claim * value-ranked rows via the SKIP LOCKED RPC and backfill each track's historic - * series. There is **no local quota ledger or budget gate** (chat#1797) — - * Songstats is the rate authority: per-track exponential backoff absorbs the - * rate limit, and the run **stops as soon as a track defers** (still retryable - * past the backoff bound), releasing the rest of the claimed batch back to - * `pending` so the next drain retries them immediately rather than waiting on - * stale-reclaim. Otherwise it drains until the queue has no claimable `pending` - * rows. Every backfill converts into permanent owned data (fetch-once). + * series. Per-track exponential backoff absorbs Songstats' rate limit; the run + * **stops as soon as a track defers** (still retryable past the backoff bound), + * releasing the rest of the claimed batch back to `pending` so the next drain + * retries them immediately rather than waiting on stale-reclaim. Otherwise it + * drains until the queue has no claimable `pending` rows. Every backfill + * converts into permanent owned data (fetch-once). */ export async function songstatsBackfillWorkflow() { "use workflow"; diff --git a/chat-1756-home.jpeg b/chat-1756-home.jpeg deleted file mode 100644 index 1f45bb7919cea5ad6bd10834373d118f43dd0f3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72878 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≪+b-x9p$#*wpjYw2HyUN)EpMgR_}=7e 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