fix(locking-redis): order multi-key acquires, atomic releaseAll, same-owner reentrancy under awaitQueue - #16284
fix(locking-redis): order multi-key acquires, atomic releaseAll, same-owner reentrancy under awaitQueue#16284docloulou wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 088b073 The changes in this PR will be included in the next version bump. This PR includes changesets to release 79 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Non-team PR fixing four correctness bugs in the Redis locking provider: partial multi-key acquire rollback, stable key ordering for deadlock prevention, atomic releaseAll compare-and-delete, and same-owner re-entrancy under awaitQueue. Uses per-call claim tokens in Lua scripts with a two-phase commit; 19 unit and 22 integration tests cover all changed paths. At 1562 changed lines with no linked issue, this exceeds the large-contribution threshold. Team review is warranted given the critical nature and backward-compat notes for the locking module.
Triggered by: PR marked as ready for review |
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Non-team PR fixing four real correctness bugs in the Redis locking provider with comprehensive tests and a changeset. Template is complete and code quality is high. The previous review's blocking point remains unresolved: the contribution exceeds 500 changed lines and no GitHub issue has been linked. Team review is also warranted given the critical nature of the locking module and the non-trivial backward-compatibility notes.
Triggered by: PR description updated |
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Author resolved the linked-issue requirement — issue #16285 is now linked via closing keyword. The large-contribution blocking point from prior reviews remains open: 1562 changed lines, and the linked issue does not carry a help-wanted label as required by CONTRIBUTING.md for large pre-approved changes. Code quality is high, tests are comprehensive, changeset is present, and requires-team is appropriately applied for this critical module.
Triggered by: PR description updated |
7dd3718 to
78705f1
Compare
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Community PR fixing four correctness bugs in the Redis locking provider with comprehensive tests (19 unit + 22 integration), a complete template, and a changeset. The prior blocking point remains: 1562 changed lines and linked issue #16285 has no help-wanted label. New finding: release() calls getTokenKeyName(), which throws INVALID_ARGUMENT for lock keys containing } without a hash tag, but the PR's own backward-compat notes claim such pre-existing keys remain releasable via releaseLegacyLock. That command is never defined and no fallback path is implemented in release().
Triggered by: PR description updated |
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Community PR fixing four correctness bugs in the Redis locking provider with comprehensive tests (19 unit + 22 integration), complete template, and a changeset. The releaseLegacyLock bug from the prior review is resolved — the command is now defined and release() routes to it correctly for keys without a slot-aligned sidecar. The large-contribution blocking point remains open: linked issue #16285 still lacks a help-wanted label. Team review continues to be warranted given the critical nature of the locking module.
Triggered by: PR description updated |
…-owner reentrancy Multi-key acquisition deduplicates and sorts the keys and takes them sequentially, which removes the ABBA deadlock between two callers requesting the same keys in different orders under awaitQueue. releaseAll issues the existing atomic releaseLock compare-and-delete per scanned key instead of a GET pipeline followed by a second UNLINK pipeline, so it can no longer delete a lock that expired and was re-acquired by another owner between the read and the delete. A named owner re-entering its own lock now succeeds under awaitQueue, as it already did with awaitQueue: false. The same-owner check was skipped entirely in the awaitQueue branch of the Lua script, so the owner backed off against its own lock until timeout. A failed multi-key acquire still leaves the keys it already took held until their TTL expires. Rolling them back safely requires an exact per-call acquisition token, since a compare-and-delete keyed on the owner would destroy a live lease re-acquired by another call sharing the same ownerId; that protocol is out of scope for this fix. Adds unit and integration coverage for each behaviour.
78705f1 to
088b073
Compare
|
Thanks for the contribution! A few items need to be addressed before this can move forward: Significantly revised community PR fixing three correctness bugs in the Redis locking provider: sorted/deduped multi-key acquisition to prevent ABBA deadlocks, atomic releaseAll via the existing releaseLock Lua script, and same-owner re-entrancy under awaitQueue. Prior blocking points (large contribution at 1562 lines, releaseLegacyLock finding) are resolved — PR is now ~398 lines. Code quality, unit and integration tests, and changeset are solid. One gap: the PR body no longer carries a closing-keyword link to issue #16285, which is required by contribution guidelines for non-trivial changes.
Triggered by: PR marked as ready for review |
|
Thanks for the contribution! Initial automated review looks good. Community PR fixing three correctness bugs in the Redis locking provider: stable sorted key ordering to prevent ABBA deadlocks in multi-key acquisitions, atomic compare-and-delete in releaseAll replacing the previous read-then-delete race, and same-owner re-entrancy under awaitQueue. All prior blocking points resolved — PR is 398 lines with closing-keyword link to #16285, complete template, changeset, and comprehensive tests (4 unit + 3 integration). No new security, performance, or correctness issues found. Triggered by: PR description updated |
Summary
What — What changes are introduced in this PR?
Three correctness fixes in the Redis locking provider (
packages/modules/providers/locking-redis/src/services/redis-lock.ts), each reachable through the public locking module API:awaitQueue: true: previously each caller took the keys in its own argument order, in parallel, so two overlapping calls could each end up holding a key the other was waiting on and back off against each other until they timed out.releaseAllissues the existing atomicreleaseLockcompare-and-delete per scanned key instead of aGETpipeline followed by a secondUNLINKpipeline. It can no longer delete a lock that expired and was re-acquired by a different owner in the window between the read and the delete.awaitQueue: true, as it already did withawaitQueue: false. The same-owner check was skipped entirely in theawaitQueuebranch of the Lua script, so an owner backed off against its own lock until it timed out.Why — Why are these changes relevant or necessary?
None of these require unusual configuration; a caller only has to use the documented options.
The deadlock needs nothing more than two concurrent calls passing an overlapping key set in different orders with
awaitQueue: true, which is the option that is supposed to make contention wait rather than fail. Each call can take its first key and then stall on the second, and the failure mode is a timeout rather than a lock error, so it reads as a slow dependency rather than as contention. Sorting the keys inside the provider removes the cycle for the callers themselves, without any convention imposed on the caller.releaseAllis documented as an owner-scoped sweep, but its read pipeline and its delete pipeline were separate round trips, and the gap between them grows with the number of scanned keys. Any key that expired and was re-acquired by another owner during that gap was deleted anyway, so a routine sweep by one owner could silently drop a lock another owner legitimately held. Reusing the existingreleaseLockscript makes the owner comparison and the delete happen at the same instant, on the server.The re-entrancy bug turns
awaitQueue: trueinto the opposite of its purpose for the one case where waiting is never correct. WithawaitQueue: false, re-acquiring a lock you already own returns immediately and refreshes the TTL. WithawaitQueue: true, the same call spun against its own lock until the key expired, or forever when noexpirewas passed. Passing anownerIdexists precisely so the provider can tell "my lock" from "someone else's lock", and theawaitQueuebranch was the one place that ignored it.How — How have these changes been implemented?
The Lua
acquireLockscript drops itsawaitQueueargument. Whether to wait is a client-side decision; the script only reports the state of the key, and the same-owner branch now runs on every call instead of only when the caller opted out of waiting. The return contract is unchanged:1means the key was acquired, either freshly or as a same-owner re-entry, and0means it is held by someone else. A re-entry rewrites the value withSET ... XX, so it refreshes the TTL when the caller passedexpireand drops any remaining TTL when it did not — the same thingdevelopalready does when re-entering withawaitQueue: false. There is no new return code, and the anonymous owner"*"still cannot re-enter, since it is not an identity.acquire_iterates[...new Set(keys)].sort()instead of mapping over the caller's array in parallel. Each key gets its own backoff counter; previously a singleretryDelaywas shared and mutated by every key being acquired concurrently, so the delay a given key saw depended on how many siblings had already retried. The call to the script now passes three arguments instead of four. The error thrown on contention is unchanged, including the logical (unprefixed) key in its message.releaseAllbuilds one pipeline ofreleaseLockcalls, one per scanned key, and executes it: two Redis round trips perSCANbatch instead of three, since theSCANis now followed by a single pipeline rather than by a read pipeline and then a delete pipeline. The owner check and the delete are fused inside each script invocation. TheSCANpattern, the batch size, and the"*"owner default are untouched.releaseis not changed at all; it already issued one atomicreleaseLockper key, and running those in parallel is correct because each one is self-contained.Testing — How have these changes been tested, or how can the reviewer test the feature?
Four unit tests added to
src/services/__tests__/redis-lock.spec.ts, against a mocked Redis client:acquireLockper key in that order;awaitQueue: trueresolves without any backoff delay;releaseAllbuilds a single pipeline ofreleaseLockcalls, one per scanned key, and executes it once.Three integration tests added to
integration-tests/__tests__/index.spec.ts, against a real Redis through the locking module:awaitQueue: trueresolves instead of running into the test timeout, which is what it runs into for as long as theawaitQueuebranch skips the same-owner check;releaseAllreleases only the calling owner's keys and leaves another owner's lock intact;awaitQueue: trueboth complete, and the same test deadlocks when the sorted acquisition is removed.All pre-existing unit and integration tests pass unchanged.
Examples
Two callers asking for an overlapping key set in opposite orders, each releasing the set when it is done with it:
An owner re-entering a lock it already holds:
Checklist
Additional Context
Known limitation, deliberately left in place
A multi-key
acquirethat fails does not release the keys it already took. They stay held until their TTL expires, or forever when the caller passed noexpire, since the script then issuesSET NXwithoutEX. This is the current behaviour and this PR does not change it, in either direction.It is worth being explicit about why it is not fixed here, because the obvious fix does not work. Rolling back by compare-and-deleting on the stored owner is unsafe: if one of the keys the call took expires, and a different call sharing the same
ownerIdacquires it before the rollback runs, the owner comparison succeeds against that second call's lease and the rollback deletes it. Sharing anownerIdacross concurrent units of work is normal and supported, so this is not a corner case; the rollback would trade a leaked key for a lock silently taken away from a live holder, which is strictly worse. Telling the two acquisitions apart requires an exact per-acquisition token, and the value stored under a lock key today is a single owner string with nowhere to put one.The larger design that does fix it
There is a design that closes this properly. It is a protocol change, so it belongs in front of maintainers as its own change rather than inside a bug fix.
Storing a per-call acquisition token alongside the owner makes the rollback exact: a call withdraws only the acquisition it performed itself, so it can never revoke a lease that expired and was re-acquired, by the same owner or anyone else. The same token additionally allows a commit step once every key in the set is held, which verifies that no lease was lost mid-flight and fails the call instead of letting it proceed believing it holds a set it no longer holds.
A full prototype of that protocol exposed the following compatibility and operational costs, which are what reviewers should weigh before asking for it:
redisClient.acquireLockorredisClient.releaseLockdirectly stops working;keyPrefix, because a client-side prefix breaks the slot alignment the scripts declare; for a deployment that sets one today, that is a boot failure rather than a degraded path;INVALID_ARGUMENTpath reachable from any caller that passes a key it has not validated;That is a real protocol change with an operational story attached, not a patch. If maintainers want the rollback guarantee and consider those costs acceptable, I am happy to open it as a separate PR or write it up as an RFC first.
Fixes #16285