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;