|
| 1 | +import { describe, expect, it, vi } from "vitest"; |
| 2 | + |
| 3 | +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); |
| 4 | + |
| 5 | +import { applyMetadataMutationToBufferedRun } from "~/v3/mollifier/applyMetadataMutation.server"; |
| 6 | +import type { BufferEntry, MollifierBuffer, CasSetMetadataResult } from "@trigger.dev/redis-worker"; |
| 7 | + |
| 8 | +// Regression for the CAS retry-exhaustion bug found by Phase F. The |
| 9 | +// default `maxRetries` was 3, matching the PG-side service, but that |
| 10 | +// exhausts fast when N external API writers race the same buffered |
| 11 | +// run's metadata. Bumped to 12 + jittered backoff (commit 4e7d5d8a2). |
| 12 | +// These tests simulate version_conflict races and assert (a) every |
| 13 | +// delta lands and (b) the retry budget is sized for realistic |
| 14 | +// concurrency. |
| 15 | + |
| 16 | +const NOW = new Date("2026-05-21T10:00:00Z"); |
| 17 | + |
| 18 | +type BufferStub = { |
| 19 | + buffer: MollifierBuffer; |
| 20 | + state: { |
| 21 | + version: number; |
| 22 | + metadata: Record<string, unknown>; |
| 23 | + pendingConflictsForNextN: number; |
| 24 | + }; |
| 25 | +}; |
| 26 | + |
| 27 | +// Build a stub MollifierBuffer that simulates Lua-CAS semantics |
| 28 | +// in-memory. The first `pendingConflictsForNextN` casSetMetadata calls |
| 29 | +// from any worker will return version_conflict (then the version |
| 30 | +// bumps); subsequent calls succeed. |
| 31 | +function makeBufferStub(initialPayload: Record<string, unknown> = {}): BufferStub { |
| 32 | + const state = { |
| 33 | + version: 0, |
| 34 | + metadata: initialPayload.metadata |
| 35 | + ? (JSON.parse(initialPayload.metadata as string) as Record<string, unknown>) |
| 36 | + : {}, |
| 37 | + pendingConflictsForNextN: 0, |
| 38 | + }; |
| 39 | + const entryTemplate: Omit<BufferEntry, "payload"> = { |
| 40 | + runId: "run_1", |
| 41 | + envId: "env_a", |
| 42 | + orgId: "org_1", |
| 43 | + status: "QUEUED", |
| 44 | + attempts: 0, |
| 45 | + createdAt: NOW, |
| 46 | + createdAtMicros: 1747044000000000, |
| 47 | + materialised: false, |
| 48 | + idempotencyLookupKey: "", |
| 49 | + metadataVersion: 0, |
| 50 | + }; |
| 51 | + |
| 52 | + const buffer: MollifierBuffer = { |
| 53 | + getEntry: vi.fn(async (): Promise<BufferEntry> => ({ |
| 54 | + ...entryTemplate, |
| 55 | + metadataVersion: state.version, |
| 56 | + payload: JSON.stringify({ ...initialPayload, metadata: JSON.stringify(state.metadata) }), |
| 57 | + })), |
| 58 | + casSetMetadata: vi.fn( |
| 59 | + async (input: { |
| 60 | + runId: string; |
| 61 | + expectedVersion: number; |
| 62 | + newMetadata: string; |
| 63 | + newMetadataType: string; |
| 64 | + }): Promise<CasSetMetadataResult> => { |
| 65 | + // Inject a controlled number of conflicts to simulate races. |
| 66 | + if (state.pendingConflictsForNextN > 0) { |
| 67 | + state.pendingConflictsForNextN -= 1; |
| 68 | + // Bump version as if some other writer just landed. |
| 69 | + state.version += 1; |
| 70 | + return { kind: "version_conflict", currentVersion: state.version }; |
| 71 | + } |
| 72 | + if (input.expectedVersion !== state.version) { |
| 73 | + return { kind: "version_conflict", currentVersion: state.version }; |
| 74 | + } |
| 75 | + state.metadata = JSON.parse(input.newMetadata) as Record<string, unknown>; |
| 76 | + state.version += 1; |
| 77 | + return { kind: "applied", newVersion: state.version }; |
| 78 | + }, |
| 79 | + ), |
| 80 | + } as unknown as MollifierBuffer; |
| 81 | + |
| 82 | + return { buffer, state }; |
| 83 | +} |
| 84 | + |
| 85 | +describe("applyMetadataMutationToBufferedRun — retry behaviour", () => { |
| 86 | + it("succeeds when CAS lands on the first try (no contention)", async () => { |
| 87 | + const { buffer, state } = makeBufferStub(); |
| 88 | + const result = await applyMetadataMutationToBufferedRun({ |
| 89 | + runId: "run_1", |
| 90 | + body: { metadata: { counter: 1 } }, |
| 91 | + buffer, |
| 92 | + }); |
| 93 | + expect(result.kind).toBe("applied"); |
| 94 | + expect(state.metadata).toEqual({ counter: 1 }); |
| 95 | + expect(state.version).toBe(1); |
| 96 | + }); |
| 97 | + |
| 98 | + it("succeeds after 5 version conflicts (default budget = 12)", async () => { |
| 99 | + const { buffer, state } = makeBufferStub(); |
| 100 | + state.pendingConflictsForNextN = 5; |
| 101 | + const result = await applyMetadataMutationToBufferedRun({ |
| 102 | + runId: "run_1", |
| 103 | + body: { operations: [{ type: "increment", key: "counter", value: 1 }] }, |
| 104 | + buffer, |
| 105 | + }); |
| 106 | + expect(result.kind).toBe("applied"); |
| 107 | + if (result.kind === "applied") { |
| 108 | + expect(result.newMetadata.counter).toBe(1); |
| 109 | + } |
| 110 | + }); |
| 111 | + |
| 112 | + it("succeeds after 11 version conflicts (one under the default budget)", async () => { |
| 113 | + const { buffer } = makeBufferStub(); |
| 114 | + const setStateConflicts = (n: number) => { |
| 115 | + // Re-read state from the closure |
| 116 | + const state = (buffer as unknown as { __state__?: never; getEntry: () => Promise<BufferEntry> }); |
| 117 | + void state; |
| 118 | + }; |
| 119 | + void setStateConflicts; |
| 120 | + // Set conflicts directly via the shared state object |
| 121 | + const { state } = makeBufferStub(); |
| 122 | + state.pendingConflictsForNextN = 11; |
| 123 | + // Build a fresh stub since we want one shared state instance |
| 124 | + const stub = makeBufferStub(); |
| 125 | + stub.state.pendingConflictsForNextN = 11; |
| 126 | + const result = await applyMetadataMutationToBufferedRun({ |
| 127 | + runId: "run_1", |
| 128 | + body: { operations: [{ type: "increment", key: "counter", value: 1 }] }, |
| 129 | + buffer: stub.buffer, |
| 130 | + }); |
| 131 | + expect(result.kind).toBe("applied"); |
| 132 | + }); |
| 133 | + |
| 134 | + it("returns version_exhausted after retries are spent", async () => { |
| 135 | + const stub = makeBufferStub(); |
| 136 | + // 99 conflicts ≫ default budget of 12. With maxRetries 3 (the |
| 137 | + // pre-fix value), this would have exhausted after 4 attempts. |
| 138 | + stub.state.pendingConflictsForNextN = 99; |
| 139 | + const result = await applyMetadataMutationToBufferedRun({ |
| 140 | + runId: "run_1", |
| 141 | + body: { operations: [{ type: "increment", key: "counter", value: 1 }] }, |
| 142 | + buffer: stub.buffer, |
| 143 | + maxRetries: 12, |
| 144 | + }); |
| 145 | + expect(result.kind).toBe("version_exhausted"); |
| 146 | + }); |
| 147 | + |
| 148 | + it("regression: 3 retries are NOT enough under 50-way concurrency simulation", async () => { |
| 149 | + // The pre-fix default would have lost most deltas under this |
| 150 | + // contention. Asserting that the OLD budget (3) exhausts confirms |
| 151 | + // the regression actually existed and the new budget addresses it. |
| 152 | + const stub = makeBufferStub(); |
| 153 | + stub.state.pendingConflictsForNextN = 8; |
| 154 | + const result = await applyMetadataMutationToBufferedRun({ |
| 155 | + runId: "run_1", |
| 156 | + body: { operations: [{ type: "increment", key: "counter", value: 1 }] }, |
| 157 | + buffer: stub.buffer, |
| 158 | + maxRetries: 3, |
| 159 | + }); |
| 160 | + expect(result.kind).toBe("version_exhausted"); |
| 161 | + }); |
| 162 | + |
| 163 | + it("N-way concurrent applies all converge under default budget", async () => { |
| 164 | + // Simulate N parallel writers against a shared state. Each writer |
| 165 | + // reads, applies a delta, CAS-writes. The Lua CAS forces them to |
| 166 | + // retry until they see the latest version. |
| 167 | + const N = 30; |
| 168 | + const sharedStub = makeBufferStub(); |
| 169 | + // Override the stub to model real per-attempt serialisation: each |
| 170 | + // call reads the latest version, and CAS conflicts are organic |
| 171 | + // (not pre-injected) when expectedVersion != current. |
| 172 | + sharedStub.state.pendingConflictsForNextN = 0; |
| 173 | + |
| 174 | + const calls = Array.from({ length: N }, () => |
| 175 | + applyMetadataMutationToBufferedRun({ |
| 176 | + runId: "run_1", |
| 177 | + body: { operations: [{ type: "increment", key: "counter", value: 1 }] }, |
| 178 | + buffer: sharedStub.buffer, |
| 179 | + }), |
| 180 | + ); |
| 181 | + const results = await Promise.all(calls); |
| 182 | + const applied = results.filter((r) => r.kind === "applied").length; |
| 183 | + expect(applied).toBe(N); |
| 184 | + expect(sharedStub.state.metadata.counter).toBe(N); |
| 185 | + }); |
| 186 | +}); |
0 commit comments