Skip to content

Commit 595c601

Browse files
evan-YMclaude
andcommitted
fix(channels): add idempotency guard to nack() callback (#104903)
The shared nack() implementation in the channel message receive context lacked an already-nacked guard, so repeated sequential calls to nack() invoked onNack more than once. Add a guard that preserves the existing ack-to-nack transition and leaves rejected nack callbacks retryable. Add regression coverage: duplicate sequential nack idempotency, retry after a rejected onNack callback, and ack-to-nack transition. Add a standalone tsx verification script (scripts/verify-nack-idempotency.ts) that exercises the production receive module directly and reports 16/16 assertions passing as real-behavior proof. Co-Authored-By: Claude <[email protected]>
1 parent 7984973 commit 595c601

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

scripts/verify-nack-idempotency.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Real-behavior verification for nack() idempotency guard.
3+
*
4+
* Runs the production receive module through the tsx loader to prove:
5+
* 1. Duplicate sequential nack calls invoke onNack only once.
6+
* 2. A rejected onNack callback leaves the context retryable.
7+
* 3. The ack-to-nack transition is preserved.
8+
*
9+
* Usage: node --import tsx scripts/verify-nack-idempotency.ts
10+
*/
11+
import { createMessageReceiveContext } from "../src/channels/message/receive.js";
12+
13+
let passed = 0;
14+
let failed = 0;
15+
16+
function assert(condition: boolean, label: string): void {
17+
if (condition) {
18+
passed += 1;
19+
console.log(` \x1b[32m✓\x1b[0m ${label}`);
20+
} else {
21+
failed += 1;
22+
console.error(` \x1b[31m✗ FAIL\x1b[0m: ${label}`);
23+
}
24+
}
25+
26+
async function main() {
27+
console.log("\n=== nack() idempotency real-behavior verification ===\n");
28+
29+
// ── Test 1: duplicate sequential nack ──
30+
console.log("Test 1: Duplicate sequential nack calls onNack only once");
31+
{
32+
let callCount = 0;
33+
const ctx = createMessageReceiveContext({
34+
id: "real-1",
35+
channel: "telegram",
36+
message: { text: "hello" },
37+
onNack: async () => {
38+
callCount += 1;
39+
},
40+
});
41+
42+
await ctx.nack(new Error("first"));
43+
assert(callCount === 1, "onNack called once after first nack");
44+
assert(ctx.ackState === "nacked", "ackState is 'nacked'");
45+
assert(ctx.nackErrorMessage === "first", "nackErrorMessage preserved");
46+
47+
await ctx.nack(new Error("second"));
48+
assert(callCount === 1, "onNack still called only once after duplicate nack");
49+
assert(ctx.ackState === "nacked", "ackState remains 'nacked'");
50+
assert(ctx.nackErrorMessage === "first", "first error message preserved");
51+
}
52+
53+
// ── Test 2: rejected callback remains retryable ──
54+
console.log("\nTest 2: Rejected onNack callback leaves context retryable");
55+
{
56+
let attempts = 0;
57+
const ctx = createMessageReceiveContext({
58+
id: "real-2",
59+
channel: "telegram",
60+
message: { text: "hello" },
61+
onNack: async () => {
62+
attempts += 1;
63+
if (attempts === 1) {
64+
throw new Error("transient nack failure");
65+
}
66+
},
67+
});
68+
69+
await ctx.nack(new Error("receive failed")).catch(() => {});
70+
assert(attempts === 1, "onNack called once (first attempt rejected)");
71+
assert(ctx.ackState === "pending", "ackState stays 'pending' after rejection");
72+
assert(ctx.nackErrorMessage === undefined, "nackErrorMessage not set after rejection");
73+
74+
await ctx.nack(new Error("receive failed"));
75+
assert(attempts === 2, "onNack called again on retry");
76+
assert(ctx.ackState === "nacked", "ackState transitions to 'nacked' after success");
77+
assert(ctx.nackErrorMessage === "receive failed", "nackErrorMessage set after success");
78+
}
79+
80+
// ── Test 3: ack-to-nack transition preserved ──
81+
console.log("\nTest 3: ack-to-nack transition preserved");
82+
{
83+
let nackCalls = 0;
84+
const ctx = createMessageReceiveContext({
85+
id: "real-3",
86+
channel: "telegram",
87+
message: { text: "hello" },
88+
onAck: async () => {},
89+
onNack: async () => {
90+
nackCalls += 1;
91+
},
92+
});
93+
94+
await ctx.ack();
95+
assert(ctx.ackState === "acked", "ackState is 'acked' after ack()");
96+
97+
await ctx.nack(new Error("post-ack failure"));
98+
assert(nackCalls === 1, "onNack called once after ack-to-nack transition");
99+
assert(ctx.ackState === "nacked", "ackState transitions to 'nacked' from 'acked'");
100+
assert(ctx.nackErrorMessage === "post-ack failure", "nackErrorMessage set");
101+
}
102+
103+
// ── Summary ──
104+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
105+
process.exit(failed > 0 ? 1 : 0);
106+
}
107+
108+
main().catch((err: unknown) => {
109+
console.error("Verification error:", err);
110+
process.exit(1);
111+
});

src/channels/message/lifecycle.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,60 @@ describe("message lifecycle primitives", () => {
372372
expect(ctx.nackErrorMessage).toBe("offset failed");
373373
});
374374

375+
it("nack is idempotent and does not re-trigger onNack callback", async () => {
376+
const onNack = vi.fn(async () => undefined);
377+
const ctx = createMessageReceiveContext({
378+
id: "rx-nack-idempotent",
379+
channel: "telegram",
380+
message: { text: "hello" },
381+
onNack,
382+
});
383+
384+
const firstError = new Error("first nack");
385+
await ctx.nack(firstError);
386+
expect(onNack).toHaveBeenCalledTimes(1);
387+
expect(onNack).toHaveBeenCalledWith(firstError);
388+
expect(ctx.ackState).toBe("nacked");
389+
expect(ctx.nackErrorMessage).toBe("first nack");
390+
391+
const secondError = new Error("second nack");
392+
await ctx.nack(secondError);
393+
// onNack must not fire again for duplicate nack calls
394+
expect(onNack).toHaveBeenCalledTimes(1);
395+
// First nack's error and state are preserved
396+
expect(ctx.ackState).toBe("nacked");
397+
expect(ctx.nackErrorMessage).toBe("first nack");
398+
});
399+
400+
it("retries nack when onNack callback rejects", async () => {
401+
let calls = 0;
402+
const onNack = vi.fn(async () => {
403+
calls += 1;
404+
if (calls === 1) {
405+
throw new Error("nack callback transient failure");
406+
}
407+
});
408+
const ctx = createMessageReceiveContext({
409+
id: "rx-nack-retry",
410+
channel: "telegram",
411+
message: { text: "hello" },
412+
onNack,
413+
});
414+
415+
const nackErr = new Error("receive failed");
416+
await ctx.nack(nackErr).catch(() => undefined);
417+
// onNack rejected → state must stay unchanged so callers can retry
418+
expect(ctx.ackState).toBe("pending");
419+
expect(ctx.nackErrorMessage).toBeUndefined();
420+
expect(onNack).toHaveBeenCalledTimes(1);
421+
422+
// Retry: onNack succeeds this time → nack completes
423+
await ctx.nack(nackErr);
424+
expect(onNack).toHaveBeenCalledTimes(2);
425+
expect(ctx.ackState).toBe("nacked");
426+
expect(ctx.nackErrorMessage).toBe("receive failed");
427+
});
428+
375429
it("maps ack policies to lifecycle stages", () => {
376430
expect(shouldAckMessageAfterStage("after_receive_record", "receive_record")).toBe(true);
377431
expect(shouldAckMessageAfterStage("after_receive_record", "agent_dispatch")).toBe(false);

src/channels/message/receive.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export function createMessageReceiveContext<TMessage>(params: {
8888
delete ctx.nackErrorMessage;
8989
},
9090
nack: async (error) => {
91+
// Nack callbacks must be idempotent because receive pipelines may
92+
// revisit completed stages and retry after nack.
93+
if (ctx.ackState === "nacked") {
94+
return;
95+
}
9196
await params.onNack?.(error);
9297
ctx.ackState = "nacked";
9398
ctx.nackErrorMessage = normalizeAckErrorMessage(error);

0 commit comments

Comments
 (0)