Skip to content

feat(server): implement RedisLocker and IoRedisLocker#846

Open
Olebeh wants to merge 6 commits into
tus:mainfrom
Olebeh:feat/redislocker
Open

feat(server): implement RedisLocker and IoRedisLocker#846
Olebeh wants to merge 6 commits into
tus:mainfrom
Olebeh:feat/redislocker

Conversation

@Olebeh

@Olebeh Olebeh commented Jul 17, 2026

Copy link
Copy Markdown

This PR adds to new lockers to use which expands the choice from a sole MemoryLocker to also include RedisLocker and IoRedisLocker for better coordination of exclusive upload access across horizontally scaled servers that use tus

What is new

  • RedisLocker (via @redis/client) and IoRedisLocker (via ioredis)
  • Appropriate integration test for each new locker

How does it work

The key is stored on a Redis DB indicating that a locker with a specific ID is currently in use. The value of the key is UUID token generated on a process, it is needed to determine whether a process B has took over the upload that process A was working on, hence took over the Redis entry and changed its value with a new UUID. A Lua script was created in order to execute a few operations at Redis simultaneously without risking any lock requests in-between calls. The script checks whether the current upload ID (if exists) matches the UUID generated by process A. If it was not, but process A still tries to call DEL or SET NX functions - it will be denied because the upload no longer belongs to process A. In order to let the process A know that process B wants to take over with requestRelease, a global subscription pool exists on the Locker instance (if using IoRedisLocker), which utilizes Redis' Sub/Pub capabilities in order to invoke the requestRelease on process A, to which process A subscribes and to which process B publishes its nudge. The requestRelease function itself is stored in a hashmap on a main Locker object with keys being the channel ids, to which processes subscribe and publish messages. In case with regular RedisLocker instance though, the package provides capabilities to only listen to messages on a specific channel, therefore it lacks the global subscription pool and the hashmap, since it can be stored on the Lock object. Everything else works just as like.

Usage

import {createClient} from '@redis/client'
import {RedisLocker} from '@tus/server'

const redis = createClient({url: 'redis://localhost:6379'})
const subscriber = redis.duplicate()
await Promise.all([redis.connect(), subscriber.connect()])

const server = new Server({datastore, locker: new RedisLocker({redis, subscriber})})

Tests / CI

  • Integration suites for both lockers mirror the existing Locker.test.ts (acquire/release, pub/sub release, timeout, abort, watchdog TTL extension, and cross-instance coordination)
  • This PR also modifies the CI by adding Redis image in order to conclude the tests on new lockers. The suites skip themselves when Redis is unreachable, so they never break environments without one.

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c4c7f70

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@tus/server Minor

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

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8dd4d9bb-4daf-4fbd-9fb7-9ab363ae556e

📥 Commits

Reviewing files that changed from the base of the PR and between 2091d9c and c4c7f70.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/lockers/RedisLocker.ts
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/test/MemoryLocker.test.ts
  • packages/server/src/test/RedisLocker.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/test/RedisLocker.test.ts
  • packages/server/src/lockers/RedisLocker.ts
  • .github/workflows/ci.yml

Walkthrough

Changes

Adds RedisLocker and IoRedisLocker implementations with token-safe Redis locking, Pub/Sub coordination, retries, abort handling, and TTL renewal. Exports both lockers, provisions Redis in CI, adds integration tests for Redis and memory lockers, and records a minor release changeset.

Redis distributed locking

