You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Supersedes #1090. That tracker did its job: it got us from "scattered backfills" to a measured surface area with a working freshness substrate. This issue is the reframe around the real end goal — homeostasis — with the current state laid out source-by-source, an honest evaluation of what's left, and the one build that actually matters next.
0. The goal, in one sentence
Homeostasis = the system keeps its own data complete and fresh with no babysitting.
Three layers, in order. Each is a prerequisite for the next:
Surface area → 100% terminal. Every eligible data point is either fetched or confirmed source-absent (a durable "we tried, the source has nothing" marker). NOT "100% of every field" — much data doesn't exist at the source.
A freshness layer that works. A uniform ledger that knows, for every data point: where it came from, when we fetched it, and whether it's due for a refresh.
Demand-driven refresh. The system refreshes what's stale — proactively (capped background sweepers) and on access (you load a movie page → its stale data self-freshens). ← this is the prize.
Two numbers we never conflate:
Coverage % — we have the value.
Terminal % — we have it or confirmed the source doesn't. ← this is what we drive to 100%.
1. Current state — the whole surface area (📸 snapshot 2026-06-10 — regenerate via §9)
Totals: 1,165,578 movies · 680,710 people · freshness ledger = ~1.74M rows across 6 sources.
Layer 1 — fetchable sources (have a terminal goal)
Read this table as: Layer 1 is essentially done. Everything is terminal or at a documented source-ceiling except three things still moving: festival (67%), watch-providers (catalog crawl), and making the imdb_id ceiling explicit (mark the ~510k as source-absent so 56% reads as 100% terminal).
2. How the freshness layer works (Layer 2 — ✅ DONE & live)
The substrate shipped (was #1090 Phase 1 / #1096). It is the foundation everything else stands on:
data_refreshes ledger — one polymorphic table keyed (entity_type, entity_id, source) storing fetched_at, stale_after, status (pending|ok|empty|error|ineligible), error_reason, attempt_count, last_checked_at. It stores freshness metadata only — the values still live in external_metrics / blobs.
Uniform API — Freshness.touch/5 (every fetch worker calls it), Freshness.stale?/3, Freshness.due/2. One vocabulary for all sources.
Policy registry — per-(entity_type, source) staleness rules: :age_tiered (recent movies refresh more often than classics) and :fixed_cadence, with empty-result and error backoff.
Live: ~1.74M ledger rows across all 6 sources. Cinegraph.Health.SurfaceArea.report() renders the table in §1 on demand.
What this buys us: for any data point we can now answer "where from / when / is it due?" — which is exactly the signal Layer 3 consumes to decide what to refresh.
3. Demand-driven refresh (Layer 3 — the end goal)
Two halves. One is shipping; the other is the real prize and isn't built.
3a. Proactive (background, capped) — 🟡 shipping now
#1106 — the unified per-movie TMDb refresh: one append_to_response call re-hydrates a movie's details + metrics + credits + watch-providers and touches the ledger for all of them at once (replacing the old siloed, single-purpose fetches). Driven by Freshness.due/2.
Today: 131,411 tmdb_details + 35,999 watch_providers due — the floor starts chipping tomorrow.
3b. On-access (read-through) — ❌ NOT built — the prize
A movie/person page view checks the ledger; if a source is stale, it enqueues the same unified refresh worker (debounced) so the page self-freshens. This is what makes the catalog stay current without any cron babysitting — true homeostasis. Design lives in #1010.
This is the most important remaining work. Everything else is cleanup or autopilot.
4. The "API budget governor" — what it is, and do we actually need it?
You asked what this is and where it came from. Straight answer:
What it was specced as (old #1090 Phase 4 / #1010 §6): a heavyweight subsystem — a per-source daily API-spend ledger (refresh_budgets table with atomic counters), hard per-source caps (OMDb 90k/day, TMDb 40k/day…), and a Budget.allow?(source) arbiter that every fetch worker calls before each API hit, plus queue backpressure.
Why it was proposed: the fear that once read-through (3b) is on AND the sweepers are uncapped, total API volume becomes unbounded and spiky — a traffic spike or a bot crawling 50k pages could trigger 50k refreshes in minutes and blow OMDb's hard 100k/day quota or hammer the shared 16 GB DB.
Honest evaluation — we do NOT need the heavyweight version. We already have most of the throttling for free:
Oban queue concurrency (tmdb:5, omdb:5…) caps in-flight API calls.
The freshness TTLs themselves bound how often anything re-fetches — steady-state volume ≈ catalog ÷ TTL, not unbounded.
Per-sweeper daily caps (the 5k floor, 30k availability) already cap proactive spend.
The only genuinely unbounded path is read-through, because external traffic drives it. And even that is tamed by lightweight means, not a new subsystem:
Ledger-gated enqueue — only enqueue if due? (a fresh page does nothing).
Oban uniqueness — collapse duplicate enqueues.
Queue-depth backpressure — skip enqueue if the queue is already deep.
A per-source daily ceiling constant — checked against a cheap count of today's completed jobs. No new table, no arbiter.
Verdict / decision: ❌ Drop the heavyweight budget governor. Fold a minimal spend-guard (the 4 bullets above) into the read-through work itself, and only revisit a real budget ledger if we ever observe read-through actually approaching OMDb's 100k/day under real traffic. This removes a large speculative build from the roadmap.
5. Wireframes — the admin homeostasis dashboard
Surface the report() we already compute. New page /admin/homeostasis.
Make the imdb_id ceiling explicit — write source-absent markers for the ~510k TMDb-null movies so 56% reads as 100% terminal. (small, finishes Layer 1)
Layer 1 — terminal: every fetchable source is 100% terminal or a documented source-ceiling with explicit source-absent markers — including the ~510k TMDb-null imdb_id rows marked, so the dashboard reads 100% terminal, not 56%.
Layer 2 — freshness: the ledger covers every source (✅ already true) and is rendered by SurfaceArea.report() + /admin/homeostasis.
Visibility:/admin/homeostasis (§5) shipped; the spend-guard is active and observable; a source that starts drifting re-opens as a child issue rather than silently rotting.
9. How to regenerate the §1 numbers
§1 is a point-in-time snapshot (2026-06-10). To refresh from prod (read-only):
# In `kamal console`, or locally via Cinegraph.ProdRpc.eval_json/1# (must IO.puts(Jason.encode!(...)); convert tuples → lists for JSON).Cinegraph.Health.SurfaceArea.report()# → %{sources: [%{source:, terminal_pct:, eligible:, fetched:, source_absent:, needs_fetch:, ...}], ...}# "Due now" per source = what the refresh engine will pick up:importEcto.Query;aliasCinegraph.Reponow=DateTime.utc_now()due=fnsrc->from(rin"data_refreshes",where: r.source==^srcandr.stale_after<type(^now,:utc_datetime_usec)andr.status!="ineligible",select: count(r.id))|>Repo.one()endEnum.map(~w(tmdb_details watch_providers omdb tmdb_person),&{&1,due.(&1)})
10. Acceptance criteria for the next slices
Per the review: #1108 is the operating map; each slice below ships as its own PR-sized child. These are the bolts to tighten before coding.
10a. Make the imdb_id ceiling explicit — small, finishes Layer 1, no API
Where markers live:data_refreshes rows (entity_type: "movie", source: "imdb_id", status: :empty) — reuse the existing source-absent convention; no new table.
How to compute: movies with a tmdb_id but imdb_id IS NULLafter a TMDb external_ids fetch has run (i.e. we asked, TMDb returned null). Gate on "we actually checked" — never mark an unchecked movie absent.
Idempotency: upsert on (entity_type, entity_id, source); re-runs touch last_checked_at, never duplicate.
Tests: null-after-fetch → :empty; never-checked → untouched; re-run is a no-op; surface_areaimdb_id terminal_pct → ~100%.
Debounce: the ledger is the debounce — enqueue only if Freshness.due?; Oban unique (period ≥ 3600s, keys [:movie_id]) collapses dupes. A fresh page enqueues nothing.
Backpressure: skip enqueue if the target queue's available + scheduled > threshold (start 1,000).
Spend-guard (the lightweight §4 guard): per-source daily ceiling checked against a cheap count of today's completed jobs — start TMDb 40k / OMDb 90k; over cap → skip (log, don't fail). No new table.
Observability: per-source counts of enqueued vs skipped (reason: dup / cap / backpressure) → §5c spend monitor.
Route/auth:/admin/homeostasis, behind existing admin auth (same gate as /admin/health).
Data source:SurfaceArea.report() + ledger due-counts, cached (the report is heavy — reuse the 35-min health-cache + warmer pattern; never compute in the request path).
Refresh behavior: server-rendered from cache; a manual "recompute" action busts the cache.
Buttons: v1 is read-only — the "Run now / Adjust cap" controls in §5b are placeholders until wired to the maintenance modules; label them as such or omit in v1.
Done: the §5a grid renders live per-source terminal / coverage / fresh / due with the overall grade.
0. The goal, in one sentence
Homeostasis = the system keeps its own data complete and fresh with no babysitting.
Three layers, in order. Each is a prerequisite for the next:
Two numbers we never conflate:
1. Current state — the whole surface area (📸 snapshot 2026-06-10 — regenerate via §9)
Totals: 1,165,578 movies · 680,710 people · freshness ledger = ~1.74M rows across 6 sources.
Layer 1 — fetchable sources (have a terminal goal)
Derived (via OMDb/TMDb — coverage is source-absence, not backlog)
rotten_tomatoes37,843 ·metacritic20,912 ·tmdb_budget68,954 ·tmdb_revenue26,115 — all at source ceiling.Computed / supplemental / ephemeral (not fetch backlogs)
collaborations(graph) ·person_quality_scores(weekly) ·stock_images(on-demand) ·wikidata(verification) ·now_playing(ephemeral, continuously refreshed — working).Read this table as: Layer 1 is essentially done. Everything is terminal or at a documented source-ceiling except three things still moving: festival (67%), watch-providers (catalog crawl), and making the imdb_id ceiling explicit (mark the ~510k as source-absent so 56% reads as 100% terminal).
2. How the freshness layer works (Layer 2 — ✅ DONE & live)
The substrate shipped (was #1090 Phase 1 / #1096). It is the foundation everything else stands on:
data_refreshesledger — one polymorphic table keyed(entity_type, entity_id, source)storingfetched_at, stale_after, status (pending|ok|empty|error|ineligible), error_reason, attempt_count, last_checked_at. It stores freshness metadata only — the values still live inexternal_metrics/ blobs.Freshness.touch/5(every fetch worker calls it),Freshness.stale?/3,Freshness.due/2. One vocabulary for all sources.(entity_type, source)staleness rules::age_tiered(recent movies refresh more often than classics) and:fixed_cadence, with empty-result and error backoff.Cinegraph.Health.SurfaceArea.report()renders the table in §1 on demand.What this buys us: for any data point we can now answer "where from / when / is it due?" — which is exactly the signal Layer 3 consumes to decide what to refresh.
3. Demand-driven refresh (Layer 3 — the end goal)
Two halves. One is shipping; the other is the real prize and isn't built.
3a. Proactive (background, capped) — 🟡 shipping now
#1106 — the unified per-movie TMDb refresh: one
append_to_responsecall re-hydrates a movie's details + metrics + credits + watch-providers and touches the ledger for all of them at once (replacing the old siloed, single-purpose fetches). Driven byFreshness.due/2.tmdb_details+ 35,999watch_providersdue — the floor starts chipping tomorrow.3b. On-access (read-through) — ❌ NOT built — the prize
A movie/person page view checks the ledger; if a source is stale, it enqueues the same unified refresh worker (debounced) so the page self-freshens. This is what makes the catalog stay current without any cron babysitting — true homeostasis. Design lives in #1010.
This is the most important remaining work. Everything else is cleanup or autopilot.
4. The "API budget governor" — what it is, and do we actually need it?
You asked what this is and where it came from. Straight answer:
What it was specced as (old #1090 Phase 4 / #1010 §6): a heavyweight subsystem — a per-source daily API-spend ledger (
refresh_budgetstable with atomic counters), hard per-source caps (OMDb 90k/day, TMDb 40k/day…), and aBudget.allow?(source)arbiter that every fetch worker calls before each API hit, plus queue backpressure.Why it was proposed: the fear that once read-through (3b) is on AND the sweepers are uncapped, total API volume becomes unbounded and spiky — a traffic spike or a bot crawling 50k pages could trigger 50k refreshes in minutes and blow OMDb's hard 100k/day quota or hammer the shared 16 GB DB.
Honest evaluation — we do NOT need the heavyweight version. We already have most of the throttling for free:
The only genuinely unbounded path is read-through, because external traffic drives it. And even that is tamed by lightweight means, not a new subsystem:
due?(a fresh page does nothing).Verdict / decision: ❌ Drop the heavyweight budget governor. Fold a minimal spend-guard (the 4 bullets above) into the read-through work itself, and only revisit a real budget ledger if we ever observe read-through actually approaching OMDb's 100k/day under real traffic. This removes a large speculative build from the roadmap.
5. Wireframes — the admin homeostasis dashboard
Surface the
report()we already compute. New page/admin/homeostasis.5a. Overview
5b. Per-source drill-down (click a row)
5c. Refresh-spend monitor (the lightweight guard from §4)
6. Roadmap — what's next, in order
/admin/homeostasisdashboard (§5) — surfacereport()for at-a-glance ongoing visibility.Heavyweight budget governor— dropped (§4); revisit only if evidence demands it.7. Issue map — roped in, superseded, out of scope
Superseded & closed by this issue (their content is folded in above):
Active children (open, roped in):
Correctly OUT of scope (own trackers — this issue does not own them):
8. Definition of Done (the epic)
This epic is done when all four hold:
imdb_idrows marked, so the dashboard reads 100% terminal, not 56%.SurfaceArea.report()+/admin/homeostasis./admin/homeostasis(§5) shipped; the spend-guard is active and observable; a source that starts drifting re-opens as a child issue rather than silently rotting.9. How to regenerate the §1 numbers
§1 is a point-in-time snapshot (2026-06-10). To refresh from prod (read-only):
10. Acceptance criteria for the next slices
10a. Make the
imdb_idceiling explicit — small, finishes Layer 1, no APIdata_refreshesrows(entity_type: "movie", source: "imdb_id", status: :empty)— reuse the existing source-absent convention; no new table.tmdb_idbutimdb_id IS NULLafter a TMDbexternal_idsfetch has run (i.e. we asked, TMDb returned null). Gate on "we actually checked" — never mark an unchecked movie absent.(entity_type, entity_id, source); re-runs touchlast_checked_at, never duplicate.:empty; never-checked → untouched; re-run is a no-op;surface_areaimdb_idterminal_pct → ~100%.imdb_idreads 100% terminal (fetched + source-absent), not 56%.10b. Read-through refresh — Layer 3, the prize (do after #1106 soaks clean)
showmount → check the ledger for that entity's sources; for eachdue?source, enqueue the unified worker (Unified per-movie TMDb refresh (one call, all sources) + near-term availability throughput bump #1106'sTMDbMovieRefreshWorker/ the person equivalent).Freshness.due?; Obanunique(period ≥ 3600s, keys[:movie_id]) collapses dupes. A fresh page enqueues nothing.available + scheduled> threshold (start 1,000).10c.
/admin/homeostasisdashboard (§5)/admin/homeostasis, behind existing admin auth (same gate as/admin/health).SurfaceArea.report()+ ledger due-counts, cached (the report is heavy — reuse the 35-min health-cache + warmer pattern; never compute in the request path).