fix(redis): reclaim a distributed lock that a timed-out acquire may have taken - #6864
Conversation
`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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview On failure with Four cron/poll routes now pass
Reviewed by Cursor Bugbot for commit 121cdd4. Configure here. |
Greptile SummaryThe PR makes lock reclamation opt-in when a Redis
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/core/config/redis.ts | Adds opt-in compare-and-delete cleanup after rejected lock acquisition while preserving the original error and safe default behavior. |
| apps/sim/lib/core/config/redis.test.ts | Covers acquisition success, contention, opted-in reclamation, default non-reclamation, cleanup failure, and the no-Redis path. |
| apps/sim/app/api/webhooks/poll/[provider]/route.ts | Enables reclamation with a uniquely generated request identifier and does not start polling after acquisition throws. |
| apps/sim/app/api/resume/poll/route.ts | Enables reclamation using a per-request identifier while retaining fail-closed acquisition handling. |
| apps/sim/app/api/workspace-events/poll/route.ts | Enables reclamation for the polling lock with a unique request-scoped ownership value. |
| apps/sim/app/api/cron/renew-subscriptions/route.ts | Enables reclamation for subscription renewal using a freshly generated lock value. |
Reviews (2): Last reviewed commit: "fix(redis): make the timed-out-acquire r..." | Re-trigger Greptile
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>
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 121cdd4. Configure here.
The bug
acquireLockawaitedSET NXand let a rejection propagate:A rejected SET does not mean the server declined it. The client is configured with
commandTimeout: 5000(redis.ts:164), and ioredis rejects locally when that elapses (ioredis/built/Command.js:195, messageCommand timed out) while the command can still reach Redis and take the lock.When that happens the lock is held by a caller that never learned it won, so it never releases. Every caller treats a throw as "did not acquire", so nothing else releases it either. Every contender skips until the TTL expires.
How staging hit it
The
sim-staging-us-east-1-cron-lambda-errorsalarm at 21:06 UTC:acquireLockthrewCommand timed outinapp/api/webhooks/poll/[provider]/route.ts:43outlook polling failed: Command timed outafter ~10.8sPolling already in progress - skipped, against a lock whose holder had never started pollingWorth being precise about one thing, because the incident's own remediation note pointed at it: the route's
finally/releaseLockis not missing. It lives inside therunDetachedtask, which never started, because the throw happened at lock acquisition. The poll never ran, so there was nothing to terminate — the lock simply had no owner that could release it.The fix, and why it is opt-in
On failure, best-effort compare-and-delete through the existing
releaseLock— but only when the caller opts in, because reclaiming unconditionally is unsafe for two caller classes that exist today. Both were caught in review; the first revision of this PR had it on for everyone and was wrong.Callers that fall open.
withLeaderLock(leader-lock.ts:36-43) and the MCP OAuth refresh mutex (mcp/oauth/storage.ts:329-336) catch the throw and run their work uncoordinated. If the SET landed, the lock they hold is what keeps everyone else out while they run. Freeing it under them admits a second concurrent runner — for OAuth refresh, two rotations of the same refresh token and aninvalid_grant.Callers whose lock value is not unique. The copilot chat lock keys on
streamId, which is the client-supplieduserMessageId(copilot/chat/post.ts:266declares itz.string().max(128).optional();post.ts:592passes it asstreamId). 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 holds.So the option carries its preconditions in its own TSDoc: the value must be unique to the holder, and a throw must mean the caller does no work. Default behavior is byte-for-byte what it was before this PR.
Opted in
The four cron/poll callers that satisfy both preconditions — each mints its value with
generateShortId()and returns 5xx rather than proceeding when acquisition throws:app/api/webhooks/poll/[provider]generateShortId()app/api/resume/pollgenerateShortId()app/api/workspace-events/pollgenerateShortId()app/api/cron/renew-subscriptionsgenerateShortId()For those, every branch is better than today or identical to it:
Not opted in, and unchanged:
lib/concurrency/leader-lock,lib/mcp/oauth/storage,lib/copilot/request/session/abort,lib/table/cascade-lock,app/api/tools/file/manage.Control flow is unchanged
The error still propagates in every case, so callers see exactly what they saw before — the only difference is a best-effort side effect for the four that opt in. Deliberately not converted to
return false: that would make the poll route answerPolling already in progress - skippedwhen polling was not in progress, hiding a real Redis outage from the alarm that correctly fired here.It also makes the existing recovery work as designed — the Lambda retry that got
already in progresswill now find the lock free and actually poll.Tests
Six cases in
redis.test.ts: SET NX taking the lock (no cleanup issued), contention returning false (no cleanup issued), a timed-out SET issuing the compare-and-delete and rethrowing when reclaim is on, a timed-out SET issuing no cleanup by default so a fall-open caller keeps holding its lock, a failed cleanup still surfacing the original error, and the no-Redis no-op path.The webhook polling route test now asserts it passes
{ reclaimOnFailure: true }, pinning the incident's actual fix at the call site rather than only in the primitive.I verified the reclaim test fails against the original implementation (1 failed / 20 passed) and passes with the fix, so it isn't a test that would pass either way.
Verification
bun run testacrossredis.test.tsand all affected callers: 64 passedbun run type-check(apps/sim): cleanbiome check: clean🤖 Generated with Claude Code