feat(broadcast): per-wave audience export — pick N, stamp, push to fresh Resend segment#3462
Conversation
…esh Resend segment
The sustainable per-send primitive for the PRO-launch ramp. Replaces
manual dashboard sub-segmenting with one CLI command per wave; the
existing canary-250 stamps already in registrations naturally exclude
yesterday's recipients from being picked again.
npx convex run broadcast/audienceWaveExport:assignAndExportWave \
'{"waveLabel":"wave-2","count":500}'
# → returns { segmentId, assigned, ... }
# Then existing flow:
npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \
'{"segmentId":"<returned>","nameSuffix":"wave-2"}'
What it does:
1. Refuse if waveLabel already has stamped rows (operator picks unique
label per wave; prevents accidental double-stamping).
2. Page registrations.paginate (1000/page), apply same dedup rules as
audienceExport.ts (empty / suppressed / paid / already-in-prior-wave).
Reservoir-sample N via Algorithm R — fair sample, single pass,
O(N) memory.
3. Stamp each picked row with proLaunchWave + assignedAt via the
shared _stampWaveByNormalizedEmail mutation (mirrors the
canary-250 backfill action).
4. Create a fresh Resend segment via POST /segments named
`pro-launch-${waveLabel}`.
5. Push picked contacts via the shared upsertContactToSegment helper
(same two-step pattern audienceExport already uses — handles the
"global contact exists, segments field not applied on duplicate
422" Resend API quirk).
6. Return { segmentId, assigned, linkedExisting, alreadyExists, failed,
underfilled }.
Companion refactor — extracted Resend helpers to a shared module:
- `_resendContacts.ts` (NEW): RESEND_API_BASE, USER_AGENT,
isDuplicateContactError, UpsertOutcome, upsertContactToSegment,
and createSegment.
- `audienceExport.ts`: replaced its inline copies with imports from
the new module. No behaviour change; just dedup.
Why Resend can't do this natively: verified against Resend docs —
POST /broadcasts accepts segment_id only (no exclude/sample/limit
params), POST /segments accepts name only (segments are membership
lists, not query-defined via API). Progressive waves require tracking
membership somewhere; Convex is the right source of truth since dedup
math already runs there.
Convex codegen regenerated and committed (api.d.ts now includes
audienceWaveExport's internal mutation/query/action). Convex
typecheck (`tsc -p convex/tsconfig.json`) clean. Sentry-coverage lint
guard clean.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds a per-wave audience export action (
Confidence Score: 3/5Not safe to merge as-is; the stamp mutation can silently overwrite a prior wave's label under concurrent runs, breaking the double-email guarantee. One P1 finding (overwrite race in _stampWaveByNormalizedEmail) is a present correctness defect that directly contradicts a stated operational guarantee; three P2s do not compound the score but reinforce that the stamp path needs more care. convex/broadcast/audienceWaveExport.ts — specifically the _stampWaveByNormalizedEmail mutation handler and the stamp loop in assignAndExportWave. Important Files Changed
Reviews (1): Last reviewed commit: "feat(broadcast): per-wave audience expor..." | Re-trigger Greptile |
| }); | ||
| return { result: "stamped" as const }; | ||
| }, | ||
| }); | ||
|
|
||
| type WaveExportStats = { | ||
| waveLabel: string; | ||
| segmentId: string; | ||
| segmentName: string; | ||
| // How many registrations were eligible (passed all dedup filters) | ||
| // and seen by the reservoir. May be > or = to `assigned`. | ||
| poolSize: number; | ||
| // Newly-stamped + pushed to Resend segment. | ||
| assigned: number; |
There was a problem hiding this comment.
Stamp mutation silently overwrites a different wave's label
_stampWaveByNormalizedEmail only short-circuits when the row already has the same waveLabel; it doesn't guard against an existing different wave label. If two assignAndExportWave calls with different labels (e.g., wave-2 and wave-3) are started within the same paginate window, both select the same un-stamped email, then both stamp it sequentially — the second write overwrites the first. The contact ends up in both Resend segments and receives two emails, directly violating the "No double-emails" operational guarantee.
The fix is to add an "already in a different wave" guard in the mutation:
if (row.proLaunchWave && row.proLaunchWave !== waveLabel) {
return { result: "alreadyInDifferentWave" as const };
}And then count / warn on that outcome in the action loop instead of overwriting silently.
| { waveLabel, count }, | ||
| ): Promise<WaveExportStats> => { | ||
| const apiKey = process.env.RESEND_API_KEY; | ||
| if (!apiKey) { | ||
| throw new Error( |
There was a problem hiding this comment.
count validation doesn't reject non-integer values
v.number() allows floats, and the guard Number.isFinite(count) && count > 0 passes for values like 1.5 or 499.9. A fractional count yields a reservoir with a non-integer size, so buf.length < this.size keeps accepting items one past the intended cap. Add Number.isInteger(count) to the guard:
| { waveLabel, count }, | |
| ): Promise<WaveExportStats> => { | |
| const apiKey = process.env.RESEND_API_KEY; | |
| if (!apiKey) { | |
| throw new Error( | |
| if (!Number.isFinite(count) || !Number.isInteger(count) || count <= 0) { |
| ); | ||
| for (const row of page.page) { | ||
| const email = row.normalizedEmail; | ||
| if (!email || email.length === 0) continue; | ||
| if (suppressedSet.has(email)) continue; | ||
| if (paidSet.has(email)) continue; | ||
| if (row.proLaunchWave) continue; | ||
| reservoir.offer(email); |
There was a problem hiding this comment.
Sequential stamp loop is N round-trips for N contacts
The stamp loop issues one ctx.runMutation per picked email, so a wave of 500 contacts makes 500 serialized Convex round-trips before the Resend push even begins. A batched mutation accepting a list of {normalizedEmail, waveLabel, assignedAt} tuples would reduce this to a single round-trip and also make the stamp phase closer to atomic.
| .map((row) => row.normalizedEmail) | ||
| .filter((e): e is string => typeof e === "string" && e.length > 0); | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Snapshot of paid (customer) normalizedEmails. Mirrors | ||
| * `audienceExport.getPaidEmails` including the `email`-fallback | ||
| * defence-in-depth for un-backfilled rows. | ||
| */ | ||
| 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); | ||
| }, | ||
| }); | ||
|
|
||
| /** | ||
| * Paginated page of registrations. Mirrors | ||
| * `audienceExport.getRegistrationsPage` shape so the call shape is | ||
| * identical between exporters. | ||
| */ | ||
| export const _getRegistrationsPage = internalQuery({ | ||
| args: { | ||
| cursor: v.union(v.string(), v.null()), | ||
| numItems: v.number(), | ||
| }, | ||
| handler: async (ctx, { cursor, numItems }) => { |
There was a problem hiding this comment.
_getSuppressedEmails, _getPaidEmails, and _getRegistrationsPage duplicated from audienceExport.ts
These three internal queries are acknowledged as mirrors of the originals, but the PR also extracted Resend helpers into _resendContacts.ts specifically to avoid exactly this kind of duplication. Keeping three separate copies of the same DB-read logic means future dedup-rule changes must be applied in both places.
P1 from review: previous order stamped all picked contacts BEFORE
attempting Resend push. If `createSegment` threw, or any
`upsertContactToSegment` returned `failed`, those contacts were
permanently excluded from future waves but never landed in a
sendable Resend segment — silently stranded.
New order:
1. Pick N (in-memory reservoir, no side effects)
2. createSegment — atomic, throws on failure → no contacts stamped
3. For each picked: push first, stamp ONLY on success
(created / linkedExisting / alreadyInSegment).
Failed pushes leave the contact unstamped → available for
next wave's pick.
Edge case (rare): push succeeds, stamp throws. Contact is in the
Resend segment but unstamped → may be re-picked into a later wave
and receive a duplicate email. Counted in new `stampFailed` stat;
operator can manually stamp via Data Explorer if it happens. We
don't roll back the Resend push (the DELETE call is a worse risk
than the duplicate-email exposure).
The new `stampFailed: number` field in WaveExportStats surfaces
this case explicitly. Documented in the file docstring's
"Atomicity" section so the next reader doesn't try to "simplify"
back to the unsafe stamp-first ordering.
…esh Resend segment (koala73#3462) * feat(broadcast): per-wave audience export — pick N, stamp, push to fresh Resend segment The sustainable per-send primitive for the PRO-launch ramp. Replaces manual dashboard sub-segmenting with one CLI command per wave; the existing canary-250 stamps already in registrations naturally exclude yesterday's recipients from being picked again. npx convex run broadcast/audienceWaveExport:assignAndExportWave \ '{"waveLabel":"wave-2","count":500}' # → returns { segmentId, assigned, ... } # Then existing flow: npx convex run broadcast/sendBroadcast:createProLaunchBroadcast \ '{"segmentId":"<returned>","nameSuffix":"wave-2"}' What it does: 1. Refuse if waveLabel already has stamped rows (operator picks unique label per wave; prevents accidental double-stamping). 2. Page registrations.paginate (1000/page), apply same dedup rules as audienceExport.ts (empty / suppressed / paid / already-in-prior-wave). Reservoir-sample N via Algorithm R — fair sample, single pass, O(N) memory. 3. Stamp each picked row with proLaunchWave + assignedAt via the shared _stampWaveByNormalizedEmail mutation (mirrors the canary-250 backfill action). 4. Create a fresh Resend segment via POST /segments named `pro-launch-${waveLabel}`. 5. Push picked contacts via the shared upsertContactToSegment helper (same two-step pattern audienceExport already uses — handles the "global contact exists, segments field not applied on duplicate 422" Resend API quirk). 6. Return { segmentId, assigned, linkedExisting, alreadyExists, failed, underfilled }. Companion refactor — extracted Resend helpers to a shared module: - `_resendContacts.ts` (NEW): RESEND_API_BASE, USER_AGENT, isDuplicateContactError, UpsertOutcome, upsertContactToSegment, and createSegment. - `audienceExport.ts`: replaced its inline copies with imports from the new module. No behaviour change; just dedup. Why Resend can't do this natively: verified against Resend docs — POST /broadcasts accepts segment_id only (no exclude/sample/limit params), POST /segments accepts name only (segments are membership lists, not query-defined via API). Progressive waves require tracking membership somewhere; Convex is the right source of truth since dedup math already runs there. Convex codegen regenerated and committed (api.d.ts now includes audienceWaveExport's internal mutation/query/action). Convex typecheck (`tsc -p convex/tsconfig.json`) clean. Sentry-coverage lint guard clean. * fixup: reorder wave export to push-first, stamp-only-on-success P1 from review: previous order stamped all picked contacts BEFORE attempting Resend push. If `createSegment` threw, or any `upsertContactToSegment` returned `failed`, those contacts were permanently excluded from future waves but never landed in a sendable Resend segment — silently stranded. New order: 1. Pick N (in-memory reservoir, no side effects) 2. createSegment — atomic, throws on failure → no contacts stamped 3. For each picked: push first, stamp ONLY on success (created / linkedExisting / alreadyInSegment). Failed pushes leave the contact unstamped → available for next wave's pick. Edge case (rare): push succeeds, stamp throws. Contact is in the Resend segment but unstamped → may be re-picked into a later wave and receive a duplicate email. Counted in new `stampFailed` stat; operator can manually stamp via Data Explorer if it happens. We don't roll back the Resend push (the DELETE call is a worse risk than the duplicate-email exposure). The new `stampFailed: number` field in WaveExportStats surfaces this case explicitly. Documented in the file docstring's "Atomicity" section so the next reader doesn't try to "simplify" back to the unsafe stamp-first ordering.
Summary
The sustainable per-wave primitive for the PRO-launch ramp. One CLI command per send, no manual Resend dashboard work, no risk of re-emailing prior waves. Replaces #3453's "you'd have to keep building segments forever" tax.
Why this design (recap)
Resend's API can't subset/sample/exclude at broadcast time (verified docs):
POST /broadcastsacceptssegment_idonly — no inclusion/exclusion filters.POST /segmentsacceptsnameonly — segments are membership lists, not query-defined.So progressive waves require tracking membership in Convex (where dedup math already runs). The
proLaunchWave?field onregistrations(added in #3453) is the source of truth; this PR adds the action that picks-N-and-stamps automatically.What
assignAndExportWavedoeswaveLabelalready has stamped rows (operator picks unique label per wave; prevents accidental double-stamp).registrations.paginate(1000/page), filter by the same dedup rules asaudienceExport.ts:normalizedEmailemailSuppressionscustomers(paid)proLaunchWaveis undefined (not in any prior wave)Random-sample
countvia reservoir sampling (Algorithm R) — fair sample, single pass, O(N) memory.proLaunchWave = waveLabel+proLaunchWaveAssignedAt.POST /segmentsnamedpro-launch-${waveLabel}.upsertContactToSegmenthelper (same two-step patternaudienceExport.tsuses for the global-contact-422 quirk).{ segmentId, assigned, linkedExisting, alreadyExists, failed, underfilled }.underfilled: truewhen the eligible pool is smaller thancount(e.g., asked for 500 but only 320 unstamped left after past waves). Operator should treat this as "the waitlist is drained for this ramp tier."Companion refactor — extracted Resend helpers
convex/broadcast/_resendContacts.ts(NEW) owns the Resend HTTP layer:RESEND_API_BASE,USER_AGENTconstantsisDuplicateContactErrorheuristic (matches the 422 duplicate shape)UpsertOutcometypeupsertContactToSegment(the two-stepPOST /contacts+ fallbackPOST /contacts/{email}/segments/{id}that handles "global contact exists, segments field not applied on duplicate" — encoded once, reused by both exporters)createSegment(NEW — used by the wave action to mint per-wave segments)audienceExport.tsnow imports these instead of carrying inline copies. No behaviour change on the full-audience path; just dedup.Files
convex/broadcast/audienceWaveExport.ts(NEW)convex/broadcast/_resendContacts.ts(NEW)convex/broadcast/audienceExport.ts_resendContacts.tsconvex/_generated/api.d.tsinternal.broadcast.audienceWaveExport.*Test plan
npx tsc --noEmit -p convex/tsconfig.jsoncleancount: 1to verify the full path (pick → stamp → segment-create → push) works end-to-end without committing to a real wave size{ assigned: 500, ... },pro-launch-wave-2segment in Resend dashboard with 500 contactsOperational guarantees
audienceExport.ts(added in chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts #3453) plus the_hasWaveLabelpre-flight here means an accidental re-run is a no-op error, not a duplicate send.waveLabelfor the next attempt and the partially-stamped contacts roll into the next wave's pool naturally.