Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/jobs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"test": "vitest run"
},
"dependencies": {
"@marble/db": "workspace:*",
Expand All @@ -20,6 +21,7 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260508.1",
"vitest": "^3.2.4",
"wrangler": "^4.90.0"
}
}
126 changes: 126 additions & 0 deletions apps/jobs/src/consumers/deliveries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("@marble/events", () => ({
buildWebhookPayload: vi.fn(),
serializeEventType: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ createDbClient: vi.fn() }));
vi.mock("@/lib/formats", () => ({ buildWebhookRequestBody: vi.fn() }));
vi.mock("@/lib/signing", () => ({ signPayload: vi.fn() }));
vi.mock("@/lib/usage", () => ({
checkWebhookUsage: vi.fn(),
recordWebhookUsage: vi.fn(),
sendWebhookUsageAlert: vi.fn(),
}));

import { WEBHOOK_DELIVERY_STALE_SENDING_MS } from "@/lib/constants";
import { claimWebhookDeliveryAttempt } from "./deliveries";

function createDb({
activeClaimCount,
staleClaimCount,
status,
}: {
activeClaimCount: number;
staleClaimCount: number;
status?: string;
}) {
const updateMany = vi
.fn()
.mockResolvedValueOnce({ count: activeClaimCount })
.mockResolvedValueOnce({ count: staleClaimCount });
const findUnique = vi
.fn()
.mockResolvedValue(status === undefined ? null : { status });

return {
webhookDelivery: {
updateMany,
findUnique,
},
};
}

