Runbook — Stale-hold sweeper, 30-day expiry (BIF-8745 / T6)
Billings::SweepStaleWaHoldsWorker (hub_core) releases reservations for messages that were sent
but never delivered. A held row whose webhook never arrived (see
webhook-hold-transitions.md — Meta may send no webhook at
all on TTL expiry) would reserve balance forever; past the ~30-day Meta window it can no longer
deliver, so the sweeper flips it to expired.
What it does — and deliberately does NOT do
- Scope: only
state='held'rows withheld_at < 30.days.ago(STALE_HOLD_HORIZON_DAYS).deliveredholds belong to settlement (eod-settlement-settle-daily.md); terminal states (settled/refunded/expired) are never revisited.heldrows with a NULLheld_atare skipped (nothing to age against). - No money movement — pools/credit are untouched; only settlement moves money. Expiring
releases the reserve: the hold leaves the
held|deliveredset thatreserved_wa_balancesums, and the short-TTL Redis cache is cleared per touched package so the freed reserve is visible to the very next send-gate check. - Race-safe: rows are processed
in_batches(of: 500)with a per-rowwith_lockre-check — a hold the delivery webhook flips todeliveredbetween the batch query and the write is left alone (it settles via T5 instead). retry: 0, queuebilling_settle_wa_holds; a crashed run self-heals on the next night (stale rows only get staler). Unexpected errors go to Rollbar; the run logsexpired N stale holds.
Scheduling
Register in the host app's Sidekiq Cron after the settlement dispatcher (T7), so a hold that could still settle today is consumed by settlement before the sweeper sees it:
sweep_stale_wa_holds:
cron: '0 23 * * *' # ~06:00 Asia/Jakarta — after the settlement fan-out has drained
class: 'Billings::SweepStaleWaHoldsWorker'
queue: billing_settle_wa_holds
The 30-day horizons are deliberately aligned: settlement force-closes unfilled batches at 30d (shortfall), the sweeper expires undelivered holds at 30d — nothing reserves or stays pending forever.
Verify a run
-- chat_billing shard: what just expired
SELECT organization_id, count(*), sum(estimated_amount)
FROM wa_balance_holds
WHERE state = 'expired' AND expired_at >= now() - interval '1 day'
GROUP BY 1 ORDER BY 2 DESC;
-- leak check: nothing held should be older than the horizon after a green run
SELECT count(*) FROM wa_balance_holds
WHERE state = 'held' AND held_at < now() - interval '30 days';
The second query should return 0 after every successful nightly run.
Triage
- Large expiry counts for one org — that org's messages are being sent but their status
webhooks never arrive (webhook wiring broken, or wamids never reach the send subscriber).
Correlate with the T4
wa_hold_missing_on_webhookmetric and the settlement shortfall records; fix the webhook path — the sweeper is masking a delivery-signal gap, not causing one. - A hold expired that later did deliver — the webhook transition treats terminal states as no-ops, so the delivery is intercepted but nothing is charged (bounded under-charge, SC-4). If Meta billed for it, it surfaces as a settlement shortfall on that bucket. No manual reversal — fix the latency source.
- Manual run (safe, idempotent — expired rows don't match the query again):
Billings::SweepStaleWaHoldsWorker.new.perform