Skip to content

Commit 121cdd4

Browse files
icecrasher321claude
andcommitted
fix(redis): make the timed-out-acquire reclaim opt-in per caller
Review caught that reclaiming unconditionally is unsafe for two caller classes, both of which exist today: Callers that fall open. `withLeaderLock` and the MCP OAuth refresh mutex catch a throw from `acquireLock` and run their work uncoordinated. If the SET landed, today the lock they hold keeps everyone else out while they run. Freeing it under them admits a second concurrent runner — for OAuth refresh that means two rotations of the same token and an `invalid_grant`. Callers whose lock value is not unique. The copilot chat lock keys on `streamId`, which is the client-supplied `userMessageId`. Two sends can carry the same value, so a compare-and-delete from a contender that timed out can match — and delete — the lock the active stream is holding. Reclaiming is therefore opt-in, and the option documents both preconditions it needs: a value unique to the holder, and a caller that does no work when acquisition throws. Default behavior is byte-for-byte what it was before. Opted in are the four cron/poll callers that satisfy both — webhook polling, resume polling, workspace-events polling, and Teams subscription renewal. Each mints its value with `generateShortId()` and returns 5xx rather than proceeding when acquisition throws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f808908 commit 121cdd4

9 files changed

Lines changed: 67 additions & 24 deletions

File tree

apps/sim/app/api/cron/renew-subscriptions/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ describe('Teams subscription renewal route (fire-and-forget)', () => {
6363
expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith(
6464
'teams-subscription-renewal-lock',
6565
expect.any(String),
66-
expect.any(Number)
66+
expect.any(Number),
67+
{ reclaimOnFailure: true }
6768
)
6869

6970
await flushMicrotasks()

apps/sim/app/api/cron/renew-subscriptions/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
252252
}
253253

254254
const lockValue = generateShortId()
255-
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
255+
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
256+
reclaimOnFailure: true,
257+
})
256258
if (!locked) {
257259
return NextResponse.json(
258260
{ success: true, message: 'Renewal already in progress – skipped', status: 'skip' },

apps/sim/app/api/resume/poll/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6262
const authError = verifyCronAuth(request, 'Time-pause resume poll')
6363
if (authError) return authError
6464

65-
const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS)
65+
const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS, {
66+
reclaimOnFailure: true,
67+
})
6668
if (!lockAcquired) {
6769
return NextResponse.json(
6870
{ success: true, message: 'Polling already in progress – skipped', requestId },

apps/sim/app/api/webhooks/poll/[provider]/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ describe('webhook polling route (fire-and-forget)', () => {
6666
expect(response.status).toBe(202)
6767
const data = await response.json()
6868
expect(data).toMatchObject({ status: 'started' })
69+
// `reclaimOnFailure` is what stops a timed-out acquire from leaving a lock
70+
// no one owns, which skipped every poll until the TTL expired.
6971
expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith(
7072
'gmail-polling-lock',
7173
expect.any(String),
72-
expect.any(Number)
74+
expect.any(Number),
75+
{ reclaimOnFailure: true }
7376
)
7477

7578
await flushMicrotasks()

apps/sim/app/api/webhooks/poll/[provider]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ export const GET = withRouteHandler(
4040

4141
const LOCK_KEY = `${provider}-polling-lock`
4242
const lockValue = requestId
43-
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
43+
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS, {
44+
reclaimOnFailure: true,
45+
})
4446
if (!locked) {
4547
return NextResponse.json(
4648
{

apps/sim/app/api/workspace-events/poll/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ describe('workspace events polling route (fire-and-forget)', () => {
6262
expect(redisConfigMockFns.mockAcquireLock).toHaveBeenCalledWith(
6363
'workspace-events-no-activity-poll-lock',
6464
expect.any(String),
65-
expect.any(Number)
65+
expect.any(Number),
66+
{ reclaimOnFailure: true }
6667
)
6768

6869
await flushMicrotasks()

apps/sim/app/api/workspace-events/poll/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3131
return authError
3232
}
3333

34-
const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS)
34+
const lockAcquired = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS, {
35+
reclaimOnFailure: true,
36+
})
3537

3638
if (!lockAcquired) {
3739
return NextResponse.json(

apps/sim/lib/core/config/redis.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,16 @@ describe('redis config', () => {
229229
expect(mockRedisInstance.eval).not.toHaveBeenCalled()
230230
})
231231

232-
it('reclaims the lock it may have taken when SET times out', async () => {
232+
it('reclaims the lock it may have taken when SET times out and reclaim is on', async () => {
233233
// ioredis gives up client-side on `commandTimeout` while the command can
234234
// still land, so the lock would otherwise be held by a caller that never
235235
// learned it won and never releases it.
236236
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
237237
mockRedisInstance.eval.mockResolvedValueOnce(1)
238238

239-
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
239+
await expect(
240+
acquireLock(lockKey, value, ttlSeconds, { reclaimOnFailure: true })
241+
).rejects.toThrow('Command timed out')
240242
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
241243
expect.stringContaining('del'),
242244
1,
@@ -245,12 +247,24 @@ describe('redis config', () => {
245247
)
246248
})
247249

250+
it('leaves the lock alone by default so a fall-open caller keeps holding it', async () => {
251+
// `withLeaderLock` and the MCP OAuth mutex run their work anyway when
252+
// acquisition throws. Freeing the lock under them would let a second
253+
// runner in alongside, so reclaiming has to stay opt-in.
254+
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
255+
256+
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
257+
expect(mockRedisInstance.eval).not.toHaveBeenCalled()
258+
})
259+
248260
it('surfaces the original failure when the cleanup also fails', async () => {
249261
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
250262
mockRedisInstance.eval.mockRejectedValueOnce(new Error('Connection is closed'))
251263

252264
// The TTL stays the backstop; the caller must still see why acquiring failed.
253-
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
265+
await expect(
266+
acquireLock(lockKey, value, ttlSeconds, { reclaimOnFailure: true })
267+
).rejects.toThrow('Command timed out')
254268
})
255269

256270
it('returns true as a no-op when the cache capability selects the database', async () => {

apps/sim/lib/core/config/redis.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,32 @@ end
235235
* single-replica deployments to function without Redis. In multi-replica
236236
* deployments without Redis, the idempotency layer prevents duplicate processing.
237237
*/
238+
export interface AcquireLockOptions {
239+
/**
240+
* Release the lock this call may have taken when the SET itself rejects.
241+
*
242+
* A rejected SET does not mean the server declined it: `commandTimeout` gives
243+
* up client-side while the command can still reach Redis and take the lock,
244+
* leaving it held by a caller that never learned it won and so never releases
245+
* it. Every contender then skips until the TTL expires.
246+
*
247+
* Only opt in when BOTH hold, because the reclaim is unsafe otherwise:
248+
*
249+
* 1. `value` is unique to this holder. A value two holders can share — the
250+
* copilot chat lock keys on a client-supplied `userMessageId` — makes the
251+
* compare-and-delete match a lock another holder is actively using.
252+
* 2. A throw means the caller does no work. One that falls open and runs
253+
* anyway (`withLeaderLock`, the MCP OAuth refresh mutex) would keep running
254+
* while this frees its lock, admitting a second concurrent runner.
255+
*/
256+
reclaimOnFailure?: boolean
257+
}
258+
238259
export async function acquireLock(
239260
lockKey: string,
240261
value: string,
241-
expirySeconds: number
262+
expirySeconds: number,
263+
options?: AcquireLockOptions
242264
): Promise<boolean> {
243265
const redis = getRedisClient()
244266
if (!redis) {
@@ -249,19 +271,13 @@ export async function acquireLock(
249271
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
250272
return result === 'OK'
251273
} catch (error) {
252-
/*
253-
* A rejected SET does not mean the server declined it. `commandTimeout`
254-
* gives up client-side while the command may still reach Redis and take the
255-
* lock, leaving it held by a caller that never learned it won and so never
256-
* releases it — every contender then skips until the TTL expires.
257-
*
258-
* Reclaiming it is safe because this is the same compare-and-delete
259-
* `releaseLock` uses on the success path: it deletes only while `value`
260-
* still owns the key, so a lock a different holder won in the meantime is
261-
* left alone. Best effort — if Redis is still unreachable the TTL remains
262-
* the backstop, which is exactly the behavior without this cleanup.
263-
*/
264-
await releaseLock(lockKey, value).catch(() => {})
274+
// Best effort, and the same compare-and-delete `releaseLock` runs on the
275+
// success path: it deletes only while `value` still owns the key. If Redis
276+
// is still unreachable the TTL stays the backstop, which is the behavior
277+
// without this cleanup.
278+
if (options?.reclaimOnFailure) {
279+
await releaseLock(lockKey, value).catch(() => {})
280+
}
265281
throw error
266282
}
267283
}

0 commit comments

Comments
 (0)