-
Notifications
You must be signed in to change notification settings - Fork 3.4k
fix(adapters): drop duplicate webhook deliveries at ingest #1987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 2 commits
87473db
3400495
5976ba2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import { | |
| getLinkedIssueNumbers, | ||
| onConversationClosed, | ||
| ConversationLockManager, | ||
| DeliveryDeduplicator, | ||
| AppNotInstalledError, | ||
| installCredentialHelper, | ||
| } from '@archon/core'; | ||
|
|
@@ -66,6 +67,12 @@ export class GitHubAdapter implements IPlatformAdapter { | |
| private allowedUsers: string[]; | ||
| private botMention: string; | ||
| private lockManager: ConversationLockManager; | ||
| /** | ||
| * Ingest idempotency: drops repeat deliveries of one logical comment event | ||
| * (dual repo+App subscriptions, LB double-forwards, redeliveries) before | ||
| * they reach the lock manager, which orders but does not dedup (#1951). | ||
| */ | ||
| private readonly deliveryDedup = new DeliveryDeduplicator(); | ||
| private readonly retryDelayFn: (attempt: number) => number; | ||
| /** | ||
| * Resolve the originating user's personal GitHub token (App mode only). | ||
|
|
@@ -918,8 +925,10 @@ ${userComment}`; | |
|
|
||
| /** | ||
| * Handle incoming webhook event | ||
| * @param deliveryId - GitHub's X-GitHub-Delivery GUID; dedup fallback when | ||
| * the payload carries no comment identity | ||
| */ | ||
| async handleWebhook(payload: string, signature: string): Promise<void> { | ||
| async handleWebhook(payload: string, signature: string, deliveryId?: string): Promise<void> { | ||
| // 1. Verify signature | ||
| if (!this.verifySignature(payload, signature)) { | ||
| getLog().error( | ||
|
|
@@ -987,6 +996,27 @@ ${userComment}`; | |
| // 5. Check @mention | ||
| if (!this.hasMention(comment)) return; | ||
|
|
||
| // 5a. Ingest idempotency. Key on the comment's identity (id + updated_at) | ||
| // rather than the delivery GUID: dual subscriptions (repo webhook + App | ||
| // webhook) deliver the same comment under different GUIDs, so GUID-only | ||
| // dedup misses the common duplicate source. An edited comment gets a new | ||
| // updated_at and forms a new key, so edit re-triggers still run. Both | ||
| // fields are required — keying on id alone would dedup an edit against | ||
| // the original. Falls back to the delivery GUID when either is absent. | ||
| const dedupKey = | ||
| event.comment?.id !== undefined && event.comment.updated_at | ||
| ? `comment:${owner}/${repo}#${String(number)}:${String(event.comment.id)}:${event.comment.updated_at}` | ||
| : deliveryId | ||
| ? `delivery:${deliveryId}` | ||
| : undefined; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if (dedupKey && this.deliveryDedup.seen(dedupKey)) { | ||
| getLog().info( | ||
| { eventType, owner, repo, number, deliveryId }, | ||
| 'github.duplicate_delivery_dropped' | ||
| ); | ||
| return; | ||
| } | ||
|
Comment on lines
+999
to
+1027
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Dedup marks the key as seen before downstream processing succeeds.
If recovering failed triggers via redelivery matters, consider recording the key only after the run is successfully enqueued/handled, or evicting the key on downstream failure. The 🧰 Tools🪛 ESLint[error] 1013-1013: Unsafe call of a type that could not be resolved. ( 🤖 Prompt for AI Agents |
||
|
|
||
| getLog().info({ eventType, owner, repo, number }, 'github.webhook_processing'); | ||
|
|
||
| // 5b. Resolve GitHub login → Archon user (auto-create on first sight). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { DeliveryDeduplicator } from './delivery-dedup'; | ||
|
|
||
| /** Deterministic clock: tests advance time explicitly instead of sleeping. */ | ||
| function createClock(start = 0) { | ||
| let now = start; | ||
| return { | ||
| now: () => now, | ||
| advance: (ms: number) => { | ||
| now += ms; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| describe('DeliveryDeduplicator', () => { | ||
| test('first sighting of a key is not a duplicate', () => { | ||
| const dedup = new DeliveryDeduplicator(); | ||
| expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:00:00Z')).toBe(false); | ||
| }); | ||
|
|
||
| test('repeat sighting within TTL is a duplicate', () => { | ||
| const dedup = new DeliveryDeduplicator(); | ||
| const key = 'comment:owner/repo#42:100:2026-06-12T00:00:00Z'; | ||
| expect(dedup.seen(key)).toBe(false); | ||
| expect(dedup.seen(key)).toBe(true); | ||
| expect(dedup.seen(key)).toBe(true); | ||
| }); | ||
|
|
||
| test('different keys do not collide', () => { | ||
| const dedup = new DeliveryDeduplicator(); | ||
| expect(dedup.seen('comment:owner/repo#42:100:t1')).toBe(false); | ||
| expect(dedup.seen('comment:owner/repo#42:101:t1')).toBe(false); | ||
| expect(dedup.seen('comment:owner/repo#43:100:t1')).toBe(false); | ||
| }); | ||
|
|
||
| test('same comment with new updated_at is not a duplicate (edit re-trigger)', () => { | ||
| const dedup = new DeliveryDeduplicator(); | ||
| expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:00:00Z')).toBe(false); | ||
| expect(dedup.seen('comment:owner/repo#42:100:2026-06-12T00:05:00Z')).toBe(false); | ||
| }); | ||
|
|
||
| test('key expires after TTL and may run again', () => { | ||
| const clock = createClock(); | ||
| const dedup = new DeliveryDeduplicator(20, 10_000, clock.now); | ||
| const key = 'delivery:guid-1'; | ||
| expect(dedup.seen(key)).toBe(false); | ||
| expect(dedup.seen(key)).toBe(true); | ||
|
|
||
| clock.advance(30); | ||
|
|
||
| expect(dedup.seen(key)).toBe(false); | ||
| expect(dedup.seen(key)).toBe(true); | ||
| }); | ||
|
|
||
| test('key just inside the TTL window is still a duplicate', () => { | ||
| const clock = createClock(); | ||
| const dedup = new DeliveryDeduplicator(20, 10_000, clock.now); | ||
| expect(dedup.seen('k')).toBe(false); | ||
|
|
||
| clock.advance(19); | ||
|
|
||
| expect(dedup.seen('k')).toBe(true); | ||
| }); | ||
|
|
||
| test('expired entries are pruned on insert', () => { | ||
| const clock = createClock(); | ||
| const dedup = new DeliveryDeduplicator(20, 10_000, clock.now); | ||
| dedup.seen('a'); | ||
| dedup.seen('b'); | ||
| expect(dedup.size).toBe(2); | ||
|
|
||
| clock.advance(30); | ||
|
|
||
| dedup.seen('c'); | ||
| expect(dedup.size).toBe(1); // a and b expired and pruned | ||
| }); | ||
|
|
||
| test('evicts oldest entries past max size', () => { | ||
| const dedup = new DeliveryDeduplicator(60_000, 3); | ||
| dedup.seen('a'); | ||
| dedup.seen('b'); | ||
| dedup.seen('c'); | ||
| dedup.seen('d'); // evicts a | ||
|
|
||
| expect(dedup.size).toBe(3); | ||
| expect(dedup.seen('a')).toBe(false); // evicted, so first-seen again | ||
| expect(dedup.seen('d')).toBe(true); // still tracked | ||
| }); | ||
|
|
||
| test('re-seeing a key after expiry refreshes its eviction position', () => { | ||
| const clock = createClock(); | ||
| const dedup = new DeliveryDeduplicator(20, 10, clock.now); | ||
| dedup.seen('a'); | ||
| clock.advance(30); | ||
| dedup.seen('b'); | ||
| dedup.seen('a'); // expired -> refreshed, moves behind b | ||
|
|
||
| expect(dedup.seen('a')).toBe(true); | ||
| expect(dedup.seen('b')).toBe(true); | ||
| expect(dedup.size).toBe(2); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Dedup tests depend on unmocked downstream Octokit failures.
Line 521-Line 526 swallows expected errors from an unmocked webhook path, which makes these tests depend on external call behavior instead of only dedup logic. Mock the minimal Octokit methods in
createDedupAdapter()and makedeliver()await success deterministically.Suggested fix
function createDedupAdapter(): GitHubAdapter { const adapter = new GitHubAdapter( { kind: 'pat', token: 'fake-token-for-testing' }, 'fake-webhook-secret', mockLockManager, 'archon' ); // `@ts-expect-error` - accessing private method for testing adapter.verifySignature = mock(() => true); + // `@ts-expect-error` - accessing private property for testing + adapter.octokit = { + rest: { + repos: { + get: mock(async () => ({ data: { default_branch: 'main' } })), + }, + issues: { + listComments: mock(async () => ({ data: [] })), + createComment: mock(async () => ({ data: { id: 1 } })), + }, + }, + }; return adapter; } async function deliver(adapter: GitHubAdapter, payload: string, deliveryId?: string) { - try { - await adapter.handleWebhook(payload, 'mock-signature', deliveryId); - } catch { - // Expected - Octokit API not mocked for the downstream message path. - } + await adapter.handleWebhook(payload, 'mock-signature', deliveryId); }As per coding guidelines, “Keep tests deterministic — no flaky timing or network dependence without guardrails” and “mock external dependencies (database, AI SDKs, platform APIs).”
🤖 Prompt for AI Agents
Source: Coding guidelines