File size: 7,383 Bytes
97ee7cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | 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;
|