| import { cronJobs } from "convex/server"; | |
| import { internal } from "./_generated/api"; | |
| const crons = cronJobs(); | |
| crons.hourly( | |
| "cleanup-expired-pairing-tokens", | |
| { minuteUTC: 27 }, | |
| internal.telegramPairingTokens.cleanupExpired, | |
| ); | |
| crons.hourly( | |
| "api-plan-limit-usage-scan", | |
| { minuteUTC: 17 }, | |
| internal.apiPlanLimitUsage.scanApiPlanLimitUsageInternal, | |
| {}, | |
| ); | |
| crons.hourly( | |
| "api-plan-limit-email-delivery", | |
| { minuteUTC: 18 }, | |
| internal.apiPlanLimitEmails.sendDuePlanLimitEmails, | |
| {}, | |
| ); | |
| // PRO-launch broadcast ramp runner. Wakes once a day at 13:00 UTC | |
| // (~9am ET / 6am PT / 3pm CET — early enough that any kill-gate | |
| // trip can be triaged within US business hours, late enough that | |
| // overnight bounces and complaints have flowed back via the Resend | |
| // webhook). The action no-ops when no ramp is configured, the ramp | |
| // is paused, kill-gated, or the prior wave hasn't settled yet — | |
| // see `convex/broadcast/rampRunner.ts` for the full state machine. | |
| // Daily retention prune for the plan-limit tables. apiUsageRollups gains a row | |
| // per user per hourly scan and apiPlanLimitNotices accumulates superseded rows, | |
| // neither with a native TTL — this ages both out past a 90-day window in | |
| // bounded per-run batches. See `pruneApiPlanLimitData` in apiPlanLimitNotices.ts. | |
| crons.daily( | |
| "api-plan-limit-prune", | |
| { hourUTC: 4, minuteUTC: 45 }, | |
| internal.apiPlanLimitNotices.pruneApiPlanLimitData, | |
| {}, | |
| ); | |
| // Daily retention prune for the append-only historical intelligence store | |
| // (#5694). The table has no natural ceiling — every seeder run appends the | |
| // events it published — and each row carries a 512-float vector, so the | |
| // vector index is the real cost being bounded here. Ages rows past | |
| // INTEL_HISTORY_RETENTION_DAYS out by `ingestedAt` in bounded per-run | |
| // batches that self-drain. Also drains expired retraction tombstones | |
| // (#5743) in the same pass, by `retractedAt` — a handful of hand-created | |
| // rows do not justify a second scheduled function. See `prune` in | |
| // convex/intelHistory.ts. 04:30 UTC sits between the plan-limit prune | |
| // (04:45) and the wave-runs cleanup (04:00) so the three delete-heavy jobs | |
| // never overlap. | |
| crons.daily( | |
| "intel-history-prune", | |
| { hourUTC: 4, minuteUTC: 30 }, | |
| internal.intelHistory.prune, | |
| {}, | |
| ); | |
| crons.daily( | |
| "broadcast-ramp-runner", | |
| { hourUTC: 13, minuteUTC: 0 }, | |
| internal.broadcast.rampRunner.runDailyRamp, | |
| ); | |
| // Daily prune of `wavePickedContacts` rows belonging to discarded/failed | |
| // wave runs older than 24h. Each invocation deletes one chunk (500 rows) | |
| // and self-schedules until a run's rows are drained, then moves on. Avoids | |
| // hitting Convex's per-mutation write limit on bulk deletion of up to 25k | |
| // rows in one shot. See `convex/broadcast/waveRuns.ts` | |
| // (`cleanupDiscardedWavePickedContactsAction`). | |
| crons.daily( | |
| "wave-runs-cleanup", | |
| { hourUTC: 4, minuteUTC: 0 }, | |
| internal.broadcast.waveRuns.cleanupDiscardedWavePickedContactsAction, | |
| {}, | |
| ); | |
| // Every 6h, not daily: a payment becomes a reconciliation candidate at ~6h | |
| // pending, so on a daily cadence its age at first scan is uniformly 6h-30h and | |
| // anything landing in (24h, 30h] misses the 24h customer-email freshness gate | |
| // (STUCK_PAYMENT_CUSTOMER_EMAIL_MAX_AGE_MS) — ~25% of ordinary stuck payments | |
| // silently dropped to ops-only. At 6h cadence first-scan age stays <=~12h, so | |
| // every stuck payment gets its recovery email. Safe to run 4x/day: the action | |
| // is fully idempotent and marker-gated (already-handled payments are skipped). | |
| crons.interval( | |
| "payments-stuck-pending-reconciliation", | |
| { hours: 6 }, | |
| internal.payments.billing.reconcileStuckPendingPayments, | |
| {}, | |
| ); | |
| // Idempotent daily seed of the `followedCountriesShards` lock table | |
| // (Codex round-4 P0 v2). Skips existing shards; inserts any missing | |
| // shard ids in `[0, SHARD_COUNT)`. Defends against a deploy-time seed | |
| // step being skipped — every `followCountry` / `unfollowCountry` / | |
| // `mergeAnonymousLocal` mutation throws SHARDS_NOT_SEEDED if its shard | |
| // row is missing, so the cron is the steady-state self-heal. Cheap: | |
| // post-seed it just runs a 64-row collect + skip-loop. | |
| crons.daily( | |
| "followed-countries-shards-seed", | |
| { hourUTC: 3, minuteUTC: 0 }, | |
| internal.followedCountries._seedShards, | |
| ); | |
| // Daily dedupe pass for the `followedCountriesShards` table. Pairs with | |
| // `_seedShards` above: a concurrent-seed race (e.g. the deploy step | |
| // running in parallel with the cron tick) can produce duplicate rows | |
| // for the same `shardId`. `readShardOrThrow` uses `.first()` so | |
| // duplicates don't break correctness, but they degrade OCC contention | |
| // coverage for users hashing to that shard. Running the dedupe in the | |
| // same daily slot, 1 minute after the seed, guarantees the table is | |
| // back to exactly SHARD_COUNT rows within 24h of any race. Idempotent | |
| // in the steady-state (no duplicates → no deletes). | |
| crons.daily( | |
| "followed-countries-shards-dedupe", | |
| { hourUTC: 3, minuteUTC: 1 }, | |
| internal.followedCountries._dedupeShards, | |
| ); | |
| crons.daily( | |
| "followed-countries-country-locks-seed", | |
| { hourUTC: 3, minuteUTC: 2 }, | |
| internal.followedCountries._seedCountryLocks, | |
| ); | |
| crons.daily( | |
| "followed-countries-country-locks-dedupe", | |
| { hourUTC: 3, minuteUTC: 3 }, | |
| internal.followedCountries._dedupeCountryLocks, | |
| ); | |
| // Daily self-heal for the singleton Dodo failure summary. This both restores a | |
| // missing deploy-time seed and removes duplicate global rows from a rare race | |
| // between deploy/manual/cron seed invocations. Operational reads tolerate the | |
| // duplicates until this idempotent pass retains the oldest authority row. | |
| crons.daily( | |
| "dodo-webhook-failure-summary-seed", | |
| { hourUTC: 3, minuteUTC: 4 }, | |
| internal.payments.webhookMutations._seedFailureSummary, | |
| ); | |
| // Dunning + winback scan (#4932). Schedules the due day-3/day-7 payment- | |
| // failure reminders and the 30-day winback (at most one step per | |
| // subscription per tick; every send re-validates live state). 14:30 UTC = | |
| // ~10:30am ET, inside US business hours so a reply/complaint gets seen the | |
| // same day, and 90 minutes after the broadcast ramp runner (13:00) so the | |
| // two email systems never interleave sends. | |
| crons.daily( | |
| "billing-dunning-scan", | |
| { hourUTC: 14, minuteUTC: 30 }, | |
| internal.payments.subscriptionEmails.runDunningScan, | |
| {}, | |
| ); | |
| // Missed-renewal reconciliation (#4765): a renewal that succeeded at Dodo | |
| // but whose webhook was lost leaves the local sub with a lapsed period — | |
| // wrongly cutting off a paying customer. Daily sweep refreshes those from | |
| // Dodo's authoritative state and recomputes entitlements. | |
| crons.daily( | |
| "dodo-renewal-reconciliation", | |
| { hourUTC: 3, minuteUTC: 17 }, | |
| internal.payments.billing.reconcileMissedDodoRenewals, | |
| {}, | |
| ); | |
| // Business Pro seat grant reconciliation (#4634/#4635) — safety net for the | |
| // webhook-driven and scheduled grant-revocation paths in subscriptionHelpers.ts. | |
| // A lost webhook or a dropped scheduled function can leave a grant pointing | |
| // at a Business subscription that's no longer covering/no longer api_business; | |
| // this daily sweep independently re-derives every live grant's validity | |
| // rather than trusting a single revocation trigger to have fired. | |
| crons.daily( | |
| "business-pro-grants-reconciliation", | |
| { hourUTC: 3, minuteUTC: 20 }, | |
| internal.payments.subscriptionHelpers.reconcileBusinessProGrants, | |
| {}, | |
| ); | |
| export default crons; | |