Skip to content

Bound exclusive lock acquisition in SharedLock to fail fast on a dead… - #3882

Open
mdsarfarazulh wants to merge 1 commit into
redis:mainfrom
mdsarfarazulh:issue-3880
Open

Bound exclusive lock acquisition in SharedLock to fail fast on a dead…#3882
mdsarfarazulh wants to merge 1 commit into
redis:mainfrom
mdsarfarazulh:issue-3880

Conversation

@mdsarfarazulh

@mdsarfarazulh mdsarfarazulh commented Aug 7, 2026

Copy link
Copy Markdown

… holder or leaked writer #3804 #3880

Make sure that:

  • You have read the contribution guidelines.
  • You have created a feature request first to discuss your contribution intent. Please reference the feature request ticket number in the pull request.
  • You applied code formatting rules using the mvn formatter:format target. Don’t submit any formatting related changes.
  • You submit test cases (unit or integration tests) that back your changes.

Note

Medium Risk
Changes core connection lifecycle locking and close behavior on all endpoints; incorrect timeout or error handling could affect close/reconnect under contention, but behavior is heavily tested and only alters previously infinite-wait failure modes.

Overview
Fixes #3804 and #3880 by replacing unbounded exclusive locking in SharedLock with a 30s default timeout (configurable via constructor for tests). Exclusive acquisition uses timed tryLock instead of blocking forever when the internal guard lock was leaked by a dead thread, and writer draining uses a deadline-bounded spin instead of infinite CPU burn when shared writers or abandoned exclusive state (writers == -1) can never clear.

On timeout, doExclusive throws RedisException with messages that distinguish leaked guard locks, leaked shared writers, and abandoned exclusive mode, indicating the endpoint must be rebuilt.

DefaultEndpoint.closeAsync and ClusterNodeEndpoint.closeAsync now catch failures from exclusive command drain/cancel during close, still proceed with channel teardown where applicable, and complete the close future exceptionally (with suppressed causes when both drain and channel close fail) rather than hanging Netty event-loop threads.

Unit tests cover lock timeout scenarios, interrupted-thread close, and cluster node close behavior.

Reviewed by Cursor Bugbot for commit 4582c5e. Bugbot is set up for automated code reviews on this repo. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df39962fe2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java Outdated
Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java Outdated
Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java
Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2888654b5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java Outdated
Comment on lines +172 to +174
throw new RedisException("Timed out after " + TimeUnit.NANOSECONDS.toMillis(exclusiveLockTimeoutNanos)
+ "ms acquiring the exclusive SharedLock; the lock holder likely died inside the guarded region. "
+ "The endpoint must be rebuilt to recover.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Complete close futures when lock acquisition times out

When this timeout is reached from DefaultEndpoint.closeAsync() via cancelBufferedCommands(), the endpoint has already changed STATUS from open to closed before calling doExclusive; throwing synchronously here aborts the close path before the channel is closed or closeFuture is completed, so subsequent closeAsync() callers get the same permanently incomplete future instead of the endpoint being rebuilt or failing the close future. Handle this timeout in the close path, or complete the close future exceptionally, before leaving the endpoint in the closed state.

Useful? React with 👍 / 👎.

Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 830de44. Configure here.

Comment thread src/main/java/io/lettuce/core/protocol/DefaultEndpoint.java
Comment thread src/main/java/io/lettuce/core/protocol/SharedLock.java

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 830de44607

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

long timeoutNanos = deadline - System.nanoTime();
if (timeoutNanos > 0) {
try {
acquired = lock.tryLock(timeoutNanos, TimeUnit.NANOSECONDS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep contended guard acquisition non-interruptible

When the caller enters closeAsync() with its interrupt flag already set while another thread is legitimately in a doExclusive section such as notifyChannelActive(), the new preflight tryLock() only handles the uncontended case; once it returns false, this interruptible timed lock throws immediately instead of waiting for normal contention to clear. That turns a non-leaked lock into a RedisException/exceptional close solely because the caller was already interrupted, whereas the old lock.lock() path ignored interrupts, so the bounded acquisition should preserve the flag but continue waiting until acquisition or timeout.

Useful? React with 👍 / 👎.

Comment on lines +630 to +631
if (cancelError != null) {
closeFuture.completeExceptionally(cancelError);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fail buffered commands when close cancellation times out

When closeAsync() is called while disconnected (channel == null) and there are commands in disconnectedBuffer, the new SharedLock timeout path makes cancelBufferedCommands() throw before drainCommands() runs; this branch only completes the close future exceptionally. Because no channel close/inactive callback follows in the null-channel case, those queued command futures remain in the buffer and never complete after close returns, so the timeout fallback should also drain/fail the buffered commands or otherwise complete them.

Useful? React with 👍 / 👎.

Comment on lines +423 to +424
ArgumentCaptor<io.netty.util.concurrent.GenericFutureListener> captor = ArgumentCaptor
.forClass(io.netty.util.concurrent.GenericFutureListener.class);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Import GenericFutureListener instead of qualifying it

This new test inlines io.netty.util.concurrent.GenericFutureListener in both the captor type and .forClass(...). The repo's Java style requires imports over fully-qualified names except for genuine same-name clashes, and there is no conflicting GenericFutureListener in this file, so this should be imported and referenced by simple name.

AGENTS.md reference: AGENTS.md:L150-L152

Useful? React with 👍 / 👎.

@mdsarfarazulh

Copy link
Copy Markdown
Author

@a-TODO-rov please have a look at this PR.

Comment on lines +223 to +230
throw new RedisException("Timed out after " + TimeUnit.NANOSECONDS.toMillis(exclusiveLockTimeoutNanos)
+ "ms acquiring the exclusive SharedLock; the exclusive lock was likely abandoned by a previous holder. "
+ "The endpoint must be rebuilt to recover.");
}

throw new RedisException("Timed out after " + TimeUnit.NANOSECONDS.toMillis(exclusiveLockTimeoutNanos)
+ "ms waiting for " + currentWriters + " shared writer(s) to drain while acquiring the "
+ "exclusive SharedLock; a shared writer was likely leaked. The endpoint must be rebuilt to recover.");

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.

What happens to the connection after exception is thrown ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

When a RedisException is thrown due to the SharedLock timeout, the connection and endpoint are permanently closed and cleaned up:

  1. State Transition: DefaultEndpoint.STATUS and RedisChannelHandler.CLOSED transition to ST_CLOSED before the lock is acquired, ensuring isClosed() == true, isOpen() == false, and any
    subsequent command writes fail immediately ("Connection is closed").
  2. Watchdog Disarmed: connectionWatchdog.prepareClose() is invoked before lock acquisition, preventing background reconnect loops.
  3. Channel & Socket Teardown: Even when the lock fails fast, DefaultEndpoint.closeAsync() proceeds to invoke channel.close(), releasing Netty pipeline handlers and the underlying TCP socket. Any
    channel close failure is attached as a suppressed exception.
  4. Buffered Commands Drained: Even on lock timeout, buffered/queued commands are drained and cancelled directly (lock-free) so awaiting callers do not hang.
  5. Pool / Listener Eviction: RedisChannelHandler catches the close future completion and fires closeEvents.fireEventClosed(this). Connection pools (BoundedAsyncPool, GenericObjectPool, cluster
    node pools) receive the event/exception, evict the damaged connection, and create a fresh instance on subsequent requests.
  6. Why Rebuilding is Advised: A SharedLock timeout indicates a thread terminated inside an exclusive section or leaked writer state, breaking internal concurrency invariants. Rebuilding the
    endpoint/connection provides a fresh lock with clean state.

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