Skip to content
Closed
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
28 changes: 22 additions & 6 deletions src/orb/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,30 @@ async function finalizeRelayFailureRetryRow(
row: { delivery_id: string; event_name: string; installation_id: number },
outcome: RelayForwardOutcome,
): Promise<void> {
if (isRelayFailureRetryTerminal(outcome, row.event_name)) {
await env.DB.prepare("DELETE FROM orb_relay_failures WHERE delivery_id = ?").bind(row.delivery_id).run();
try {
if (isRelayFailureRetryTerminal(outcome, row.event_name)) {
await env.DB.prepare("DELETE FROM orb_relay_failures WHERE delivery_id = ?").bind(row.delivery_id).run();
return;
}
await env.DB
.prepare("UPDATE orb_relay_failures SET attempts = attempts + 1, last_attempt_at = datetime('now') WHERE delivery_id = ?")
.bind(row.delivery_id)
.run();
} catch (error) {
// The forward already succeeded (outcome is known) before this write failed -- the row stays pending retry, so
// the SAME event risks redelivery to the container on the next retry tick. Never throw (retryFailedRelays's
// "Never throws" contract): log with enough context to spot the duplicate-forward risk from the log alone.
console.error(JSON.stringify({
level: "error",
event: "orb_relay_failure_finalize_write_failed",
message: `finalizeRelayFailureRetryRow DB write failed after a successful forward -- duplicate redelivery risk for ${row.delivery_id}`,
deliveryId: row.delivery_id,
eventName: row.event_name,
outcome,
error: error instanceof Error ? error.message : String(error),
}));
return;
}
await env.DB
.prepare("UPDATE orb_relay_failures SET attempts = attempts + 1, last_attempt_at = datetime('now') WHERE delivery_id = ?")
.bind(row.delivery_id)
.run();
if (outcome === "skipped") {
logRelayTransientSkip({
deliveryId: row.delivery_id,
Expand Down
50 changes: 50 additions & 0 deletions test/integration/orb-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,56 @@ describe("retryFailedRelays", () => {
expect(row ?? null).toBeNull(); // row removed on success
});

it("does not throw when the finalize DELETE fails right after a successful forward (#8332 duplicate-redelivery guard)", async () => {
const e = brokeredEnv();
const secret = await enroll(e, 9700);
await registerOrbRelay(e, secret, "https://c.example/v1/orb/relay");
await storeRelayFailure(e, { deliveryId: "finalize-delete-fail", eventName: "pull_request", installationId: 9700, rawBody: "{}" });
const realPrepare = db(e).prepare.bind(db(e));
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
e.DB = {
prepare: (sql: string) =>
sql === "DELETE FROM orb_relay_failures WHERE delivery_id = ?"
? { bind: () => ({ run: async () => { throw new Error("simulated D1 write failure"); } }) }
: realPrepare(sql),
} as unknown as Env["DB"];
const fetchOk = (() => Promise.resolve(new Response("ok", { status: 200 }))) as typeof fetch;
await expect(retryFailedRelays(e, { fetchImpl: fetchOk })).resolves.toBeUndefined(); // "Never throws" contract holds
expect(
errorLog.mock.calls.some(
([line]) =>
String(line).includes("orb_relay_failure_finalize_write_failed") &&
String(line).includes("finalize-delete-fail") &&
String(line).includes("forwarded"),
),
).toBe(true);
});

it("does not throw when the finalize UPDATE fails right after a non-terminal forward (#8332 duplicate-redelivery guard)", async () => {
const e = brokeredEnv();
const secret = await enroll(e, 9701);
await registerOrbRelay(e, secret, "https://c.example/v1/orb/relay");
await storeRelayFailure(e, { deliveryId: "finalize-update-fail", eventName: "pull_request", installationId: 9701, rawBody: "{}" });
const realPrepare = db(e).prepare.bind(db(e));
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
e.DB = {
prepare: (sql: string) =>
sql === "UPDATE orb_relay_failures SET attempts = attempts + 1, last_attempt_at = datetime('now') WHERE delivery_id = ?"
? { bind: () => ({ run: async () => { throw new Error("simulated D1 write failure"); } }) }
: realPrepare(sql),
} as unknown as Env["DB"];
const fetchFail = (() => Promise.resolve(new Response("bad", { status: 503 }))) as typeof fetch;
await expect(retryFailedRelays(e, { fetchImpl: fetchFail })).resolves.toBeUndefined(); // "Never throws" contract holds
expect(
errorLog.mock.calls.some(
([line]) =>
String(line).includes("orb_relay_failure_finalize_write_failed") &&
String(line).includes("finalize-update-fail") &&
String(line).includes("failed"),
),
).toBe(true);
});

it("INCREMENTS attempts when forwardOrbEvent still fails", async () => {
const e = brokeredEnv();
const secret = await enroll(e, 9101);
Expand Down