describe("claimWebhookDeliveryAttempt", () => {
it("claims pending or retrying deliveries for a new attempt", async () => {
const now = new Date("2026-06-30T12:00:00.000Z");
const db = createDb({ activeClaimCount: 1, staleClaimCount: 0 });

await expect(
claimWebhookDeliveryAttempt(db as never, "delivery_1", now)
).resolves.toBe(true);

expect(db.webhookDelivery.updateMany).toHaveBeenCalledTimes(1);
expect(db.webhookDelivery.updateMany).toHaveBeenCalledWith({
where: {
id: "delivery_1",
status: { in: ["pending", "retrying"] },
},
data: {
status: "sending",
attemptCount: { increment: 1 },
lastAttemptAt: now,
},
});
expect(db.webhookDelivery.findUnique).not.toHaveBeenCalled();
});

it("reclaims stale sending deliveries instead of acknowledging them", async () => {
const now = new Date("2026-06-30T12:00:00.000Z");
const db = createDb({ activeClaimCount: 0, staleClaimCount: 1 });

await expect(
claimWebhookDeliveryAttempt(db as never, "delivery_1", now)
).resolves.toBe(true);

expect(db.webhookDelivery.updateMany).toHaveBeenCalledTimes(2);
expect(db.webhookDelivery.updateMany).toHaveBeenNthCalledWith(2, {
where: {
id: "delivery_1",
status: "sending",
OR: [
{
lastAttemptAt: {
lt: new Date(
now.getTime() - WEBHOOK_DELIVERY_STALE_SENDING_MS
),
},
},
{ lastAttemptAt: null },
],
},
data: {
status: "sending",
attemptCount: { increment: 1 },
lastAttemptAt: now,
},
});
expect(db.webhookDelivery.findUnique).not.toHaveBeenCalled();
});

it("keeps active sending deliveries on the queue retry path", async () => {
const now = new Date("2026-06-30T12:00:00.000Z");
const db = createDb({
activeClaimCount: 0,
staleClaimCount: 0,
status: "sending",
});

await expect(
claimWebhookDeliveryAttempt(db as never, "delivery_1", now)
).rejects.toThrow("Webhook delivery delivery_1 is already sending");
});

it("skips terminal or missing deliveries", async () => {
const now = new Date("2026-06-30T12:00:00.000Z");
const db = createDb({
activeClaimCount: 0,
staleClaimCount: 0,
status: "success",
});

await expect(
claimWebhookDeliveryAttempt(db as never, "delivery_1", now)
).resolves.toBe(false);
});
});
72 changes: 59 additions & 13 deletions apps/jobs/src/consumers/deliveries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { buildWebhookPayload, serializeEventType } from "@marble/events";
import { WEBHOOK_DELIVERY_TIMEOUT_MS } from "@/lib/constants";
import {
WEBHOOK_DELIVERY_STALE_SENDING_MS,
WEBHOOK_DELIVERY_TIMEOUT_MS,
} from "@/lib/constants";
import { createDbClient } from "@/lib/db";
import { buildWebhookRequestBody } from "@/lib/formats";
import { signPayload } from "@/lib/signing";
Expand Down Expand Up @@ -37,19 +40,9 @@ async function processDelivery(
env: Env,
deliveryId: string
) {
const claim = await db.webhookDelivery.updateMany({
where: {
id: deliveryId,
status: { in: ["pending", "retrying"] },
},
data: {
status: "sending",
attemptCount: { increment: 1 },
lastAttemptAt: new Date(),
},
});
const claimed = await claimWebhookDeliveryAttempt(db, deliveryId);

if (claim.count === 0) {
if (!claimed) {
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Fence reclaimed attempts before any later delivery-state write.

After a stale reclaim, two workers can exist for the same delivery. The helper only returns boolean, so the later writes at Line 81, Line 136, Line 146, Line 188, Line 199, and Line 209 are still keyed only by id. If the original worker resumes after the reclaim, it can overwrite the newer attempt's success/failed/retrying state.

Please return claim metadata from claimWebhookDeliveryAttempt (for example the lastAttemptAt written during the claim, or a dedicated lease token) and require every later webhookDelivery.update* in this attempt to match that token in where before mutating the row again.

Possible direction
-  const claimed = await claimWebhookDeliveryAttempt(db, deliveryId);
-  if (!claimed) {
+  const claim = await claimWebhookDeliveryAttempt(db, deliveryId);
+  if (!claim) {
     return;
   }
-export async function claimWebhookDeliveryAttempt(...)
+export async function claimWebhookDeliveryAttempt(...): Promise<{ claimedAt: Date } | null>
...
-    return true;
+    return { claimedAt: now };
...
-    return true;
+    return { claimedAt: now };
...
-  return false;
+  return null;

Then switch the later status transitions to conditional updateMany(...) calls such as where: { id: delivery.id, lastAttemptAt: claim.claimedAt } so a reclaimed worker cannot clobber the current owner.

Also applies to: 217-268

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/jobs/src/consumers/deliveries.ts` around lines 43 - 45, The delivery
claim flow in claimWebhookDeliveryAttempt and the later webhookDelivery.update*
status transitions need a lease check, not just a boolean claim result. Return
claim metadata such as lastAttemptAt or a dedicated token from
claimWebhookDeliveryAttempt, then require every subsequent state write in
deliveries.ts to include that same token in its where clause before updating the
row. Update the later success/failed/retrying writes to conditional
updateMany-style mutations so a reclaimed worker cannot overwrite the current
attempt’s state.

return;
}

Expand Down Expand Up @@ -220,3 +213,56 @@ async function processDelivery(

throw new Error(`Webhook returned ${response.status}`);
}

export async function claimWebhookDeliveryAttempt(
db: ReturnType<typeof createDbClient>,
deliveryId: string,
now = new Date()
) {
const claim = await db.webhookDelivery.updateMany({
where: {
id: deliveryId,
status: { in: ["pending", "retrying"] },
},
data: {
status: "sending",
attemptCount: { increment: 1 },
lastAttemptAt: now,
},
});

if (claim.count > 0) {
return true;
}

const staleCutoff = new Date(
now.getTime() - WEBHOOK_DELIVERY_STALE_SENDING_MS
);
const staleClaim = await db.webhookDelivery.updateMany({
where: {
id: deliveryId,
status: "sending",
OR: [{ lastAttemptAt: { lt: staleCutoff } }, { lastAttemptAt: null }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit stale claims to remaining attempts

When a worker is interrupted after claiming the last allowed attempt, the row is left sending with attemptCount === maxAttempts; this stale predicate still matches it, then the retry increments attemptCount again and processDelivery only checks attemptNumber >= maxAttempts after making the POST. In that recovery case the customer endpoint can receive a fourth request for the default maxAttempts = 3 (possibly duplicating a request that already reached them), so the stale-reclaim path should guard on remaining attempts or make the delivery terminal before sending again.

Useful? React with 👍 / 👎.

},
data: {
status: "sending",
attemptCount: { increment: 1 },
lastAttemptAt: now,
},
});

if (staleClaim.count > 0) {
return true;
}

const delivery = await db.webhookDelivery.findUnique({
where: { id: deliveryId },
select: { status: true },
});

if (delivery?.status === "sending") {
throw new Error(`Webhook delivery ${deliveryId} is already sending`);
}

return false;
}
2 changes: 2 additions & 0 deletions apps/jobs/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const IMPORT_JOB_RETENTION_DAYS = 30;
export const IMPORT_STALE_JOB_DAYS = 1;
export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
export const WEBHOOK_DELIVERY_TIMEOUT_MS = 15_000;
export const WEBHOOK_DELIVERY_STALE_SENDING_MS =
WEBHOOK_DELIVERY_TIMEOUT_MS * 2;
// The dashboard currently renders a 30-day chart plus the previous 30-day
// comparison from raw API request rows.
export const API_REQUEST_RETENTION_DAYS = 60;
Expand Down
16 changes: 16 additions & 0 deletions apps/jobs/vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

const root = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
resolve: {
alias: {
"@": resolve(root, "src"),
},
},
test: {
include: ["src/**/*.test.ts"],
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.