| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { v } from "convex/values"; |
| import { |
| internalAction, |
| internalMutation, |
| internalQuery, |
| } from "../_generated/server"; |
| import { internal } from "../_generated/api"; |
| import { |
| createSegment, |
| upsertContactToSegment, |
| } from "./_resendContacts"; |
| import { filterPageForEligibility } from "./_poolSelection"; |
|
|
| |
| |
| |
|
|
| |
| |
| const DEFAULT_BATCH_SIZE = 250; |
|
|
| |
| |
| |
| const PERSIST_CHUNK_SIZE = 500; |
|
|
| |
| const CLEANUP_CHUNK_SIZE = 500; |
|
|
| |
| |
| |
| const FAILURE_RATE_THRESHOLD = 0.05; |
|
|
| |
| |
| |
| const RESEND_BACKOFF_MS = [250, 500]; |
| const RESEND_BACKOFF_MAX_RETRIES = 3; |
|
|
| |
| |
| const REGISTRATIONS_PAGE_SIZE = 1000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const MIN_USABLE_POOL_SIZE = 100; |
|
|
| |
| |
| |
|
|
| function maskEmail(email: string): string { |
| const at = email.indexOf("@"); |
| if (at <= 0) return "***"; |
| const local = email.slice(0, at); |
| const domain = email.slice(at); |
| const visible = local.slice(0, Math.min(2, local.length)); |
| return `${visible}${"*".repeat(Math.max(1, local.length - visible.length))}${domain}`; |
| } |
|
|
| class Reservoir<T> { |
| private readonly size: number; |
| private readonly buf: T[] = []; |
| private seen = 0; |
| constructor(size: number) { this.size = size; } |
| offer(item: T): void { |
| this.seen++; |
| if (this.buf.length < this.size) { |
| this.buf.push(item); |
| } else { |
| const j = Math.floor(Math.random() * this.seen); |
| if (j < this.size) this.buf[j] = item; |
| } |
| } |
| values(): T[] { return this.buf; } |
| totalSeen(): number { return this.seen; } |
| } |
|
|
| |
| |
| |
| |
| |
| async function pushWithBackoff( |
| apiKey: string, |
| email: string, |
| segmentId: string, |
| ): Promise<Awaited<ReturnType<typeof upsertContactToSegment>>> { |
| let lastResult: Awaited<ReturnType<typeof upsertContactToSegment>> | undefined; |
| for (let attempt = 0; attempt < RESEND_BACKOFF_MAX_RETRIES; attempt++) { |
| const result = await upsertContactToSegment(apiKey, email, segmentId); |
| if (result.kind !== "failed") return result; |
| lastResult = result; |
| |
| |
| const transient = /\b(429|5\d\d)\b/.test(result.reason); |
| if (!transient || attempt === RESEND_BACKOFF_MAX_RETRIES - 1) { |
| return result; |
| } |
| |
| |
| |
| |
| |
| |
| const base = |
| RESEND_BACKOFF_MS[attempt] ?? |
| RESEND_BACKOFF_MS[RESEND_BACKOFF_MS.length - 1] ?? |
| 1000; |
| |
| const jitter = base * 0.2 * (Math.random() * 2 - 1); |
| const sleepMs = Math.max(0, base + jitter); |
| await new Promise((resolve) => setTimeout(resolve, sleepMs)); |
| } |
| return lastResult ?? { kind: "failed", reason: "[pushWithBackoff] exhausted with no result" }; |
| } |
|
|
| |
| |
| |
|
|
| export type WaveRunStatus = |
| | "picking" |
| | "segment-created" |
| | "pushing" |
| | "broadcast-created" |
| | "sent" |
| | "failed"; |
|
|
| export type WaveFailureSubstatus = |
| | "empty-pool" |
| | "segment-create-failed" |
| | "persist-failed" |
| | "batch-failure-rate-exceeded" |
| | "create-broadcast-failed" |
| | "send-broadcast-failed" |
| | "discarded-by-operator"; |
|
|
| export type ClaimLeaseResult = |
| | { ok: true; runId: string } |
| | { ok: false; reason: "lease-held" | "no-config" | "label-collides"; current?: string }; |
|
|
| |
| |
| |
| |
| |
|
|
| export const _hasWaveLabel = internalQuery({ |
| args: { waveLabel: v.string() }, |
| handler: async (ctx, { waveLabel }) => { |
| const existing = await ctx.db |
| .query("registrations") |
| .withIndex("by_proLaunchWave", (q) => q.eq("proLaunchWave", waveLabel)) |
| .first(); |
| return existing !== null; |
| }, |
| }); |
|
|
| export const _getSuppressedEmails = internalQuery({ |
| args: {}, |
| handler: async (ctx) => { |
| const all = await ctx.db.query("emailSuppressions").collect(); |
| return all |
| .map((row) => row.normalizedEmail) |
| .filter((e): e is string => typeof e === "string" && e.length > 0); |
| }, |
| }); |
|
|
| export const _getPaidEmails = internalQuery({ |
| args: {}, |
| handler: async (ctx) => { |
| const all = await ctx.db.query("customers").collect(); |
| return all |
| .map((row) => { |
| const stored = row.normalizedEmail; |
| if (stored && stored.length > 0) return stored; |
| return (row.email ?? "").trim().toLowerCase(); |
| }) |
| .filter((e): e is string => typeof e === "string" && e.length > 0); |
| }, |
| }); |
|
|
| export const _getRegistrationsPage = internalQuery({ |
| args: { |
| cursor: v.union(v.string(), v.null()), |
| numItems: v.number(), |
| }, |
| handler: async (ctx, { cursor, numItems }) => { |
| return await ctx.db |
| .query("registrations") |
| .paginate({ cursor, numItems }); |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _getUsersByEmailPage = internalQuery({ |
| args: { |
| emails: v.array(v.string()), |
| }, |
| handler: async (ctx, { emails }) => { |
| const validEmails = emails.filter((e) => e && e.length > 0); |
| if (validEmails.length === 0) return []; |
| const rows = await Promise.all( |
| validEmails.map((email) => |
| ctx.db |
| .query("users") |
| .withIndex("by_normalizedEmail", (q) => |
| q.eq("normalizedEmail", email), |
| ) |
| .first(), |
| ), |
| ); |
| const out: Array<{ normalizedEmail: string; localePrimary?: string }> = []; |
| for (const row of rows) { |
| if (row && row.normalizedEmail) { |
| out.push({ |
| normalizedEmail: row.normalizedEmail, |
| localePrimary: row.localePrimary, |
| }); |
| } |
| } |
| return out; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _dryRunNonEnglishExclusion = internalAction({ |
| args: { |
| sampleSize: v.optional(v.number()), |
| }, |
| handler: async ( |
| ctx, |
| args, |
| ): Promise<{ |
| eligibleTotal: number; |
| excludedTotal: number; |
| excludedByLocale: Record<string, number>; |
| sampleExcludedEmails: string[]; |
| }> => { |
| const sampleSize = |
| typeof args.sampleSize === "number" && args.sampleSize > 0 |
| ? Math.min(args.sampleSize, 200) |
| : 20; |
|
|
| const [suppressed, paid] = await Promise.all([ |
| ctx.runQuery(internal.broadcast.waveRuns._getSuppressedEmails, {}), |
| ctx.runQuery(internal.broadcast.waveRuns._getPaidEmails, {}), |
| ]); |
| const suppressedSet = new Set(suppressed); |
| const paidSet = new Set(paid); |
|
|
| let eligibleTotal = 0; |
| let excludedTotal = 0; |
| const excludedByLocale: Record<string, number> = {}; |
| const sampleExcludedEmails: string[] = []; |
|
|
| let cursor: string | null = null; |
| while (true) { |
| const page: { |
| page: Array<{ normalizedEmail: string; proLaunchWave?: string }>; |
| isDone: boolean; |
| continueCursor: string; |
| } = await ctx.runQuery( |
| internal.broadcast.waveRuns._getRegistrationsPage, |
| { cursor, numItems: REGISTRATIONS_PAGE_SIZE }, |
| ); |
|
|
| |
| |
| const candidates: string[] = []; |
| for (const row of page.page) { |
| const e = row.normalizedEmail; |
| if (!e || e.length === 0) continue; |
| if (suppressedSet.has(e)) continue; |
| if (paidSet.has(e)) continue; |
| if (row.proLaunchWave) continue; |
| candidates.push(e); |
| } |
| const dedup = Array.from(new Set(candidates)); |
| const usersByEmail: Map<string, { localePrimary?: string }> = new Map(); |
| if (dedup.length > 0) { |
| const userRows: Array<{ |
| normalizedEmail: string; |
| localePrimary?: string; |
| }> = await ctx.runQuery( |
| internal.broadcast.waveRuns._getUsersByEmailPage, |
| { emails: dedup }, |
| ); |
| for (const u of userRows) { |
| usersByEmail.set(u.normalizedEmail, { |
| localePrimary: u.localePrimary, |
| }); |
| } |
| } |
|
|
| const result = filterPageForEligibility({ |
| page: page.page, |
| suppressedSet, |
| paidSet, |
| usersByEmail, |
| excludeNonEnglish: true, |
| }); |
|
|
| eligibleTotal += result.pageEligibleCount; |
| excludedTotal += result.pageExcludedTotal; |
| for (const [locale, count] of Object.entries(result.pageExcludedByLocale)) { |
| excludedByLocale[locale] = (excludedByLocale[locale] ?? 0) + count; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| if (sampleExcludedEmails.length < sampleSize) { |
| const eligibleSet = new Set(result.eligible); |
| for (const row of page.page) { |
| if (sampleExcludedEmails.length >= sampleSize) break; |
| const e = row.normalizedEmail; |
| if (!e) continue; |
| if (suppressedSet.has(e) || paidSet.has(e) || row.proLaunchWave) continue; |
| |
| if (!eligibleSet.has(e)) { |
| sampleExcludedEmails.push(e); |
| } |
| } |
| } |
|
|
| if (page.isDone) break; |
| cursor = page.continueCursor; |
| } |
|
|
| return { eligibleTotal, excludedTotal, excludedByLocale, sampleExcludedEmails }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const _recordPoolFilterStats = internalMutation({ |
| args: { |
| runId: v.string(), |
| excludeNonEnglish: v.boolean(), |
| eligiblePoolCount: v.number(), |
| excludedCount: v.number(), |
| excludedLocaleCounts: v.record(v.string(), v.number()), |
| }, |
| handler: async (ctx, args) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", args.runId)) |
| .unique(); |
| if (!run) { |
| throw new Error(`[_recordPoolFilterStats] no run ${args.runId}`); |
| } |
| await ctx.db.patch(run._id, { |
| excludeNonEnglish: args.excludeNonEnglish, |
| eligiblePoolCount: args.eligiblePoolCount, |
| excludedCount: args.excludedCount, |
| excludedLocaleCounts: args.excludedLocaleCounts, |
| updatedAt: Date.now(), |
| }); |
| return { ok: true as const }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _claimWaveRunLease = internalMutation({ |
| args: { |
| waveLabel: v.string(), |
| runId: v.string(), |
| requestedCount: v.number(), |
| batchSize: v.number(), |
| }, |
| handler: async (ctx, args): Promise<ClaimLeaseResult> => { |
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (!config) return { ok: false, reason: "no-config" }; |
| if (config.pendingRunId) { |
| return { ok: false, reason: "lease-held", current: config.pendingRunId }; |
| } |
| |
| |
| |
| |
| for (const status of ACTIVE_STATUSES) { |
| const existing = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_status", (q) => q.eq("status", status)) |
| .first(); |
| if (existing) { |
| return { ok: false, reason: "lease-held", current: existing.runId }; |
| } |
| } |
| const collides = await ctx.db |
| .query("registrations") |
| .withIndex("by_proLaunchWave", (q) => q.eq("proLaunchWave", args.waveLabel)) |
| .first(); |
| if (collides) return { ok: false, reason: "label-collides" }; |
|
|
| const now = Date.now(); |
| await ctx.db.patch(config._id, { |
| pendingRunId: args.runId, |
| pendingRunStartedAt: now, |
| pendingWaveLabel: args.waveLabel, |
| }); |
| await ctx.db.insert("waveRuns", { |
| runId: args.runId, |
| waveLabel: args.waveLabel, |
| status: "picking", |
| requestedCount: args.requestedCount, |
| totalCount: 0, |
| underfilled: false, |
| pushedCount: 0, |
| failedCount: 0, |
| batchSize: args.batchSize, |
| createdAt: now, |
| updatedAt: now, |
| }); |
| return { ok: true, runId: args.runId }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| export const _persistPickedBatch = internalMutation({ |
| args: { |
| runId: v.string(), |
| contacts: v.array(v.string()), |
| }, |
| handler: async (ctx, { runId, contacts }) => { |
| if (contacts.length > PERSIST_CHUNK_SIZE) { |
| throw new Error( |
| `[_persistPickedBatch] chunk too large: ${contacts.length} > ${PERSIST_CHUNK_SIZE}`, |
| ); |
| } |
| const now = Date.now(); |
| for (const email of contacts) { |
| await ctx.db.insert("wavePickedContacts", { |
| runId, |
| normalizedEmail: email, |
| status: "pending", |
| }); |
| } |
| |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (run) await ctx.db.patch(run._id, { updatedAt: now }); |
| return { inserted: contacts.length }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| export const _markPickComplete = internalMutation({ |
| args: { |
| runId: v.string(), |
| segmentId: v.string(), |
| totalCount: v.number(), |
| underfilled: v.boolean(), |
| }, |
| handler: async (ctx, args) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", args.runId)) |
| .unique(); |
| if (!run) throw new Error(`[_markPickComplete] no run ${args.runId}`); |
| if (run.status !== "picking") { |
| throw new Error( |
| `[_markPickComplete] run ${args.runId} is ${run.status}, expected picking`, |
| ); |
| } |
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "segment-created", |
| segmentId: args.segmentId, |
| totalCount: args.totalCount, |
| underfilled: args.underfilled, |
| updatedAt: now, |
| }); |
| return { ok: true }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| export const _markPickFailed = internalMutation({ |
| args: { |
| runId: v.string(), |
| substatus: v.union( |
| v.literal("empty-pool"), |
| v.literal("pool-too-small"), |
| v.literal("segment-create-failed"), |
| v.literal("persist-failed"), |
| ), |
| error: v.string(), |
| }, |
| handler: async (ctx, { runId, substatus, error }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) return { ok: false as const, reason: "no-run" as const }; |
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "failed", |
| failureSubstatus: substatus, |
| error: error.slice(0, 500), |
| updatedAt: now, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if (substatus === "empty-pool" || substatus === "pool-too-small") { |
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (config && config.pendingRunId === runId) { |
| await ctx.db.patch(config._id, { |
| pendingRunId: undefined, |
| pendingRunStartedAt: undefined, |
| pendingWaveLabel: undefined, |
| active: false, |
| lastRunStatus: |
| substatus === "empty-pool" |
| ? "ramp-complete-empty-pool" |
| : "ramp-complete-pool-too-small", |
| lastRunAt: now, |
| }); |
| } |
| } |
| return { ok: true as const }; |
| }, |
| }); |
|
|
| export const pickWaveAction = internalAction({ |
| args: { |
| waveLabel: v.string(), |
| runId: v.string(), |
| requestedCount: v.number(), |
| batchSize: v.optional(v.number()), |
| |
| |
| |
| |
| |
| excludeNonEnglish: v.optional(v.boolean()), |
| }, |
| handler: async (ctx, args): Promise<{ ok: boolean; reason?: string }> => { |
| const apiKey = process.env.RESEND_API_KEY; |
| if (!apiKey) { |
| throw new Error("[pickWaveAction] RESEND_API_KEY not set"); |
| } |
| if (!Number.isFinite(args.requestedCount) || args.requestedCount <= 0) { |
| throw new Error( |
| `[pickWaveAction] requestedCount must be a positive integer; got ${args.requestedCount}`, |
| ); |
| } |
| if (args.waveLabel.length === 0 || args.waveLabel.length > 64) { |
| throw new Error("[pickWaveAction] waveLabel must be 1-64 chars"); |
| } |
| const batchSize = args.batchSize ?? DEFAULT_BATCH_SIZE; |
| const excludeNonEnglish = args.excludeNonEnglish === true; |
|
|
| |
| const claim: ClaimLeaseResult = await ctx.runMutation( |
| internal.broadcast.waveRuns._claimWaveRunLease, |
| { |
| waveLabel: args.waveLabel, |
| runId: args.runId, |
| requestedCount: args.requestedCount, |
| batchSize, |
| }, |
| ); |
| if (!claim.ok) { |
| throw new Error( |
| `[pickWaveAction] could not claim lease: ${claim.reason}` + |
| (claim.current ? ` (current: ${claim.current})` : ""), |
| ); |
| } |
|
|
| try { |
| |
| const [suppressed, paid] = await Promise.all([ |
| ctx.runQuery(internal.broadcast.waveRuns._getSuppressedEmails, {}), |
| ctx.runQuery(internal.broadcast.waveRuns._getPaidEmails, {}), |
| ]); |
| const suppressedSet = new Set(suppressed); |
| const paidSet = new Set(paid); |
|
|
| const reservoir = new Reservoir<string>(args.requestedCount); |
| |
| |
| |
| let eligiblePoolCount = 0; |
| let excludedCount = 0; |
| const excludedLocaleCounts: Record<string, number> = {}; |
|
|
| let cursor: string | null = null; |
| while (true) { |
| const page: { |
| page: Array<{ normalizedEmail: string; proLaunchWave?: string }>; |
| isDone: boolean; |
| continueCursor: string; |
| } = await ctx.runQuery( |
| internal.broadcast.waveRuns._getRegistrationsPage, |
| { cursor, numItems: REGISTRATIONS_PAGE_SIZE }, |
| ); |
|
|
| |
| |
| |
| |
| let usersByEmail: Map<string, { localePrimary?: string }> = new Map(); |
| if (excludeNonEnglish) { |
| const candidates: string[] = []; |
| for (const row of page.page) { |
| const e = row.normalizedEmail; |
| if (!e || e.length === 0) continue; |
| if (suppressedSet.has(e)) continue; |
| if (paidSet.has(e)) continue; |
| if (row.proLaunchWave) continue; |
| candidates.push(e); |
| } |
| const dedup = Array.from(new Set(candidates)); |
| if (dedup.length > 0) { |
| const userRows: Array<{ |
| normalizedEmail: string; |
| localePrimary?: string; |
| }> = await ctx.runQuery( |
| internal.broadcast.waveRuns._getUsersByEmailPage, |
| { emails: dedup }, |
| ); |
| for (const u of userRows) { |
| usersByEmail.set(u.normalizedEmail, { |
| localePrimary: u.localePrimary, |
| }); |
| } |
| } |
| } |
|
|
| const result = filterPageForEligibility({ |
| page: page.page, |
| suppressedSet, |
| paidSet, |
| usersByEmail, |
| excludeNonEnglish, |
| }); |
|
|
| for (const email of result.eligible) reservoir.offer(email); |
| eligiblePoolCount += result.pageEligibleCount; |
| excludedCount += result.pageExcludedTotal; |
| for (const [locale, count] of Object.entries(result.pageExcludedByLocale)) { |
| excludedLocaleCounts[locale] = (excludedLocaleCounts[locale] ?? 0) + count; |
| } |
|
|
| if (page.isDone) break; |
| cursor = page.continueCursor; |
| } |
|
|
| |
| |
| await ctx.runMutation( |
| internal.broadcast.waveRuns._recordPoolFilterStats, |
| { |
| runId: args.runId, |
| excludeNonEnglish, |
| eligiblePoolCount, |
| excludedCount, |
| excludedLocaleCounts, |
| }, |
| ); |
|
|
| const picked = reservoir.values(); |
|
|
| |
| if (picked.length === 0) { |
| await ctx.runMutation(internal.broadcast.waveRuns._markPickFailed, { |
| runId: args.runId, |
| substatus: "empty-pool", |
| error: "no unstamped registrations", |
| }); |
| return { ok: false, reason: "empty-pool" }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if (picked.length < MIN_USABLE_POOL_SIZE) { |
| await ctx.runMutation(internal.broadcast.waveRuns._markPickFailed, { |
| runId: args.runId, |
| substatus: "pool-too-small", |
| error: |
| `picked ${picked.length} contacts (< MIN_USABLE_POOL_SIZE=${MIN_USABLE_POOL_SIZE}); ` + |
| `ramp deactivated to avoid stranding the next cron tick on awaiting-prior-stats. ` + |
| `Operator: extend rampCurve + resumeRamp if more sends desired, or run a final wave manually.`, |
| }); |
| return { ok: false, reason: "pool-too-small" }; |
| } |
|
|
| |
| const segmentName = `pro-launch-${args.waveLabel}`; |
| let segmentId: string; |
| try { |
| segmentId = await createSegment(apiKey, segmentName); |
| } catch (err) { |
| await ctx.runMutation(internal.broadcast.waveRuns._markPickFailed, { |
| runId: args.runId, |
| substatus: "segment-create-failed", |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| throw err; |
| } |
|
|
| |
| |
| try { |
| for (let i = 0; i < picked.length; i += PERSIST_CHUNK_SIZE) { |
| const chunk = picked.slice(i, i + PERSIST_CHUNK_SIZE); |
| await ctx.runMutation(internal.broadcast.waveRuns._persistPickedBatch, { |
| runId: args.runId, |
| contacts: chunk, |
| }); |
| } |
| } catch (err) { |
| await ctx.runMutation(internal.broadcast.waveRuns._markPickFailed, { |
| runId: args.runId, |
| substatus: "persist-failed", |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| throw err; |
| } |
|
|
| |
| await ctx.runMutation(internal.broadcast.waveRuns._markPickComplete, { |
| runId: args.runId, |
| segmentId, |
| totalCount: picked.length, |
| underfilled: picked.length < args.requestedCount, |
| }); |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.pushBatchAction, |
| { runId: args.runId, batchN: 0 }, |
| ); |
|
|
| console.log( |
| `[pickWaveAction] complete: runId=${args.runId} waveLabel=${args.waveLabel} ` + |
| `picked=${picked.length} requested=${args.requestedCount} underfilled=${picked.length < args.requestedCount}`, |
| ); |
| return { ok: true }; |
| } catch (err) { |
| |
| |
| |
| console.error( |
| `[pickWaveAction] runId=${args.runId} unexpected error: ${err instanceof Error ? err.message : String(err)}`, |
| ); |
| throw err; |
| } |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export const _resumeBatchInfo = internalQuery({ |
| args: { runId: v.string() }, |
| handler: async (ctx, { runId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) return null; |
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| const pending = await ctx.db |
| .query("wavePickedContacts") |
| .withIndex("by_runId_status", (q) => q.eq("runId", runId).eq("status", "pending")) |
| .take(1); |
| return { |
| run: { |
| runId: run.runId, |
| waveLabel: run.waveLabel, |
| status: run.status, |
| segmentId: run.segmentId, |
| totalCount: run.totalCount, |
| pushedCount: run.pushedCount, |
| failedCount: run.failedCount, |
| batchSize: run.batchSize, |
| broadcastId: run.broadcastId, |
| }, |
| configHoldsLease: config?.pendingRunId === runId, |
| hasPending: pending.length > 0, |
| }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| export const _getPendingBatch = internalQuery({ |
| args: { |
| runId: v.string(), |
| limit: v.number(), |
| }, |
| handler: async (ctx, { runId, limit }) => { |
| return await ctx.db |
| .query("wavePickedContacts") |
| .withIndex("by_runId_status", (q) => |
| q.eq("runId", runId).eq("status", "pending"), |
| ) |
| .take(limit); |
| }, |
| }); |
|
|
| export const _markPushingStarted = internalMutation({ |
| args: { runId: v.string() }, |
| handler: async (ctx, { runId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) return { ok: false as const, reason: "no-run" as const }; |
| if (run.status === "pushing") return { ok: true as const, alreadyPushing: true as const }; |
| if (run.status !== "segment-created") { |
| return { ok: false as const, reason: `wrong-status-${run.status}` as const }; |
| } |
| const now = Date.now(); |
| await ctx.db.patch(run._id, { status: "pushing", lastBatchAt: now, updatedAt: now }); |
| return { ok: true as const, alreadyPushing: false as const }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _markContactPushed = internalMutation({ |
| args: { |
| contactId: v.id("wavePickedContacts"), |
| runId: v.string(), |
| normalizedEmail: v.string(), |
| waveLabel: v.string(), |
| }, |
| handler: async (ctx, { contactId, runId, normalizedEmail, waveLabel }) => { |
| const contact = await ctx.db.get(contactId); |
| if ( |
| !contact || |
| contact.runId !== runId || |
| contact.status !== "pending" || |
| contact.normalizedEmail !== normalizedEmail |
| ) { |
| |
| return { ok: false as const, reason: "not-pending" as const }; |
| } |
| const now = Date.now(); |
| await ctx.db.patch(contact._id, { status: "pushed", pushedAt: now }); |
|
|
| |
| |
| const reg = await ctx.db |
| .query("registrations") |
| .withIndex("by_normalized_email", (q) => |
| q.eq("normalizedEmail", normalizedEmail), |
| ) |
| .first(); |
| let stampResult: "stamped" | "alreadyStamped" | "notFound"; |
| if (!reg) { |
| stampResult = "notFound"; |
| } else if (reg.proLaunchWave === waveLabel) { |
| stampResult = "alreadyStamped"; |
| } else { |
| await ctx.db.patch(reg._id, { |
| proLaunchWave: waveLabel, |
| proLaunchWaveAssignedAt: now, |
| }); |
| stampResult = "stamped"; |
| } |
|
|
| |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (run) { |
| await ctx.db.patch(run._id, { |
| pushedCount: run.pushedCount + 1, |
| lastBatchAt: now, |
| updatedAt: now, |
| }); |
| } |
| return { ok: true as const, stampResult }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _markContactFailed = internalMutation({ |
| args: { |
| contactId: v.id("wavePickedContacts"), |
| runId: v.string(), |
| normalizedEmail: v.string(), |
| failedReason: v.string(), |
| }, |
| handler: async (ctx, { contactId, runId, normalizedEmail, failedReason }) => { |
| const contact = await ctx.db.get(contactId); |
| if ( |
| !contact || |
| contact.runId !== runId || |
| contact.status !== "pending" || |
| contact.normalizedEmail !== normalizedEmail |
| ) { |
| return { ok: false as const, reason: "not-pending" as const }; |
| } |
| const now = Date.now(); |
| await ctx.db.patch(contact._id, { |
| status: "failed", |
| failedAt: now, |
| failedReason: failedReason.slice(0, 500), |
| }); |
|
|
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) return { ok: true as const, runFailed: false as const }; |
|
|
| const newFailedCount = run.failedCount + 1; |
| const failureRate = run.totalCount > 0 ? newFailedCount / run.totalCount : 0; |
| const exceeded = failureRate > FAILURE_RATE_THRESHOLD; |
| await ctx.db.patch(run._id, { |
| failedCount: newFailedCount, |
| lastBatchAt: now, |
| updatedAt: now, |
| ...(exceeded |
| ? { |
| status: "failed" as const, |
| failureSubstatus: "batch-failure-rate-exceeded", |
| error: `failure rate ${(failureRate * 100).toFixed(2)}% exceeds ${(FAILURE_RATE_THRESHOLD * 100).toFixed(0)}% threshold`, |
| } |
| : {}), |
| }); |
| return { ok: true as const, runFailed: exceeded }; |
| }, |
| }); |
|
|
| export const pushBatchAction = internalAction({ |
| args: { |
| runId: v.string(), |
| batchN: v.number(), |
| }, |
| handler: async ( |
| ctx, |
| { runId, batchN }, |
| ): Promise<{ ok: boolean; reason?: string }> => { |
| const apiKey = process.env.RESEND_API_KEY; |
| if (!apiKey) throw new Error("[pushBatchAction] RESEND_API_KEY not set"); |
|
|
| |
| const info = await ctx.runQuery( |
| internal.broadcast.waveRuns._resumeBatchInfo, |
| { runId }, |
| ); |
| if (!info) { |
| console.warn(`[pushBatchAction] runId=${runId} not found; exiting`); |
| return { ok: false, reason: "no-run" }; |
| } |
| if (!info.configHoldsLease) { |
| console.warn(`[pushBatchAction] runId=${runId} lost lease; exiting`); |
| return { ok: false, reason: "lost-lease" }; |
| } |
| const allowedStatuses: WaveRunStatus[] = ["segment-created", "pushing"]; |
| if (!allowedStatuses.includes(info.run.status)) { |
| console.warn( |
| `[pushBatchAction] runId=${runId} status=${info.run.status} not pushable; exiting`, |
| ); |
| return { ok: false, reason: `wrong-status-${info.run.status}` }; |
| } |
| if (!info.run.segmentId) { |
| throw new Error(`[pushBatchAction] runId=${runId} has no segmentId`); |
| } |
|
|
| |
| if (info.run.status === "segment-created") { |
| await ctx.runMutation( |
| internal.broadcast.waveRuns._markPushingStarted, |
| { runId }, |
| ); |
| } |
|
|
| |
| const batch = await ctx.runQuery( |
| internal.broadcast.waveRuns._getPendingBatch, |
| { runId, limit: info.run.batchSize }, |
| ); |
| if (batch.length === 0) { |
| |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.finalizeWaveAction, |
| { runId }, |
| ); |
| return { ok: true, reason: "no-pending-finalize-scheduled" }; |
| } |
|
|
| |
| |
| let runFailed = false; |
| for (const contact of batch) { |
| const result = await pushWithBackoff(apiKey, contact.normalizedEmail, info.run.segmentId); |
| if (result.kind === "failed") { |
| const failResult = await ctx.runMutation( |
| internal.broadcast.waveRuns._markContactFailed, |
| { |
| contactId: contact._id, |
| runId, |
| normalizedEmail: contact.normalizedEmail, |
| failedReason: result.reason, |
| }, |
| ); |
| if (failResult.ok && failResult.runFailed) { |
| runFailed = true; |
| console.error( |
| `[pushBatchAction] runId=${runId} batch=${batchN} failure-rate threshold tripped`, |
| ); |
| break; |
| } |
| console.error( |
| `[pushBatchAction] push failed for ${maskEmail(contact.normalizedEmail)}: ${result.reason}`, |
| ); |
| continue; |
| } |
| |
| await ctx.runMutation( |
| internal.broadcast.waveRuns._markContactPushed, |
| { |
| contactId: contact._id, |
| runId, |
| normalizedEmail: contact.normalizedEmail, |
| waveLabel: info.run.waveLabel, |
| }, |
| ); |
| } |
|
|
| if (runFailed) return { ok: false, reason: "batch-failure-rate-exceeded" }; |
|
|
| |
| const after = await ctx.runQuery( |
| internal.broadcast.waveRuns._resumeBatchInfo, |
| { runId }, |
| ); |
| if (!after || after.run.status === "failed") { |
| return { ok: false, reason: `terminal-status-${after?.run.status ?? "<missing>"}` }; |
| } |
| if (after.hasPending) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.pushBatchAction, |
| { runId, batchN: batchN + 1 }, |
| ); |
| return { ok: true, reason: "next-batch-scheduled" }; |
| } |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.finalizeWaveAction, |
| { runId }, |
| ); |
| return { ok: true, reason: "finalize-scheduled" }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _markBroadcastCreated = internalMutation({ |
| args: { |
| runId: v.string(), |
| broadcastId: v.string(), |
| }, |
| handler: async (ctx, { runId, broadcastId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[_markBroadcastCreated] no run ${runId}`); |
|
|
| |
| |
| |
| |
| if (run.status === "broadcast-created") { |
| if (run.broadcastId === broadcastId) { |
| return { ok: true as const, alreadyMarked: true as const }; |
| } |
| return { |
| ok: false as const, |
| reason: "duplicate-broadcast-detected" as const, |
| existing: run.broadcastId, |
| }; |
| } |
| if (run.status !== "pushing") { |
| return { ok: false as const, reason: `wrong-status-${run.status}` as const }; |
| } |
|
|
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (!config || config.pendingRunId !== runId) { |
| return { ok: false as const, reason: "lost-lease" as const }; |
| } |
|
|
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "broadcast-created", |
| broadcastId, |
| lastBatchAt: now, |
| updatedAt: now, |
| }); |
| return { ok: true as const, alreadyMarked: false as const }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const _markFinalizeFailed = internalMutation({ |
| args: { |
| runId: v.string(), |
| substatus: v.union( |
| v.literal("create-broadcast-failed"), |
| v.literal("send-broadcast-failed"), |
| ), |
| error: v.string(), |
| }, |
| handler: async (ctx, { runId, substatus, error }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[_markFinalizeFailed] no run ${runId}`); |
|
|
| |
| |
| |
| |
| if (run.status === "sent") { |
| return { ok: false as const, reason: "already-sent" as const }; |
| } |
| |
| |
| |
| if ( |
| run.status === "failed" && |
| run.failureSubstatus !== undefined && |
| run.failureSubstatus !== substatus |
| ) { |
| return { |
| ok: false as const, |
| reason: "already-failed-different-substatus" as const, |
| existing: run.failureSubstatus, |
| }; |
| } |
|
|
| const now = Date.now(); |
| |
| |
| |
| |
| |
| const statusPatch = |
| substatus === "create-broadcast-failed" |
| ? { status: "failed" as const } |
| : {}; |
| await ctx.db.patch(run._id, { |
| ...statusPatch, |
| failureSubstatus: substatus, |
| error: error.slice(0, 500), |
| updatedAt: now, |
| }); |
| return { ok: true as const }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _finalizeWaveRun = internalMutation({ |
| args: { |
| runId: v.string(), |
| sentAt: v.number(), |
| }, |
| handler: async (ctx, { runId, sentAt }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[_finalizeWaveRun] no run ${runId}`); |
|
|
| |
| |
| |
| if (run.status === "sent") { |
| |
| |
| return { ok: true as const, alreadySent: true as const, advancedToTier: undefined }; |
| } |
| if (run.status !== "broadcast-created") { |
| throw new Error( |
| `[_finalizeWaveRun] run ${runId} is ${run.status}, expected broadcast-created`, |
| ); |
| } |
| if (!run.broadcastId || !run.segmentId) { |
| throw new Error( |
| `[_finalizeWaveRun] run ${runId} missing broadcastId/segmentId`, |
| ); |
| } |
|
|
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (!config) throw new Error("[_finalizeWaveRun] no broadcastRampConfig"); |
| if (config.pendingRunId !== runId) { |
| throw new Error( |
| `[_finalizeWaveRun] lost lease: expected ${runId}, found ${config.pendingRunId ?? "<cleared>"}. ` + |
| `Refusing to advance tier β operator force-released the lease, or another run took over.`, |
| ); |
| } |
|
|
| const now = Date.now(); |
| const nextTier = config.currentTier + 1; |
|
|
| await ctx.db.patch(config._id, { |
| currentTier: nextTier, |
| lastWaveLabel: run.waveLabel, |
| lastWaveBroadcastId: run.broadcastId, |
| lastWaveSegmentId: run.segmentId, |
| lastWaveAssigned: run.pushedCount, |
| lastWaveSentAt: sentAt, |
| lastRunStatus: "succeeded", |
| lastRunAt: now, |
| lastRunError: undefined, |
| pendingRunId: undefined, |
| pendingRunStartedAt: undefined, |
| pendingWaveLabel: undefined, |
| pendingSegmentId: undefined, |
| pendingAssigned: undefined, |
| pendingExportAt: undefined, |
| pendingBroadcastId: undefined, |
| pendingBroadcastAt: undefined, |
| }); |
| await ctx.db.patch(run._id, { |
| status: "sent", |
| updatedAt: now, |
| }); |
| return { ok: true as const, advancedToTier: nextTier }; |
| }, |
| }); |
|
|
| export const finalizeWaveAction = internalAction({ |
| args: { runId: v.string() }, |
| handler: async ( |
| ctx, |
| { runId }, |
| ): Promise<{ ok: boolean; reason?: string }> => { |
| const info = await ctx.runQuery( |
| internal.broadcast.waveRuns._resumeBatchInfo, |
| { runId }, |
| ); |
| if (!info) return { ok: false, reason: "no-run" }; |
| if (!info.configHoldsLease) return { ok: false, reason: "lost-lease" }; |
| if (!info.run.segmentId) { |
| throw new Error(`[finalizeWaveAction] runId=${runId} missing segmentId`); |
| } |
|
|
| |
| |
| |
| if (info.run.status === "pushing" || info.run.status === "segment-created") { |
| let createResult: { broadcastId: string; segmentId: string; subject: string; name: string }; |
| try { |
| createResult = await ctx.runAction( |
| internal.broadcast.sendBroadcast.createProLaunchBroadcast, |
| { |
| segmentId: info.run.segmentId, |
| nameSuffix: info.run.waveLabel, |
| }, |
| ); |
| } catch (err) { |
| await ctx.runMutation( |
| internal.broadcast.waveRuns._markFinalizeFailed, |
| { |
| runId, |
| substatus: "create-broadcast-failed", |
| error: err instanceof Error ? err.message : String(err), |
| }, |
| ); |
| throw err; |
| } |
| |
| |
| |
| |
| |
| const markResult = await ctx.runMutation( |
| internal.broadcast.waveRuns._markBroadcastCreated, |
| { runId, broadcastId: createResult.broadcastId }, |
| ); |
| if (!markResult.ok) { |
| console.error( |
| `[finalizeWaveAction] CAS lost on _markBroadcastCreated runId=${runId} reason=${markResult.reason} ` + |
| `our-broadcastId=${createResult.broadcastId}. The Resend broadcast we created is orphaned β ` + |
| `operator should delete it via Resend dashboard if not the same as the winning runner's broadcastId.`, |
| ); |
| return { ok: false, reason: `markBroadcastCreated-${markResult.reason}` }; |
| } |
| } else if (info.run.status !== "broadcast-created") { |
| return { ok: false, reason: `wrong-status-${info.run.status}` }; |
| } |
|
|
| |
| const after = await ctx.runQuery( |
| internal.broadcast.waveRuns._resumeBatchInfo, |
| { runId }, |
| ); |
| if (!after?.run.broadcastId) { |
| throw new Error(`[finalizeWaveAction] runId=${runId} missing broadcastId post-create`); |
| } |
| |
| |
| |
| |
| |
| |
| |
| if (after.run.status === "sent") { |
| |
| console.log(`[finalizeWaveAction] runId=${runId} already sent by another invocation β exiting clean`); |
| return { ok: true, reason: "already-sent" }; |
| } |
| if (!after.configHoldsLease) { |
| console.warn(`[finalizeWaveAction] runId=${runId} lost lease before send β exiting`); |
| return { ok: false, reason: "lost-lease-pre-send" }; |
| } |
| try { |
| await ctx.runAction( |
| internal.broadcast.sendBroadcast.sendProLaunchBroadcast, |
| { broadcastId: after.run.broadcastId }, |
| ); |
| } catch (err) { |
| const failResult = await ctx.runMutation( |
| internal.broadcast.waveRuns._markFinalizeFailed, |
| { |
| runId, |
| substatus: "send-broadcast-failed", |
| error: err instanceof Error ? err.message : String(err), |
| }, |
| ); |
| |
| |
| |
| if (!failResult.ok && failResult.reason === "already-sent") { |
| console.log( |
| `[finalizeWaveAction] runId=${runId} send returned error but run already sent ` + |
| `by another invocation β treating as duplicate-finalize loser (clean exit)`, |
| ); |
| return { ok: true, reason: "already-sent-duplicate-loser" }; |
| } |
| throw err; |
| } |
|
|
| |
| |
| |
| const fin = await ctx.runMutation(internal.broadcast.waveRuns._finalizeWaveRun, { |
| runId, |
| sentAt: Date.now(), |
| }); |
| if ("alreadySent" in fin && fin.alreadySent) { |
| return { ok: true, reason: "already-sent" }; |
| } |
| return { ok: true }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| export const markFinalizeRecovered = internalMutation({ |
| args: { |
| runId: v.string(), |
| sentAt: v.number(), |
| reason: v.string(), |
| }, |
| handler: async (ctx, { runId, sentAt, reason }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[markFinalizeRecovered] no run ${runId}`); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (run.status !== "broadcast-created") { |
| throw new Error( |
| `[markFinalizeRecovered] run ${runId} status=${run.status} β recovery requires status='broadcast-created'. ` + |
| (run.status === "sent" |
| ? `The run was already finalized; nothing to recover. Inspect lastWaveSentAt on broadcastRampConfig.` |
| : `If in 'failed', use resumeFinalizeWaveRun (which patches back to broadcast-created) or discardWaveRun.`), |
| ); |
| } |
| if (run.failureSubstatus !== "send-broadcast-failed") { |
| throw new Error( |
| `[markFinalizeRecovered] run ${runId} status='broadcast-created' but failureSubstatus=` + |
| `${run.failureSubstatus ?? "<none>"}. markFinalizeRecovered only applies to send-broadcast-failed. ` + |
| `If the run is mid-flight (no substatus), wait for finalizeWaveAction to finish β _finalizeWaveRun is ` + |
| `idempotent on already-sent. If you ran resumeFinalizeWaveRun and want to abort the retry instead, ` + |
| `wait for the scheduled finalizeWaveAction to either succeed or re-fail; only then is markFinalizeRecovered safe.`, |
| ); |
| } |
| if (!run.broadcastId || !run.segmentId) { |
| throw new Error(`[markFinalizeRecovered] run ${runId} missing broadcastId/segmentId`); |
| } |
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (!config) throw new Error("[markFinalizeRecovered] no broadcastRampConfig"); |
| |
| |
| |
| if (config.pendingRunId !== runId) { |
| throw new Error( |
| `[markFinalizeRecovered] runId=${runId} lost lease (held by ${config.pendingRunId ?? "<cleared>"}). ` + |
| `Investigate: another run may have advanced the tier OR forceReleaseLease was used. ` + |
| `Refusing to advance the tier from a stale runId.`, |
| ); |
| } |
|
|
| const now = Date.now(); |
| const nextTier = config.currentTier + 1; |
| await ctx.db.patch(config._id, { |
| currentTier: nextTier, |
| lastWaveLabel: run.waveLabel, |
| lastWaveBroadcastId: run.broadcastId, |
| lastWaveSegmentId: run.segmentId, |
| lastWaveAssigned: run.pushedCount, |
| lastWaveSentAt: sentAt, |
| lastRunStatus: `succeeded-via-finalize-recovered: ${reason.slice(0, 200)}`, |
| lastRunAt: now, |
| lastRunError: undefined, |
| pendingRunId: undefined, |
| pendingRunStartedAt: undefined, |
| pendingWaveLabel: undefined, |
| pendingSegmentId: undefined, |
| pendingAssigned: undefined, |
| pendingExportAt: undefined, |
| pendingBroadcastId: undefined, |
| pendingBroadcastAt: undefined, |
| }); |
| await ctx.db.patch(run._id, { |
| status: "sent", |
| updatedAt: now, |
| error: undefined, |
| failureSubstatus: undefined, |
| }); |
| return { ok: true as const, advancedToTier: nextTier }; |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const discardWaveRun = internalMutation({ |
| args: { |
| runId: v.string(), |
| reason: v.string(), |
| }, |
| handler: async (ctx, { runId, reason }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[discardWaveRun] no run ${runId}`); |
| const config = await ctx.db |
| .query("broadcastRampConfig") |
| .withIndex("by_key", (q) => q.eq("key", "current")) |
| .unique(); |
| if (!config) throw new Error("[discardWaveRun] no broadcastRampConfig"); |
|
|
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "failed", |
| failureSubstatus: "discarded-by-operator", |
| error: reason.slice(0, 500), |
| updatedAt: now, |
| }); |
| await ctx.db.patch(config._id, { |
| waveLabelOffset: config.waveLabelOffset + 1, |
| lastRunStatus: `discarded-by-operator: ${reason.slice(0, 200)}`, |
| lastRunAt: now, |
| pendingRunId: undefined, |
| pendingRunStartedAt: undefined, |
| pendingWaveLabel: undefined, |
| pendingSegmentId: undefined, |
| pendingAssigned: undefined, |
| pendingExportAt: undefined, |
| pendingBroadcastId: undefined, |
| pendingBroadcastAt: undefined, |
| }); |
| |
| |
| |
| |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.cleanupDiscardedWavePickedContactsAction, |
| { runId }, |
| ); |
| return { |
| ok: true as const, |
| newWaveLabelOffset: config.waveLabelOffset + 1, |
| }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| export const resumeStalledWaveRun = internalMutation({ |
| args: { runId: v.string() }, |
| handler: async (ctx, { runId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[resumeStalledWaveRun] no run ${runId}`); |
| if (run.status === "broadcast-created") { |
| throw new Error( |
| `[resumeStalledWaveRun] runId=${runId} is in broadcast-created β use resumeFinalizeWaveRun({confirmedNotSent: true}) after Resend-dashboard verification, OR markFinalizeRecovered if the broadcast was actually sent.`, |
| ); |
| } |
| if (run.status === "failed") { |
| throw new Error( |
| `[resumeStalledWaveRun] runId=${runId} is in failed (substatus=${run.failureSubstatus ?? "<none>"}) β use resumeFinalizeWaveRun (for create/send substatuses) or discardWaveRun (for batch-failure-rate-exceeded / pick-phase substatuses).`, |
| ); |
| } |
| if (run.status === "sent") { |
| throw new Error(`[resumeStalledWaveRun] runId=${runId} is already sent`); |
| } |
|
|
| const now = Date.now(); |
| await ctx.db.patch(run._id, { lastBatchAt: now, updatedAt: now }); |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.pushBatchAction, |
| { runId, batchN: 0 }, |
| ); |
| return { ok: true as const, scheduled: "pushBatchAction" as const }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| export const resumeFinalizeWaveRun = internalMutation({ |
| args: { |
| runId: v.string(), |
| confirmedNotSent: v.optional(v.boolean()), |
| }, |
| handler: async (ctx, { runId, confirmedNotSent }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) throw new Error(`[resumeFinalizeWaveRun] no run ${runId}`); |
|
|
| const isSendFailureCase = |
| run.status === "broadcast-created" || |
| run.failureSubstatus === "send-broadcast-failed"; |
| const isCreateFailureCase = |
| run.status === "failed" && |
| run.failureSubstatus === "create-broadcast-failed"; |
|
|
| if (isSendFailureCase) { |
| if (confirmedNotSent !== true) { |
| throw new Error( |
| `[resumeFinalizeWaveRun] runId=${runId} is in send-failure state. ` + |
| `BEFORE retrying, verify in the Resend dashboard whether the broadcast for ` + |
| `broadcastId=${run.broadcastId ?? "<unknown>"} was actually queued or sent ` + |
| `(Resend may accept a send despite the action seeing a network/timeout error). ` + |
| `If confirmed NOT sent, re-run with {confirmedNotSent: true}. ` + |
| `If Resend shows the broadcast as already sent, use markFinalizeRecovered({runId, sentAt}) instead.`, |
| ); |
| } |
| |
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "broadcast-created", |
| failureSubstatus: undefined, |
| error: undefined, |
| lastBatchAt: now, |
| updatedAt: now, |
| }); |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.finalizeWaveAction, |
| { runId }, |
| ); |
| return { ok: true as const, scheduled: "finalizeWaveAction-send-only" as const }; |
| } |
|
|
| if (isCreateFailureCase) { |
| |
| |
| |
| const now = Date.now(); |
| await ctx.db.patch(run._id, { |
| status: "pushing", |
| failureSubstatus: undefined, |
| error: undefined, |
| lastBatchAt: now, |
| updatedAt: now, |
| }); |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.finalizeWaveAction, |
| { runId }, |
| ); |
| return { ok: true as const, scheduled: "finalizeWaveAction-create-and-send" as const }; |
| } |
|
|
| throw new Error( |
| `[resumeFinalizeWaveRun] runId=${runId} is in status=${run.status} substatus=${run.failureSubstatus ?? "<none>"} β ` + |
| `not a finalize-phase failure. Use resumeStalledWaveRun (for pushing/segment-created) or discardWaveRun (for batch-failure / pick-phase failures).`, |
| ); |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const _cleanupDiscardedWavePickedContacts = internalMutation({ |
| args: { runId: v.string() }, |
| handler: async (ctx, { runId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| const waveLabel = run?.waveLabel; |
|
|
| const rows = await ctx.db |
| .query("wavePickedContacts") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .take(CLEANUP_CHUNK_SIZE); |
| let unstamped = 0; |
| for (const row of rows) { |
| if (row.status === "pushed" && waveLabel) { |
| const reg = await ctx.db |
| .query("registrations") |
| .withIndex("by_normalized_email", (q) => |
| q.eq("normalizedEmail", row.normalizedEmail), |
| ) |
| .first(); |
| |
| |
| |
| if (reg && reg.proLaunchWave === waveLabel) { |
| await ctx.db.patch(reg._id, { |
| proLaunchWave: undefined, |
| proLaunchWaveAssignedAt: undefined, |
| }); |
| unstamped++; |
| } |
| } |
| await ctx.db.delete(row._id); |
| } |
| return { |
| deleted: rows.length, |
| unstamped, |
| hasMore: rows.length === CLEANUP_CHUNK_SIZE, |
| }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const cleanupDiscardedWavePickedContactsAction = internalAction({ |
| args: { |
| runId: v.optional(v.string()), |
| }, |
| handler: async (ctx, args): Promise<{ |
| deleted: number; |
| unstamped: number; |
| hasMore: boolean; |
| }> => { |
| if (args.runId) { |
| const result = await ctx.runMutation( |
| internal.broadcast.waveRuns._cleanupDiscardedWavePickedContacts, |
| { runId: args.runId }, |
| ); |
| if (result.hasMore) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.cleanupDiscardedWavePickedContactsAction, |
| { runId: args.runId }, |
| ); |
| } else if (result.unstamped > 0) { |
| console.log( |
| `[cleanupDiscardedWavePickedContactsAction] runId=${args.runId} ` + |
| `unstamped ${result.unstamped} registrations (re-eligible for future picks)`, |
| ); |
| } |
| return result; |
| } |
|
|
| |
| const candidates = await ctx.runQuery( |
| internal.broadcast.waveRuns._listFailedWaveRunsForCleanup, |
| {}, |
| ); |
| let totalDeleted = 0; |
| let totalUnstamped = 0; |
| for (const runId of candidates) { |
| const result = await ctx.runMutation( |
| internal.broadcast.waveRuns._cleanupDiscardedWavePickedContacts, |
| { runId }, |
| ); |
| totalDeleted += result.deleted; |
| totalUnstamped += result.unstamped; |
| if (result.hasMore) { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.broadcast.waveRuns.cleanupDiscardedWavePickedContactsAction, |
| { runId }, |
| ); |
| } |
| } |
| return { deleted: totalDeleted, unstamped: totalUnstamped, hasMore: false }; |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const TERMINAL_FAILURE_SUBSTATUSES = [ |
| "discarded-by-operator", |
| "empty-pool", |
| "pool-too-small", |
| "segment-create-failed", |
| "persist-failed", |
| "batch-failure-rate-exceeded", |
| ] as const; |
|
|
| |
| |
| |
| |
| const CLEANUP_CANDIDATES_PER_TICK = 100; |
|
|
| export const _listFailedWaveRunsForCleanup = internalQuery({ |
| args: {}, |
| handler: async (ctx) => { |
| const cutoff = Date.now() - 24 * 60 * 60 * 1000; |
| const failed = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_status", (q) => q.eq("status", "failed")) |
| .take(CLEANUP_CANDIDATES_PER_TICK); |
| return failed |
| .filter( |
| (r) => |
| r.updatedAt < cutoff && |
| r.failureSubstatus !== undefined && |
| (TERMINAL_FAILURE_SUBSTATUSES as readonly string[]).includes( |
| r.failureSubstatus, |
| ), |
| ) |
| .map((r) => r.runId); |
| }, |
| }); |
|
|
| |
| |
| |
|
|
| const ACTIVE_STATUSES: WaveRunStatus[] = [ |
| "picking", |
| "segment-created", |
| "pushing", |
| "broadcast-created", |
| ]; |
|
|
| export const _listInFlightWaveRuns = internalQuery({ |
| args: {}, |
| handler: async (ctx) => { |
| const rows: Array<{ |
| runId: string; |
| status: WaveRunStatus; |
| lastActivityAt: number; |
| }> = []; |
| for (const status of ACTIVE_STATUSES) { |
| const found = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_status", (q) => q.eq("status", status)) |
| .collect(); |
| for (const r of found) { |
| rows.push({ |
| runId: r.runId, |
| status: r.status, |
| lastActivityAt: r.lastBatchAt ?? r.updatedAt ?? r.createdAt, |
| }); |
| } |
| } |
| return rows; |
| }, |
| }); |
|
|
| export const getWaveRunStatus = internalQuery({ |
| args: { runId: v.string() }, |
| handler: async (ctx, { runId }) => { |
| const run = await ctx.db |
| .query("waveRuns") |
| .withIndex("by_runId", (q) => q.eq("runId", runId)) |
| .unique(); |
| if (!run) return null; |
| const pending = await ctx.db |
| .query("wavePickedContacts") |
| .withIndex("by_runId_status", (q) => |
| q.eq("runId", runId).eq("status", "pending"), |
| ) |
| .take(1); |
| return { |
| runId: run.runId, |
| waveLabel: run.waveLabel, |
| status: run.status, |
| failureSubstatus: run.failureSubstatus, |
| error: run.error, |
| segmentId: run.segmentId, |
| broadcastId: run.broadcastId, |
| requestedCount: run.requestedCount, |
| totalCount: run.totalCount, |
| pushedCount: run.pushedCount, |
| failedCount: run.failedCount, |
| underfilled: run.underfilled, |
| hasPendingContacts: pending.length > 0, |
| lastActivityAt: run.lastBatchAt ?? run.updatedAt, |
| createdAt: run.createdAt, |
| updatedAt: run.updatedAt, |
| }; |
| }, |
| }); |
|
|