Skip to content

Commit 8432e19

Browse files
committed
fix(webhooks): run a delivery whose dedup claim reply was lost or late
A Redis dedup claim that applied but whose reply was lost was replayed by ioredis, found its own value, and skipped the delivery as its own duplicate. A claim whose reply came after the command timeout threw outside the release path, leaving an orphaned in-progress lease that skipped every redelivery until it expired. The claim script now treats its own value as claimed. A claim that errors releases its own lease by token and throws a RetryableSetupError, so the webhook job requeues through the existing setup-failure path.
1 parent a0526e7 commit 8432e19

2 files changed

Lines changed: 250 additions & 8 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* The Redis claim under a lost or late reply, against real Redis. A TCP proxy between ioredis and
3+
* Redis lets the claim script run and then drops the connection before its reply (ioredis replays
4+
* the command on reconnect) or holds the reply past the command timeout. Skipped without
5+
* `TEST_REDIS_URL`; the Redis client accessor is the only fixture.
6+
*/
7+
import { createServer, type Server, type Socket, connect as tcpConnect } from 'node:net'
8+
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
9+
import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
10+
import { sleep } from '@sim/utils/helpers'
11+
import { generateId } from '@sim/utils/id'
12+
import Redis from 'ioredis'
13+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { isRetryableSetupError } from '@/lib/core/errors/retryable-infrastructure'
15+
import { IdempotencyService } from '@/lib/core/idempotency/service'
16+
17+
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
18+
19+
const redisUrl = readTestRedisUrl()
20+
const COMMAND_TIMEOUT_MS = 500
21+
const CLAIM_SCRIPT_MARKER = 'local claimed'
22+
23+
type ClaimFault = { kind: 'drop-reply' } | { kind: 'delay-reply'; ms: number }
24+
25+
/** Forwards bytes both ways; applies one armed fault to the next reply after a claim script. */
26+
class ClaimFaultProxy {
27+
private server: Server | undefined
28+
private armed: ClaimFault | undefined
29+
claimsForwarded = 0
30+
31+
async start(upstream: URL): Promise<number> {
32+
this.server = createServer((client) => this.pipe(client, upstream))
33+
await new Promise<void>((resolve) => this.server?.listen(0, '127.0.0.1', resolve))
34+
const address = this.server.address()
35+
if (!address || typeof address === 'string') throw new Error('proxy has no port')
36+
return address.port
37+
}
38+
39+
arm(fault: ClaimFault): void {
40+
this.armed = fault
41+
}
42+
43+
async stop(): Promise<void> {
44+
await new Promise((resolve) => this.server?.close(resolve))
45+
}
46+
47+
private pipe(client: Socket, upstream: URL): void {
48+
const server = tcpConnect(Number(upstream.port || 6379), upstream.hostname)
49+
let pendingFault: ClaimFault | undefined
50+
client.on('data', (chunk) => {
51+
if (chunk.includes(CLAIM_SCRIPT_MARKER)) {
52+
this.claimsForwarded++
53+
if (this.armed) {
54+
pendingFault = this.armed
55+
this.armed = undefined
56+
}
57+
}
58+
server.write(chunk)
59+
})
60+
server.on('data', (chunk) => {
61+
const fault = pendingFault
62+
pendingFault = undefined
63+
if (fault?.kind === 'drop-reply') {
64+
client.destroy()
65+
server.destroy()
66+
return
67+
}
68+
if (fault?.kind === 'delay-reply') {
69+
server.pause()
70+
setTimeout(() => {
71+
if (!client.destroyed) client.write(chunk)
72+
server.resume()
73+
}, fault.ms)
74+
return
75+
}
76+
if (!client.destroyed) client.write(chunk)
77+
})
78+
client.on('close', () => server.destroy())
79+
server.on('close', () => client.destroy())
80+
client.on('error', () => server.destroy())
81+
server.on('error', () => client.destroy())
82+
}
83+
}
84+
85+
describe.runIf(Boolean(redisUrl))('Redis idempotency claim with a lost or late reply', () => {
86+
const proxy = new ClaimFaultProxy()
87+
let redis: Redis
88+
let direct: Redis
89+
const service = new IdempotencyService({
90+
namespace: `claim-faults-${generateId()}`,
91+
inProgressTtlSeconds: 60 * 60 * 2,
92+
storeResultBody: false,
93+
forceStorage: 'redis',
94+
})
95+
96+
beforeAll(async () => {
97+
const upstream = new URL(redisUrl ?? '')
98+
const port = await proxy.start(upstream)
99+
redis = new Redis({
100+
host: '127.0.0.1',
101+
port,
102+
db: Number(upstream.pathname.slice(1) || 0),
103+
lazyConnect: true,
104+
commandTimeout: COMMAND_TIMEOUT_MS,
105+
maxRetriesPerRequest: 5,
106+
retryStrategy: () => 50,
107+
})
108+
await redis.connect()
109+
direct = new Redis(redisUrl ?? '', { lazyConnect: true, maxRetriesPerRequest: 0 })
110+
await direct.connect()
111+
})
112+
113+
beforeEach(() => {
114+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
115+
})
116+
117+
afterAll(async () => {
118+
redis?.disconnect()
119+
await direct?.quit()
120+
await proxy.stop()
121+
})
122+
123+
async function storedStatus(identifier: string): Promise<string | undefined> {
124+
const keys = await direct.keys(`idempotency:*:${identifier}`)
125+
if (keys.length === 0) return undefined
126+
const value = await direct.get(keys[0])
127+
return value ? (JSON.parse(value) as { status?: string }).status : undefined
128+
}
129+
130+
it('runs the operation once when the claim applied but its reply was lost', async () => {
131+
const identifier = generateId()
132+
const operation = vi.fn(async () => 'ran')
133+
const claimsBefore = proxy.claimsForwarded
134+
135+
proxy.arm({ kind: 'drop-reply' })
136+
const execution = await service.executeOrSkipInProgress('generic', identifier, operation)
137+
138+
expect(proxy.claimsForwarded - claimsBefore).toBe(2)
139+
expect(execution).toEqual({ outcome: 'resolved', result: 'ran' })
140+
expect(operation).toHaveBeenCalledTimes(1)
141+
expect(await storedStatus(identifier)).toBe('completed')
142+
})
143+
144+
it('releases its claim and asks for a retry when the claim reply is late, so a retry runs', async () => {
145+
const identifier = generateId()
146+
const operation = vi.fn(async () => 'ran')
147+
148+
proxy.arm({ kind: 'delay-reply', ms: COMMAND_TIMEOUT_MS * 1.5 })
149+
const failure = await service.executeOrSkipInProgress('generic', identifier, operation).then(
150+
() => undefined,
151+
(error: unknown) => error
152+
)
153+
await sleep(COMMAND_TIMEOUT_MS * 2)
154+
155+
expect(isRetryableSetupError(failure)).toBe(true)
156+
expect(operation).not.toHaveBeenCalled()
157+
expect(await storedStatus(identifier)).toBeUndefined()
158+
159+
const retry = await service.executeOrSkipInProgress('generic', identifier, operation)
160+
expect(retry).toEqual({ outcome: 'resolved', result: 'ran' })
161+
expect(operation).toHaveBeenCalledTimes(1)
162+
})
163+
164+
it('keeps the original error when the stall also outlasts the release', async () => {
165+
const identifier = generateId()
166+
const operation = vi.fn(async () => 'ran')
167+
168+
proxy.arm({ kind: 'delay-reply', ms: COMMAND_TIMEOUT_MS * 3 })
169+
const failure = await service.executeOrSkipInProgress('generic', identifier, operation).then(
170+
() => undefined,
171+
(error: unknown) => error
172+
)
173+
await sleep(COMMAND_TIMEOUT_MS * 4)
174+
175+
expect(failure).toBeInstanceOf(Error)
176+
expect(isRetryableSetupError(failure)).toBe(false)
177+
expect(operation).not.toHaveBeenCalled()
178+
})
179+
180+
it("still skips a delivery whose key is another holder's live claim", async () => {
181+
const identifier = generateId()
182+
const holder = vi.fn(async () => {
183+
await sleep(COMMAND_TIMEOUT_MS)
184+
return 'holder'
185+
})
186+
const duplicate = vi.fn(async () => 'duplicate')
187+
188+
const running = service.executeOrSkipInProgress('generic', identifier, holder)
189+
await sleep(50)
190+
const second = await service.executeOrSkipInProgress('generic', identifier, duplicate)
191+
192+
expect(second).toEqual({ outcome: 'in-progress' })
193+
expect(duplicate).not.toHaveBeenCalled()
194+
expect(await running).toEqual({ outcome: 'resolved', result: 'holder' })
195+
})
196+
})