Layer / File(s) Summary
RedisLocker implementation
packages/server/src/lockers/RedisLocker.ts
Adds configurable Redis locking with atomic Lua release and TTL-extension scripts, Pub/Sub release signaling, retries, timeouts, abort handling, and watchdog renewal.
IoRedisLocker adapter
packages/server/src/lockers/IoRedisLocker.ts
Adds equivalent distributed-lock behavior using ioredis clients and exported configuration types.
Exports, integration tests, and CI wiring
packages/server/src/lockers/index.ts, packages/server/src/test/*Locker.test.ts, .github/workflows/ci.yml
Exports the lockers, adds Redis and memory locker tests for lifecycle, contention, timeout, abort, watchdog renewal, and cross-instance coordination, and provisions Redis in CI.
Release metadata
.changeset/spicy-beans-bet.md
Records a minor release and describes the new distributed locker implementations.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding RedisLocker and IoRedisLocker support.
Description check ✅ Passed The description is on-topic and describes the new lockers, tests, and CI updates in this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 71: Update the Redis service image under the workflow service
configuration from redis:latest to a reviewed Redis version pinned by its
immutable SHA-256 digest, preserving the existing service setup.

In `@packages/server/src/lockers/RedisLocker.ts`:
- Around line 136-146: Initialize the internal abort controller in
RedisLocker.lock and IoRedisLocker.lock as already aborted when
stopSignal.aborted is true, while preserving the existing abort event listener
for later cancellation; update both packages/server/src/lockers/RedisLocker.ts
lines 136-146 and packages/server/src/lockers/IoRedisLocker.ts lines 142-152.
- Around line 168-185: Prevent a late acquireLock() winner from retaining an
orphaned lock after timeout or abort: in
packages/server/src/lockers/RedisLocker.ts at lines 168-185, recheck
cancellation after SET and subscription, then unsubscribe, release ownership,
and return without starting the watchdog; apply the equivalent rollback in
packages/server/src/lockers/IoRedisLocker.ts at lines 174-193, including cleanup
of releaseHandlers and acquired before returning.

In `@packages/server/src/test/IoRedisLocker.test.ts`:
- Around line 38-46: Update the before hook around client connection in
packages/server/src/test/IoRedisLocker.test.ts (lines 38-46) to rethrow
connection failures when running in CI and call this.skip() only outside CI;
apply the same behavior to the corresponding setup hook in
packages/server/src/test/RedisLocker.test.ts (lines 41-48).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 48b1cc61-11c1-401f-8c31-89b89002bbde

📥 Commits

Reviewing files that changed from the base of the PR and between 6112691 and 2091d9c.

📒 Files selected for processing (7)
  • .changeset/spicy-beans-bet.md
  • .github/workflows/ci.yml
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/lockers/RedisLocker.ts
  • packages/server/src/lockers/index.ts
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/test/RedisLocker.test.ts

Comment thread .github/workflows/ci.yml Outdated
Comment on lines +136 to +146
async lock(stopSignal: AbortSignal, requestRelease: RequestRelease): Promise<void> {
const abortController = new AbortController()
const onAbort = () => {
abortController.abort()
}
stopSignal.addEventListener('abort', onAbort)

try {
const lock = await Promise.race([
this.waitTimeout(abortController.signal),
this.acquireLock(this.id, requestRelease, abortController.signal),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate an already-aborted signal before acquisition begins.

  • packages/server/src/lockers/RedisLocker.ts#L136-L146: initialize the internal controller as aborted when stopSignal.aborted is already true.
  • packages/server/src/lockers/IoRedisLocker.ts#L142-L152: apply the same initial abort propagation.
📍 Affects 2 files
  • packages/server/src/lockers/RedisLocker.ts#L136-L146 (this comment)
  • packages/server/src/lockers/IoRedisLocker.ts#L142-L152
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/lockers/RedisLocker.ts` around lines 136 - 146,
Initialize the internal abort controller in RedisLocker.lock and
IoRedisLocker.lock as already aborted when stopSignal.aborted is true, while
preserving the existing abort event listener for later cancellation; update both
packages/server/src/lockers/RedisLocker.ts lines 136-146 and
packages/server/src/lockers/IoRedisLocker.ts lines 142-152.

Comment on lines +168 to +185
const lock = await this.locker.redis.set(this.locker.key(id), this.token, {
condition: 'NX',
expiration: {
type: 'PX',
value: this.locker.lockTimeout,
},
})

if (lock) {
const {subscriber} = this.locker

// Stores the requestRelease callback which later gets invoked once a message from Redis is received.
this.listener = () => {
void Promise.resolve(requestRelease()).catch(() => {})
}
await subscriber.subscribe(this.locker.channel(id), this.listener)

this.startWatchdog(id, requestRelease)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Prevent late Redis acquisition from creating permanently renewed orphan locks.

Both implementations allow the losing acquireLock() branch of Promise.race() to continue after timeout or abort.

  • packages/server/src/lockers/RedisLocker.ts#L168-L185: recheck cancellation after SET and subscription, rolling back ownership and subscription before returning.
  • packages/server/src/lockers/IoRedisLocker.ts#L174-L193: perform equivalent rollback, including releaseHandlers and acquired cleanup.
📍 Affects 2 files
  • packages/server/src/lockers/RedisLocker.ts#L168-L185 (this comment)
  • packages/server/src/lockers/IoRedisLocker.ts#L174-L193
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/lockers/RedisLocker.ts` around lines 168 - 185, Prevent a
late acquireLock() winner from retaining an orphaned lock after timeout or
abort: in packages/server/src/lockers/RedisLocker.ts at lines 168-185, recheck
cancellation after SET and subscription, then unsubscribe, release ownership,
and return without starting the watchdog; apply the equivalent rollback in
packages/server/src/lockers/IoRedisLocker.ts at lines 174-193, including cleanup
of releaseHandlers and acquired before returning.

Comment thread packages/server/src/test/IoRedisLocker.test.ts
…ion and throw err if redis unavailable during tests
@Olebeh
Olebeh requested a deployment to external-testing July 18, 2026 19:30 — with GitHub Actions Waiting

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/server/src/test/MemoryLocker.test.ts (1)

109-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use assert.rejects to prevent misleading failure messages.

If lock2.lock unexpectedly resolves, assert(false) throws an AssertionError. The generic catch block catches this AssertionError, evaluates e === ERRORS.ERR_LOCK_TIMEOUT (which is false), and throws a new AssertionError with a confusing message: "error returned is not correct AssertionError: lock2 should not have been acquired".

Using assert.rejects standardizes the rejection check and prevents swallowing the original failure context.

🛠️ Proposed fix
-    try {
-      await lock2.lock(abortController.signal, async () => {
-        cancel2()
-      })
-      assert(false, 'lock2 should not have been acquired')
-    } catch (e) {
-      assert(e === ERRORS.ERR_LOCK_TIMEOUT, `error returned is not correct ${e}`)
-    }
+    await assert.rejects(
+      lock2.lock(abortController.signal, async () => {
+        cancel2()
+      }),
+      (e: any) => {
+        assert(e === ERRORS.ERR_LOCK_TIMEOUT, `error returned is not correct ${e}`)
+        return true
+      }
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/test/MemoryLocker.test.ts` around lines 109 - 116,
Replace the manual try/catch and assert(false) around lock2.lock with
assert.rejects, asserting that the promise rejects with ERRORS.ERR_LOCK_TIMEOUT.
Preserve the existing lock2.lock invocation and cancellation behavior while
allowing unexpected resolution or rejection errors to retain their original
assertion context.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/test/MemoryLocker.test.ts`:
- Around line 49-57: Update the test around lock.lock to use assert.rejects,
ensuring the test fails when the promise unexpectedly resolves. Validate the
rejected value against ERRORS.ERR_LOCK_TIMEOUT without treating it as an Error
or accessing e.message, while preserving the existing body and timeout
assertions.

---

Nitpick comments:
In `@packages/server/src/test/MemoryLocker.test.ts`:
- Around line 109-116: Replace the manual try/catch and assert(false) around
lock2.lock with assert.rejects, asserting that the promise rejects with
ERRORS.ERR_LOCK_TIMEOUT. Preserve the existing lock2.lock invocation and
cancellation behavior while allowing unexpected resolution or rejection errors
to retain their original assertion context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8dd4d9bb-4daf-4fbd-9fb7-9ab363ae556e

📥 Commits

Reviewing files that changed from the base of the PR and between 2091d9c and c4c7f70.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/lockers/RedisLocker.ts
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/test/MemoryLocker.test.ts
  • packages/server/src/test/RedisLocker.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/test/RedisLocker.test.ts
  • packages/server/src/lockers/RedisLocker.ts
  • .github/workflows/ci.yml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/server/src/test/MemoryLocker.test.ts (1)

109-116: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use assert.rejects to prevent misleading failure messages.

If lock2.lock unexpectedly resolves, assert(false) throws an AssertionError. The generic catch block catches this AssertionError, evaluates e === ERRORS.ERR_LOCK_TIMEOUT (which is false), and throws a new AssertionError with a confusing message: "error returned is not correct AssertionError: lock2 should not have been acquired".

Using assert.rejects standardizes the rejection check and prevents swallowing the original failure context.

🛠️ Proposed fix
-    try {
-      await lock2.lock(abortController.signal, async () => {
-        cancel2()
-      })
-      assert(false, 'lock2 should not have been acquired')
-    } catch (e) {
-      assert(e === ERRORS.ERR_LOCK_TIMEOUT, `error returned is not correct ${e}`)
-    }
+    await assert.rejects(
+      lock2.lock(abortController.signal, async () => {
+        cancel2()
+      }),
+      (e: any) => {
+        assert(e === ERRORS.ERR_LOCK_TIMEOUT, `error returned is not correct ${e}`)
+        return true
+      }
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/test/MemoryLocker.test.ts` around lines 109 - 116,
Replace the manual try/catch and assert(false) around lock2.lock with
assert.rejects, asserting that the promise rejects with ERRORS.ERR_LOCK_TIMEOUT.
Preserve the existing lock2.lock invocation and cancellation behavior while
allowing unexpected resolution or rejection errors to retain their original
assertion context.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/test/MemoryLocker.test.ts`:
- Around line 49-57: Update the test around lock.lock to use assert.rejects,
ensuring the test fails when the promise unexpectedly resolves. Validate the
rejected value against ERRORS.ERR_LOCK_TIMEOUT without treating it as an Error
or accessing e.message, while preserving the existing body and timeout
assertions.

---

Nitpick comments:
In `@packages/server/src/test/MemoryLocker.test.ts`:
- Around line 109-116: Replace the manual try/catch and assert(false) around
lock2.lock with assert.rejects, asserting that the promise rejects with
ERRORS.ERR_LOCK_TIMEOUT. Preserve the existing lock2.lock invocation and
cancellation behavior while allowing unexpected resolution or rejection errors
to retain their original assertion context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8dd4d9bb-4daf-4fbd-9fb7-9ab363ae556e

📥 Commits

Reviewing files that changed from the base of the PR and between 2091d9c and c4c7f70.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/lockers/RedisLocker.ts
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/test/MemoryLocker.test.ts
  • packages/server/src/test/RedisLocker.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/server/src/test/IoRedisLocker.test.ts
  • packages/server/src/lockers/IoRedisLocker.ts
  • packages/server/src/test/RedisLocker.test.ts
  • packages/server/src/lockers/RedisLocker.ts
  • .github/workflows/ci.yml
🛑 Comments failed to post (1)
packages/server/src/test/MemoryLocker.test.ts (1)

49-57: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent false positives by failing if no error is thrown.

The current try...catch block does not fail the test if lock.lock() unexpectedly succeeds, meaning the test could silently pass when it should fail. Additionally, because ERRORS.ERR_LOCK_TIMEOUT is an object and not an Error instance, e.message evaluates to undefined.

Using assert.rejects ensures the promise is rejected and handles the validation cleanly.

🛠️ Proposed fix
-    try {
-      await lock.lock(abortController.signal, async () => {
-        throw new Error('panic should not be called')
-      })
-    } catch (e) {
-      assert(!(e instanceof Error), `error returned is not correct ${e.message}`)
-      assert('body' in e, 'body is not present in the error')
-      assert(e.body === ERRORS.ERR_LOCK_TIMEOUT.body)
-    }
+    await assert.rejects(
+      lock.lock(abortController.signal, async () => {
+        throw new Error('panic should not be called')
+      }),
+      (e: any) => {
+        assert(!(e instanceof Error), 'error returned is not correct')
+        assert('body' in e, 'body is not present in the error')
+        assert(e.body === ERRORS.ERR_LOCK_TIMEOUT.body)
+        return true
+      }
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    await assert.rejects(
      lock.lock(abortController.signal, async () => {
        throw new Error('panic should not be called')
      }),
      (e: any) => {
        assert(!(e instanceof Error), 'error returned is not correct')
        assert('body' in e, 'body is not present in the error')
        assert(e.body === ERRORS.ERR_LOCK_TIMEOUT.body)
        return true
      }
    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/test/MemoryLocker.test.ts` around lines 49 - 57, Update
the test around lock.lock to use assert.rejects, ensuring the test fails when
the promise unexpectedly resolves. Validate the rejected value against
ERRORS.ERR_LOCK_TIMEOUT without treating it as an Error or accessing e.message,
while preserving the existing body and timeout assertions.

@Olebeh

Olebeh commented Jul 18, 2026

Copy link
Copy Markdown
Author

yo im not even maintaining memory locker

@Murderlon Murderlon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thermo-nuclear maintainability pass: the API direction is useful, but the current implementation duplicates a non-trivial distributed-lock state machine and its tests. That has already required the same race fix in two places and leaves failure-atomicity gaps around subscription cleanup. I think this needs structural consolidation before merge; the inline comments describe a concrete shape.

/**
* @author Oleg Mykula <oleg.mukula@gmail.com>
*/
export class IoRedisLocker implements Locker {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is almost a second full copy of RedisLocker: the Lua scripts, timeout/abort race, retry recursion, ownership rollback, watchdog, key/channel naming, and unlock flow are duplicated; only the client command shapes and subscription bookkeeping differ. We already had to apply the late-acquisition fix in both files, which is exactly the drift risk this structure creates.

Please extract one client-agnostic lock engine behind a small adapter (tryAcquire, publish, subscribe/cleanup, compareAndDelete, compareAndExtend) and keep RedisLocker/IoRedisLocker as thin public wrappers. A single deadline/abort-aware acquisition loop could also remove the losing Promise.race branch—and the rollback complexity it created—instead of preserving it twice. This is the code-judo move here: delete most of these ~300 lines rather than maintain two distributed state machines.

this.listener = () => {
void Promise.resolve(requestRelease()).catch(() => {})
}
await subscriber.subscribe(this.locker.channel(id), this.listener)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ownership transition is not failure-atomic. Once SET NX succeeds, subscribe() can reject and control exits before the abort rollback below, leaving the key owned until its TTL. The mirror image exists in unlock(): unsubscribe() is awaited before the compare-and-delete, so a subscription cleanup failure prevents releasing ownership after the watchdog has already been stopped. I reproduced both paths with a failing subscriber stub; the release EVAL was called zero times.

Please make compare-and-delete a best-effort finally invariant for both failed acquisition setup and unlock, and add tests for subscribe/unsubscribe rejection. This lifecycle should live once in the shared engine so the two clients cannot diverge.


const REDIS_URL = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'

describe('IoRedisLocker', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 174 lines duplicate the RedisLocker behavioral suite nearly verbatim, so every new invariant and regression test must be remembered twice. Please extract a shared locker contract suite that accepts an async factory/cleanup fixture and run it for both adapters (and for MemoryLocker where the contract applies); keep only client-specific wiring tests here. That would make the implementations genuinely interchangeable and ensure fixes such as abort propagation and failed-subscription rollback are exercised consistently.

@Olebeh

Olebeh commented Jul 19, 2026

Copy link
Copy Markdown
Author

Thermo-nuclear maintainability pass: the API direction is useful, but the current implementation duplicates a non-trivial distributed-lock state machine and its tests. That has already required the same race fix in two places and leaves failure-atomicity gaps around subscription cleanup. I think this needs structural consolidation before merge; the inline comments describe a concrete shape.

Thanks for a thorough review, I'll work on it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants