Skip to content

Commit f808908

Browse files
icecrasher321claude
andcommitted
fix(redis): reclaim a lock a timed-out acquire may have taken
`acquireLock` awaited `SET NX` and let a rejection propagate. But a rejected SET does not mean the server declined it: the client is configured with `commandTimeout: 5000`, and ioredis gives up locally while the command can still reach Redis and take the lock. The caller never learns it won, so it never releases — and since every caller treats a throw as "did not acquire", nothing else releases it either. Every contender then skips until the TTL expires. Staging hit this on the Outlook polling cron: `acquireLock` threw `Command timed out`, the route returned 500, and the next scheduled poll and the Lambda retry both got `Polling already in progress - skipped` against a lock whose holder had never started polling. The 180s TTL cleared it. On failure, best-effort compare-and-delete through the existing `releaseLock`. That deletes only while this token still owns the key, so a lock another holder won in the meantime is untouched, and if Redis is still unreachable the TTL stays the backstop — the behavior without this cleanup. Control flow is unchanged for all nine call sites: the original error still propagates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7c8d290 commit f808908

2 files changed

Lines changed: 72 additions & 2 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ vi.mock('ioredis', () => ({
2525
}))
2626

2727
import {
28+
acquireLock,
2829
closeRedisConnection,
2930
extendLock,
3031
getRedisClient,
@@ -208,6 +209,58 @@ describe('redis config', () => {
208209
})
209210
})
210211

212+
describe('acquireLock', () => {
213+
const lockKey = 'outlook-polling-lock'
214+
const value = 'req-abc'
215+
const ttlSeconds = 180
216+
217+
it('returns true when SET NX takes the lock', async () => {
218+
mockRedisInstance.set.mockResolvedValueOnce('OK')
219+
220+
expect(await acquireLock(lockKey, value, ttlSeconds)).toBe(true)
221+
expect(mockRedisInstance.set).toHaveBeenCalledWith(lockKey, value, 'EX', ttlSeconds, 'NX')
222+
expect(mockRedisInstance.eval).not.toHaveBeenCalled()
223+
})
224+
225+
it('returns false without cleanup when the lock is already held', async () => {
226+
mockRedisInstance.set.mockResolvedValueOnce(null)
227+
228+
expect(await acquireLock(lockKey, value, ttlSeconds)).toBe(false)
229+
expect(mockRedisInstance.eval).not.toHaveBeenCalled()
230+
})
231+
232+
it('reclaims the lock it may have taken when SET times out', async () => {
233+
// ioredis gives up client-side on `commandTimeout` while the command can
234+
// still land, so the lock would otherwise be held by a caller that never
235+
// learned it won and never releases it.
236+
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
237+
mockRedisInstance.eval.mockResolvedValueOnce(1)
238+
239+
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
240+
expect(mockRedisInstance.eval).toHaveBeenCalledWith(
241+
expect.stringContaining('del'),
242+
1,
243+
lockKey,
244+
value
245+
)
246+
})
247+
248+
it('surfaces the original failure when the cleanup also fails', async () => {
249+
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
250+
mockRedisInstance.eval.mockRejectedValueOnce(new Error('Connection is closed'))
251+
252+
// 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')
254+
})
255+
256+
it('returns true as a no-op when the cache capability selects the database', async () => {
257+
mockEnv.REDIS_URL = undefined
258+
259+
expect(await acquireLock(lockKey, value, ttlSeconds)).toBe(true)
260+
expect(mockRedisInstance.set).not.toHaveBeenCalled()
261+
})
262+
})
263+
211264
describe('capability validation', () => {
212265
it('rejects a non-Redis URL before constructing a client', () => {
213266
mockEnv.REDIS_URL = 'https://cache.example.com'

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,25 @@ export async function acquireLock(
245245
return true // No-op when Redis unavailable; idempotency layer handles duplicates
246246
}
247247

248-
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
249-
return result === 'OK'
248+
try {
249+
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
250+
return result === 'OK'
251+
} 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(() => {})
265+
throw error
266+
}
250267
}
251268

252269
/**

0 commit comments

Comments
 (0)