‎apps/sim/lib/core/idempotency/service.ts‎

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
77
import { and, eq, lt, or, sql } from 'drizzle-orm'
88
import { getRedisClient } from '@/lib/core/config/redis'
9-
import { isRetryableSetupError } from '@/lib/core/errors/retryable-infrastructure'
9+
import {
10+
isRetryableSetupError,
11+
RetryableSetupError,
12+
} from '@/lib/core/errors/retryable-infrastructure'
1013
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1114
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
1215
import { extractProviderIdentifierFromBody, isDeliveryIdOptional } from '@/lib/webhooks/providers'
@@ -101,10 +104,16 @@ const REDIS_KEY_PREFIX = 'idempotency:'
101104
const MAX_WAIT_TIME_MS = getMaxExecutionTimeout()
102105
const POLL_INTERVAL_MS = 1000
103106

107+
/**
108+
* The claim value carries a fresh `claimToken`, so finding exactly `ARGV[1]` means this same
109+
* call already applied: ioredis replays a command whose reply a dropped connection lost, and
110+
* that replay must claim rather than report its own lease as another holder's.
111+
*/
104112
const CLAIM_REDIS_LEASE_SCRIPT = `
105113
local claimed = redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]), 'NX')
106114
if claimed then return {1, ''} end
107115
local current = redis.call('GET', KEYS[1])
116+
if current == ARGV[1] then return {1, ''} end
108117
if current then return {0, current} end
109118
claimed = redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]), 'NX')
110119
if claimed then return {1, ''} end
@@ -295,13 +304,19 @@ export class IdempotencyService {
295304
1,
296305
Math.ceil(((inProgressResult.inProgressExpiresAt ?? Date.now()) - Date.now()) / 1000)
297306
)
298-
const claimResponse = await redis.eval(
299-
CLAIM_REDIS_LEASE_SCRIPT,
300-
1,
301-
redisKey,
302-
JSON.stringify(inProgressResult),
303-
inProgressTtlSeconds
304-
)
307+
let claimResponse: unknown
308+
try {
309+
claimResponse = await redis.eval(
310+
CLAIM_REDIS_LEASE_SCRIPT,
311+
1,
312+
redisKey,
313+
JSON.stringify(inProgressResult),
314+
inProgressTtlSeconds
315+
)
316+
} catch (error) {
317+
if (!inProgressResult.claimToken) throw error
318+
return this.releaseAmbiguousRedisClaim(normalizedKey, inProgressResult.claimToken, error)
319+
}
305320
if (!Array.isArray(claimResponse) || claimResponse.length < 2) {
306321
throw new Error(`Redis idempotency claim returned an invalid result: ${normalizedKey}`)
307322
}
@@ -332,6 +347,37 @@ export class IdempotencyService {
332347
}
333348
}
334349

350+
/**
351+
* A claim that errors (a timed-out reply, a connection that dropped after the script ran) may
352+
* still hold the key, and nothing else would release it before the lease ends: every
353+
* redelivery in that window would be skipped as a duplicate. Release this call's own claim by
354+
* its token, then throw a `RetryableSetupError` so the caller re-attempts. If the release fails
355+
* too, the claim may still be held, so the original error is rethrown unchanged.
356+
*/
357+
private async releaseAmbiguousRedisClaim(
358+
normalizedKey: string,
359+
claimToken: string,
360+
error: unknown
361+
): Promise<never> {
362+
try {
363+
await this.deleteKey(normalizedKey, 'redis', { claimToken })
364+
} catch (releaseError) {
365+
logger.error(`Failed to release an idempotency claim with an unknown outcome`, {
366+
normalizedKey,
367+
error: getErrorMessage(error),
368+
releaseError: getErrorMessage(releaseError),
369+
})
370+
throw error
371+
}
372+
logger.warn(`Released an idempotency claim with an unknown outcome`, {
373+
normalizedKey,
374+
error: getErrorMessage(error),
375+
})
376+
throw new RetryableSetupError(`Idempotency claim failed: ${getErrorMessage(error)}`, {
377+
cause: error,
378+
})
379+
}
380+
335381
private async atomicallyClaimDb(
336382
normalizedKey: string,
337383
inProgressResult: ProcessingResult

0 commit comments

Comments
 (0)