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
fix(worker): R1 — drive customer-backup cadence off plans.yaml rpo_minutes (#103)
The customer-Postgres backup scheduler chose hourly-vs-daily cadence from a
hardcoded `switch canonicalTier()` plus a hardcoded SQL tier allow-list. The
hourly tiers (pro/growth/team) happened to match their advertised
`rpo_minutes:60`, but the coupling was implicit: changing rpo_minutes in
plans.yaml would NOT have moved the cadence, so the product could silently
over-promise RPO (effective ~24h vs sold 60m).
Make the cadence RPO-driven from plans.yaml (the same field surfaced on
/api/v1/capabilities), so the cadence that MAKES the RPO true is read from the
field that PROMISES it — they can no longer drift:
rpo_minutes in [1,60] → hourly (pro/growth/team)
rpo_minutes > 60 → once-daily team slot (hobby/hobby_plus = 1440)
rpo_minutes == 0 OR backup_retention_days == 0 → never (anonymous/free)
- BackupPlanRegistry grows RPOMinutes(tier); the common-plans adapter and the
test fake implement it. Registry.RPOMinutes already existed in common/plans.
- Scheduler takes the registry (wired from the same `backupPlans` the runner
uses); nil registry falls back to the legacy hardcoded mapping so a
misconfigured boot never silently stops paid backups.
- Candidate SELECT now excludes only the never-backup tiers (anonymous/free)
instead of an explicit paid-tier allow-list, so a NEW paid tier in
plans.yaml is covered automatically — removing the single-site-list failure
mode (root rule 18) that once dropped hobby_plus + every _yearly variant.
- Per-row retention==0 guard is a second, independent veto (defence-in-depth)
so a stray rpo_minutes on a zero-retention tier still never enqueues.
- Idempotency unchanged: the atomic INSERT … WHERE NOT EXISTS (50-min DB
lookback) still prevents a duplicate enqueue within the hour even across
River RunOnStart double-ticks (dedupe lives in the DB, not River UniqueOpts).
Tests prove: a pro resource becomes due every hour (all 24 hours), a free
resource is never enqueued (SQL-filtered AND registry-gated if it leaks), no
duplicate enqueue within an hour, the [1,60]→hourly / >60→daily boundary, the
zero-retention veto, the nil-registry legacy fallback, and the adapter's
RPOMinutes delegation against the real embedded plans.yaml. jobs package at
96.0% coverage; all new/changed functions at 100%.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
0 commit comments