/** * Tests for the broadcast ramp runner's lease-based concurrency guard + * structured recovery action. * * Two production-incident-prevention scenarios: * * 1. Race condition (P1, PR #3473 review): two overlapping cron runs (or * cron + manual trigger, or Convex action retry) both proceed through * assignAndExportWave + createProLaunchBroadcast + sendProLaunchBroadcast * before colliding at _recordWaveSent. By then DUPLICATE EMAILS have * already gone out. The lease (claimed BEFORE side effects) makes the * loser exit cleanly. * * 2. Recovery after exported-but-not-sent (P1, PR #3473 review): a partial * failure where assignAndExportWave succeeded (contacts stamped, segment * created) but createProLaunchBroadcast / sendProLaunchBroadcast threw. * Bare clearPartialFailure makes the next cron retry the same waveLabel, * which fails because the contacts are already stamped. recoverFromPartialFailure * provides explicit recovery modes: manual-finished (advance tier with * manually-completed broadcastId) or discard-and-rotate (bump * waveLabelOffset so next cron uses a fresh label). */ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { convexTest } from "convex-test"; import { describe, expect, test } from "vitest"; import schema from "../schema"; import { internal } from "../_generated/api"; const modules = import.meta.glob("../**/*.ts"); const __dirname = dirname(fileURLToPath(import.meta.url)); const runnerSrc = readFileSync( resolve(__dirname, "..", "broadcast", "rampRunner.ts"), "utf-8", ); async function seedRampConfig( t: ReturnType, overrides: Partial<{ currentTier: number; rampCurve: number[]; waveLabelPrefix: string; waveLabelOffset: number; lastRunStatus: string | undefined; lastWaveBroadcastId: string | undefined; lastWaveSentAt: number | undefined; pendingRunId: string | undefined; pendingRunStartedAt: number | undefined; pendingWaveLabel: string | undefined; pendingSegmentId: string | undefined; pendingAssigned: number | undefined; pendingExportAt: number | undefined; pendingBroadcastId: string | undefined; pendingBroadcastAt: number | undefined; active: boolean; killGateTripped: boolean; }> = {}, ) { await t.run(async (ctx) => { await ctx.db.insert("broadcastRampConfig", { key: "current", active: overrides.active ?? true, rampCurve: overrides.rampCurve ?? [500, 1500, 5000], currentTier: overrides.currentTier ?? 0, waveLabelPrefix: overrides.waveLabelPrefix ?? "wave", waveLabelOffset: overrides.waveLabelOffset ?? 3, bounceKillThreshold: 0.04, complaintKillThreshold: 0.0008, killGateTripped: overrides.killGateTripped ?? false, lastRunStatus: overrides.lastRunStatus, lastWaveBroadcastId: overrides.lastWaveBroadcastId, lastWaveSentAt: overrides.lastWaveSentAt, pendingRunId: overrides.pendingRunId, pendingRunStartedAt: overrides.pendingRunStartedAt, pendingWaveLabel: overrides.pendingWaveLabel, pendingSegmentId: overrides.pendingSegmentId, pendingAssigned: overrides.pendingAssigned, pendingExportAt: overrides.pendingExportAt, pendingBroadcastId: overrides.pendingBroadcastId, pendingBroadcastAt: overrides.pendingBroadcastAt, }); }); } async function loadRow(t: ReturnType) { return await t.run(async (ctx) => ctx.db .query("broadcastRampConfig") .withIndex("by_key", (q) => q.eq("key", "current")) .first(), ); } // ---------------------------------------------------------------------------- // _claimTierForRun: the pre-side-effect lock // ---------------------------------------------------------------------------- describe("_claimTierForRun — lease lifecycle", () => { test("claims successfully when no lease is held", async () => { const t = convexTest(schema, modules); await seedRampConfig(t); const result = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-1", expectedCurrentTier: 0, }); expect(result.ok).toBe(true); const row = await loadRow(t); expect(row?.pendingRunId).toBe("run-1"); expect(row?.pendingRunStartedAt).toBeTypeOf("number"); }); test("rejects when another lease is held and fresh — RACE GUARD", async () => { // The whole point: two concurrent runs both pass kill-gate / tier-bounds // checks, both attempt to claim, only ONE wins. The loser exits without // running assignAndExportWave + createProLaunchBroadcast + sendProLaunchBroadcast. const t = convexTest(schema, modules); await seedRampConfig(t); const first = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-A", expectedCurrentTier: 0, }); expect(first.ok).toBe(true); const second = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-B", expectedCurrentTier: 0, }); expect(second.ok).toBe(false); expect(second.reason).toBe("lease-held"); expect(second.heldBy).toBe("run-A"); }); test("rejects when expectedCurrentTier doesn't match — protects against tier-already-advanced", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 2 }); const result = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-1", expectedCurrentTier: 1, // stale }); expect(result.ok).toBe(false); expect(result.reason).toBe("tier-moved"); expect(result.actualTier).toBe(2); }); test("rejects EVEN A STALE lease — no automatic time-based override (P1#1)", async () => { // P1#1 fix: a wall-clock-based override has the same failure mode the // lease exists to prevent. assignAndExportWave can legitimately exceed // any cutoff for large rampCurve sizes / slow Resend, and overriding // mid-flight lets a second run race and duplicate-send. Recovery from // a genuinely-stuck lease is operator-only via forceReleaseLease. const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-very-old", pendingRunStartedAt: Date.now() - 6 * 60 * 60 * 1000, // 6 hours ago }); const result = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-fresh", expectedCurrentTier: 0, }); expect(result.ok).toBe(false); expect(result.reason).toBe("lease-held"); expect(result.heldBy).toBe("run-very-old"); const row = await loadRow(t); // Lease is unchanged. expect(row?.pendingRunId).toBe("run-very-old"); }); }); // ---------------------------------------------------------------------------- // forceReleaseLease — operator-only stale-lease recovery // ---------------------------------------------------------------------------- describe("forceReleaseLease — operator-only stale-lease recovery (P1#1)", () => { test("clears the lease and sets lastRunStatus=partial-failure so recoverFromPartialFailure can pick up", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-wedged", pendingRunStartedAt: Date.now() - 6 * 60 * 60 * 1000, // Mid-flight progress preserved so the operator can decide between // manual-finished and discard-and-rotate from persisted state. pendingWaveLabel: "wave-7", pendingSegmentId: "seg-wedged", pendingAssigned: 5000, pendingBroadcastId: "bc-wedged", }); const result = await t.mutation( internal.broadcast.rampRunner.forceReleaseLease, { reason: "cron action wedged 6h, no partial-failure recorded" }, ); expect(result.ok).toBe(true); expect(result.releasedRunId).toBe("run-wedged"); const row = await loadRow(t); expect(row?.pendingRunId).toBeUndefined(); expect(row?.pendingRunStartedAt).toBeUndefined(); expect(row?.lastRunStatus).toBe("partial-failure"); expect(row?.lastRunError).toMatch(/forced-release/i); // Pending progress markers preserved. expect(row?.pendingWaveLabel).toBe("wave-7"); expect(row?.pendingSegmentId).toBe("seg-wedged"); expect(row?.pendingBroadcastId).toBe("bc-wedged"); }); test("noop when no lease is held", async () => { const t = convexTest(schema, modules); await seedRampConfig(t); const result = await t.mutation( internal.broadcast.rampRunner.forceReleaseLease, { reason: "no-op test" }, ); expect(result.ok).toBe(true); expect(result.noop).toBe(true); }); test("after force-release, a fresh claim can succeed", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-stuck", pendingRunStartedAt: Date.now() - 60 * 60 * 1000, }); await t.mutation(internal.broadcast.rampRunner.forceReleaseLease, { reason: "stuck", }); const claim = await t.mutation( internal.broadcast.rampRunner._claimTierForRun, { runId: "run-recovered", expectedCurrentTier: 0 }, ); expect(claim.ok).toBe(true); }); }); // ---------------------------------------------------------------------------- // _recordWaveSent: validates lease and clears it // ---------------------------------------------------------------------------- describe("_recordWaveSent — lease validation on success", () => { test("clears the lease and advances tier when the lease is held by the same runId", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, pendingRunId: "run-X", pendingRunStartedAt: Date.now(), }); const result = await t.mutation(internal.broadcast.rampRunner._recordWaveSent, { runId: "run-X", expectedCurrentTier: 1, newTier: 2, waveLabel: "wave-5", broadcastId: "bc-test-123", segmentId: "seg-test-456", assigned: 1500, sentAt: Date.now(), }); expect(result.ok).toBe(true); const row = await loadRow(t); expect(row?.currentTier).toBe(2); expect(row?.lastWaveBroadcastId).toBe("bc-test-123"); expect(row?.pendingRunId).toBeUndefined(); expect(row?.pendingRunStartedAt).toBeUndefined(); }); test("throws when the lease has been overridden (lease-lost guard)", async () => { // Defends against the (rare) case where our lease was overridden as stale // by another run while we were in flight. We must NOT advance the tier — // the other run may also be in flight and would conflict. const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, pendingRunId: "run-OTHER", pendingRunStartedAt: Date.now(), }); await expect( t.mutation(internal.broadcast.rampRunner._recordWaveSent, { runId: "run-MINE", expectedCurrentTier: 1, newTier: 2, waveLabel: "wave-5", broadcastId: "bc-test", segmentId: "seg-test", assigned: 1500, sentAt: Date.now(), }), ).rejects.toThrow(/lease lost/i); }); }); // ---------------------------------------------------------------------------- // _recordRunOutcome: clears lease for the matching runId // ---------------------------------------------------------------------------- describe("_recordRunOutcome — lease release on failure", () => { test("clears the lease when runId matches the held lease", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-Y", pendingRunStartedAt: Date.now(), }); await t.mutation(internal.broadcast.rampRunner._recordRunOutcome, { runId: "run-Y", status: "partial-failure", error: "test failure", }); const row = await loadRow(t); expect(row?.pendingRunId).toBeUndefined(); expect(row?.pendingRunStartedAt).toBeUndefined(); expect(row?.lastRunStatus).toBe("partial-failure"); }); test("HARD NO-OP when runId differs — does NOT write status/error/active either (P1#2)", async () => { // P1#2 fix: a lost-lease run must NOT overwrite the winner's authoritative // outcome state. Previously, lease-mismatch only suppressed the lease-clear // but still wrote lastRunStatus / lastRunError / killGateTripped / active — // which clobbered the operator's recovery decisions. const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-OTHER", pendingRunStartedAt: Date.now(), lastRunStatus: "succeeded", // operator/winner-set killGateTripped: false, active: true, }); const result = await t.mutation( internal.broadcast.rampRunner._recordRunOutcome, { runId: "run-MINE", status: "partial-failure", error: "should not land", killGate: true, killGateReason: "should not land", deactivate: true, }, ); expect(result.ok).toBe(false); expect(result.reason).toBe("lease-lost"); const row = await loadRow(t); // Lease unchanged. expect(row?.pendingRunId).toBe("run-OTHER"); // ALL outcome fields unchanged — no stomp. expect(row?.lastRunStatus).toBe("succeeded"); expect(row?.lastRunError).toBeUndefined(); expect(row?.killGateTripped).toBe(false); expect(row?.killGateReason).toBeUndefined(); expect(row?.active).toBe(true); }); test("HARD NO-OP when lease is cleared (operator already took control)", async () => { // The other lease-lost case: operator already called forceReleaseLease / // recoverFromPartialFailure, clearing pendingRunId. The displaced run's // catch block tries to write — must be no-op too. const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: undefined, lastRunStatus: "partial-failure", // operator-set }); const result = await t.mutation( internal.broadcast.rampRunner._recordRunOutcome, { runId: "run-DISPLACED", status: "succeeded", error: "should not land", }, ); expect(result.ok).toBe(false); expect(result.reason).toBe("lease-lost"); const row = await loadRow(t); expect(row?.lastRunStatus).toBe("partial-failure"); // operator's status preserved }); }); // ---------------------------------------------------------------------------- // _recordPendingExport / _recordPendingBroadcast — per-step persistence (P1#4) // ---------------------------------------------------------------------------- describe("_recordPendingExport — persists progress + lease-validates", () => { test("persists waveLabel/segmentId/assigned/exportAt when lease is owned", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-X", pendingRunStartedAt: Date.now(), }); const result = await t.mutation( internal.broadcast.rampRunner._recordPendingExport, { runId: "run-X", waveLabel: "wave-5", segmentId: "seg-export", assigned: 1500, }, ); expect(result.ok).toBe(true); const row = await loadRow(t); expect(row?.pendingWaveLabel).toBe("wave-5"); expect(row?.pendingSegmentId).toBe("seg-export"); expect(row?.pendingAssigned).toBe(1500); expect(row?.pendingExportAt).toBeTypeOf("number"); }); test("throws when lease has been force-released (P1#1+P1#4 interaction)", async () => { // The runner's _recordPendingExport call protects against operator // force-releasing the lease while assignAndExportWave was running. const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: undefined }); // no lease await expect( t.mutation(internal.broadcast.rampRunner._recordPendingExport, { runId: "run-DISPLACED", waveLabel: "wave-5", segmentId: "seg-export", assigned: 1500, }), ).rejects.toThrow(/lease lost/i); }); test("throws when lease is owned by a different run", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-OTHER", pendingRunStartedAt: Date.now(), }); await expect( t.mutation(internal.broadcast.rampRunner._recordPendingExport, { runId: "run-MINE", waveLabel: "wave-5", segmentId: "seg-export", assigned: 1500, }), ).rejects.toThrow(/lease lost/i); }); }); describe("_recordPendingBroadcast — persists broadcastId + lease-validates", () => { test("persists broadcastId/broadcastAt when lease is owned", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "run-Y", pendingRunStartedAt: Date.now(), pendingWaveLabel: "wave-5", pendingSegmentId: "seg-export", pendingAssigned: 1500, pendingExportAt: Date.now() - 1000, }); const result = await t.mutation( internal.broadcast.rampRunner._recordPendingBroadcast, { runId: "run-Y", broadcastId: "bc-created" }, ); expect(result.ok).toBe(true); const row = await loadRow(t); expect(row?.pendingBroadcastId).toBe("bc-created"); expect(row?.pendingBroadcastAt).toBeTypeOf("number"); // Earlier persisted state preserved. expect(row?.pendingWaveLabel).toBe("wave-5"); }); test("throws when lease has been force-released", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: undefined }); await expect( t.mutation(internal.broadcast.rampRunner._recordPendingBroadcast, { runId: "run-DISPLACED", broadcastId: "bc-created", }), ).rejects.toThrow(/lease lost/i); }); }); describe("_recordWaveSent — clears all pending* progress markers (P1#4)", () => { test("on success, every pending* field is cleared so the next run starts fresh", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, pendingRunId: "run-Z", pendingRunStartedAt: Date.now(), pendingWaveLabel: "wave-5", pendingSegmentId: "seg-test", pendingAssigned: 1500, pendingExportAt: Date.now() - 5000, pendingBroadcastId: "bc-test", pendingBroadcastAt: Date.now() - 1000, }); await t.mutation(internal.broadcast.rampRunner._recordWaveSent, { runId: "run-Z", expectedCurrentTier: 1, newTier: 2, waveLabel: "wave-5", broadcastId: "bc-test", segmentId: "seg-test", assigned: 1500, sentAt: Date.now(), }); const row = await loadRow(t); expect(row?.pendingWaveLabel).toBeUndefined(); expect(row?.pendingSegmentId).toBeUndefined(); expect(row?.pendingAssigned).toBeUndefined(); expect(row?.pendingExportAt).toBeUndefined(); expect(row?.pendingBroadcastId).toBeUndefined(); expect(row?.pendingBroadcastAt).toBeUndefined(); // And the lease is also cleared. expect(row?.pendingRunId).toBeUndefined(); }); }); // ---------------------------------------------------------------------------- // clearPartialFailure — fail-closed unless operator confirms no export (P1#3) // ---------------------------------------------------------------------------- // PR 2 (post-launch-stabilization, 2026-04-30) replaced the monolithic // `runDailyRamp` body with a state-machine-based scheduler call. The two // source-grep tests that lived here previously (PR #3476 review rounds 3 // and 4) protected against regressions in the now-removed // `_recordPendingExport` call ordering and `pool-drained vs partial-failure` // routing. Both branches are gone — `pickWaveAction` (in waveRuns.ts) now // owns underfill / persist-failure handling, and the legacy // `_recordPendingExport` mutation remains in this module only for // `recoverFromPartialFailure` to use against any legacy partial-failure // rows. New behaviour is tested in `convex/__tests__/waveRuns.test.ts`. describe("clearPartialFailure — confirmNoExport guard (P1#3)", () => { test("REFUSES when any pending* progress marker is set — even with confirmNoExport=true", async () => { // P1#3 fix: confirmNoExport is a literal-true gate, but the operator // could still get it wrong. The persisted pending* markers are the // authoritative signal that the export DID run. Refuse loudly. const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "partial-failure", pendingSegmentId: "seg-stamped", // export ran }); await expect( t.mutation(internal.broadcast.rampRunner.clearPartialFailure, { reason: "operator thinks no export happened", confirmNoExport: true, }), ).rejects.toThrow(/refused: pending progress markers present/i); }); test("clears when no pending* markers AND confirmNoExport=true (truly pre-export failure)", async () => { // Genuine case: assignAndExportWave threw before any contact stamping or // segment creation, so no pending* markers were persisted. Operator // confirms zero stamps in audience tables, calls clearPartialFailure. const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "partial-failure", pendingRunId: "run-Q", pendingRunStartedAt: Date.now(), // no pendingWaveLabel/SegmentId/BroadcastId }); const result = await t.mutation( internal.broadcast.rampRunner.clearPartialFailure, { reason: "assignAndExportWave threw on Resend timeout before stamping", confirmNoExport: true, }, ); expect(result.ok).toBe(true); const row = await loadRow(t); expect(row?.lastRunStatus).toMatch(/partial-failure-cleared/); expect(row?.pendingRunId).toBeUndefined(); }); test("noop when status isn't partial-failure", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "succeeded" }); const result = await t.mutation( internal.broadcast.rampRunner.clearPartialFailure, { reason: "test", confirmNoExport: true }, ); expect(result.noop).toBe(true); }); }); // ---------------------------------------------------------------------------- // recoverFromPartialFailure — the structured recovery for P1 #2 // ---------------------------------------------------------------------------- describe("recoverFromPartialFailure — exported-but-not-sent recovery", () => { test("manual-finished: advances tier + records broadcastId from operator-completed wave", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, lastRunStatus: "partial-failure", pendingRunId: "run-stuck", pendingRunStartedAt: Date.now(), }); const result = await t.mutation( internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "manual-finished", reason: "Sent wave-5 manually via Resend dashboard after createProLaunchBroadcast threw", broadcastId: "bc-manual-789", segmentId: "seg-manual-456", sentAt: 1700000000000, assigned: 1500, }, ); expect(result.ok).toBe(true); expect(result.recovery).toBe("manual-finished"); expect(result.advancedToTier).toBe(2); const row = await loadRow(t); expect(row?.currentTier).toBe(2); expect(row?.lastWaveBroadcastId).toBe("bc-manual-789"); expect(row?.lastWaveSegmentId).toBe("seg-manual-456"); expect(row?.lastWaveAssigned).toBe(1500); expect(row?.lastWaveSentAt).toBe(1700000000000); expect(row?.lastRunStatus).toMatch(/succeeded-via-manual-recovery/); expect(row?.pendingRunId).toBeUndefined(); }); test("manual-finished: rejects when required fields are missing AND no persisted fallback", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "partial-failure" }); await expect( t.mutation(internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "manual-finished", reason: "test", // missing broadcastId, segmentId, sentAt, assigned }), ).rejects.toThrow(/missing required field/i); }); test("manual-finished: AUTO-FILLS from persisted pending* state when operator omits args (P1#4)", async () => { // P1#4 fix: when the action dies after _recordPendingExport / // _recordPendingBroadcast but before _recordWaveSent (Convex action // timeout), recovery doesn't require operator to dig broadcastId/segmentId // out of the Resend dashboard. Persisted state is the source of truth. const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, lastRunStatus: "partial-failure", pendingRunId: "run-died", pendingRunStartedAt: Date.now(), pendingWaveLabel: "wave-5", pendingSegmentId: "seg-persisted", pendingAssigned: 1500, pendingExportAt: Date.now() - 60000, pendingBroadcastId: "bc-persisted", pendingBroadcastAt: Date.now() - 30000, }); const result = await t.mutation( internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "manual-finished", reason: "Action timed out; manual send completed via Resend dashboard", sentAt: 1700000000000, // operator-only // broadcastId/segmentId/assigned/waveLabel — all OMITTED, fall back to persisted }, ); expect(result.ok).toBe(true); expect(result.advancedToTier).toBe(2); expect(result.broadcastId).toBe("bc-persisted"); expect(result.segmentId).toBe("seg-persisted"); expect(result.assigned).toBe(1500); expect(result.waveLabel).toBe("wave-5"); expect(result.usedPersistedFallback).toEqual({ broadcastId: true, segmentId: true, assigned: true, waveLabel: true, }); const row = await loadRow(t); expect(row?.currentTier).toBe(2); expect(row?.lastWaveBroadcastId).toBe("bc-persisted"); expect(row?.lastWaveSegmentId).toBe("seg-persisted"); expect(row?.lastWaveAssigned).toBe(1500); expect(row?.lastWaveLabel).toBe("wave-5"); expect(row?.lastWaveSentAt).toBe(1700000000000); // ALL pending* cleared. expect(row?.pendingWaveLabel).toBeUndefined(); expect(row?.pendingSegmentId).toBeUndefined(); expect(row?.pendingAssigned).toBeUndefined(); expect(row?.pendingExportAt).toBeUndefined(); expect(row?.pendingBroadcastId).toBeUndefined(); expect(row?.pendingBroadcastAt).toBeUndefined(); expect(row?.pendingRunId).toBeUndefined(); }); test("manual-finished: operator override beats persisted fallback when both supplied", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, lastRunStatus: "partial-failure", pendingBroadcastId: "bc-persisted", pendingSegmentId: "seg-persisted", pendingAssigned: 1500, pendingWaveLabel: "wave-5", }); const result = await t.mutation( internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "manual-finished", reason: "broadcast was re-created with a different id during manual recovery", broadcastId: "bc-OVERRIDE", segmentId: "seg-OVERRIDE", assigned: 1234, waveLabel: "wave-5-retry", sentAt: 1700000000000, }, ); expect(result.broadcastId).toBe("bc-OVERRIDE"); expect(result.segmentId).toBe("seg-OVERRIDE"); expect(result.assigned).toBe(1234); expect(result.waveLabel).toBe("wave-5-retry"); expect(result.usedPersistedFallback?.broadcastId).toBe(false); expect(result.usedPersistedFallback?.segmentId).toBe(false); expect(result.usedPersistedFallback?.assigned).toBe(false); expect(result.usedPersistedFallback?.waveLabel).toBe(false); }); test("manual-finished: rejects when sentAt is omitted even with full persisted fallback", async () => { // sentAt is operator-only — no progress marker captures send-completion time. const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "partial-failure", pendingWaveLabel: "wave-5", pendingSegmentId: "seg-persisted", pendingAssigned: 1500, pendingBroadcastId: "bc-persisted", }); await expect( t.mutation(internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "manual-finished", reason: "test", // sentAt omitted, all others fall back }), ).rejects.toThrow(/missing required field.*sentAt/i); }); test("discard-and-rotate: bumps waveLabelOffset + clears ALL pending* state", async () => { // Without offset bump, next cron retries the SAME waveLabel and // assignAndExportWave rejects because contacts are already stamped. // P1#4: also verify pending* are cleared so they don't leak into a // future recovery surface. const t = convexTest(schema, modules); await seedRampConfig(t, { currentTier: 1, waveLabelOffset: 3, // current next would be wave-(2+3)=wave-5 lastRunStatus: "partial-failure", pendingRunId: "run-stuck", pendingRunStartedAt: Date.now(), pendingWaveLabel: "wave-5", pendingSegmentId: "seg-stamped", pendingAssigned: 1500, pendingBroadcastId: "bc-stamped", }); const result = await t.mutation( internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "discard-and-rotate", reason: "wave-5 is unrecoverable; discarding the stamped batch", }, ); expect(result.ok).toBe(true); expect(result.recovery).toBe("discard-and-rotate"); expect(result.newWaveLabelOffset).toBe(4); expect(result.nextWaveLabel).toBe("wave-6"); const row = await loadRow(t); expect(row?.waveLabelOffset).toBe(4); // Tier NOT advanced — we never sent. expect(row?.currentTier).toBe(1); expect(row?.lastRunStatus).toMatch(/partial-failure-discarded-rotated/); expect(row?.pendingRunId).toBeUndefined(); // All pending* cleared. expect(row?.pendingWaveLabel).toBeUndefined(); expect(row?.pendingSegmentId).toBeUndefined(); expect(row?.pendingAssigned).toBeUndefined(); expect(row?.pendingBroadcastId).toBeUndefined(); }); test("noop when status is not partial-failure", async () => { const t = convexTest(schema, modules); await seedRampConfig(t, { lastRunStatus: "succeeded" }); const result = await t.mutation( internal.broadcast.rampRunner.recoverFromPartialFailure, { recovery: "discard-and-rotate", reason: "operator confused, no actual partial-failure", }, ); expect(result.noop).toBe(true); expect(result.currentStatus).toBe("succeeded"); }); }); // ---------------------------------------------------------------------------- // End-to-end: the race scenario the lease prevents // ---------------------------------------------------------------------------- describe("end-to-end: lease prevents duplicate-send race", () => { test("first claim wins; second is rejected without ANY side effect path being taken", async () => { // This is the scenario reviewer flagged: two concurrent runs both pass the // kill-gate / tier-bounds checks above, both attempt to claim. With the // lease, only ONE wins. The other returns claim-rejected and the runner // exits before assignAndExportWave / createProLaunchBroadcast / sendProLaunchBroadcast // are ever called. const t = convexTest(schema, modules); await seedRampConfig(t); const claimA = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-A", expectedCurrentTier: 0, }); const claimB = await t.mutation(internal.broadcast.rampRunner._claimTierForRun, { runId: "run-B", expectedCurrentTier: 0, }); // Exactly one wins. expect([claimA.ok, claimB.ok].filter(Boolean).length).toBe(1); // The winner can record success and clear the lease. const winner = claimA.ok ? "run-A" : "run-B"; await t.mutation(internal.broadcast.rampRunner._recordWaveSent, { runId: winner, expectedCurrentTier: 0, newTier: 1, waveLabel: "wave-3", broadcastId: "bc-1", segmentId: "seg-1", assigned: 500, sentAt: Date.now(), }); // After success: lease cleared, tier advanced. const row = await loadRow(t); expect(row?.currentTier).toBe(1); expect(row?.pendingRunId).toBeUndefined(); }); }); // ─────────────────────────────────────────────────────────────────────────── // PR2 review-fix 7: runDailyRamp checks pendingRunId before scheduling // ─────────────────────────────────────────────────────────────────────────── describe("runDailyRamp — pendingRunId guard (PR2 review-fix 7)", () => { test("refuses to schedule pickWaveAction when broadcastRampConfig.pendingRunId is held by a failed run", async () => { // Scenario: a wave hit `persist-failed` (or `segment-create-failed` / // `batch-failure-rate-exceeded`) — status='failed' but lease HELD. // _listInFlightWaveRuns excludes failed runs, so without this guard // runDailyRamp would schedule a new pickWaveAction whose // _claimWaveRunLease would refuse with `lease-held` — operator sees // wave-scheduled but ramp is actually blocked. const t = convexTest(schema, modules); await seedRampConfig(t, { pendingRunId: "stuck-run-1", pendingRunStartedAt: Date.now() - 5 * 60_000, pendingWaveLabel: "pro-launch-wave-4", }); // Insert a corresponding waveRuns row in failed/persist-failed. await t.run(async (ctx) => { await ctx.db.insert("waveRuns", { runId: "stuck-run-1", waveLabel: "pro-launch-wave-4", status: "failed", failureSubstatus: "persist-failed", requestedCount: 100, totalCount: 0, underfilled: false, pushedCount: 0, failedCount: 0, batchSize: 50, createdAt: Date.now() - 5 * 60_000, updatedAt: Date.now() - 5 * 60_000, }); }); const result = await t.action( internal.broadcast.rampRunner.runDailyRamp, {}, ); // Must surface the lease-held condition, NOT report wave-scheduled. expect(result).toMatchObject({ status: "lease-held-by-failed-run", }); expect(result.detail).toContain("stuck-run-1"); expect(result.detail).toContain("persist-failed"); // Drain any incidentally scheduled functions to silence convex-test's // scheduler-dispatch artifact. await t.finishInProgressScheduledFunctions().catch(() => {}); }); test("schedules pickWaveAction normally when no lease is held", async () => { // Sanity: the pendingRunId guard should be a no-op on a clean ramp. const t = convexTest(schema, modules); await seedRampConfig(t); const result = await t.action( internal.broadcast.rampRunner.runDailyRamp, {}, ); expect(result.status).toBe("wave-scheduled"); await t.finishInProgressScheduledFunctions().catch(() => {}); }); }); // `excludeNonEnglish` opt-in tests live in // `convex/__tests__/rampRunner-excludeNonEnglish.test.ts` to isolate from // this file's scheduler tests, which leak `_scheduled_functions` patches // across the test boundary once the file accumulates enough tests.