Repository navigation
Bound SharedLock lockWritersExclusive spin with Thread.onSpinWait and timeout (#3880) - #3956
ronitgupta138 wants to merge 8 commits into
Conversation
… timeout (redis#3880) - Add Thread.onSpinWait() to the lockWritersExclusive() spin loop to avoid pegging CPU cores at 100% - Bound exclusive lock acquisition by a timeout (default 30 seconds) and fail fast with RedisException if shared writers fail to drain - Add unit tests verifying fail-fast behavior and validation in SharedLockUnitTests
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a7a06ef048
ℹ️ 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".
| return; | ||
| } | ||
|
|
||
| Thread.onSpinWait(); |
There was a problem hiding this comment.
Preserve Java 8 compatibility in the spin loop
When Lettuce is built on its supported Java 8 baseline, this call cannot be resolved because Thread.onSpinWait() was introduced in Java 9; pom.xml still compiles to 1.8, so the JDK 8 build fails, while an artifact compiled with a newer JDK can instead throw NoSuchMethodError on Java 8 as soon as contention reaches this branch. Use a Java 8-compatible pause/backoff mechanism here.
AGENTS.md reference: AGENTS.md:L61-L64
Useful? React with 👍 / 👎.
| throw new RedisException( | ||
| "Failed to acquire exclusive lock: shared writers failed to drain within " + exclusiveLockTimeout); |
There was a problem hiding this comment.
Keep channel-inactive cleanup running after lock timeout
When a shared writer is still active at the 30-second deadline during disconnect, this exception escapes DefaultEndpoint.notifyChannelInactive() through CommandHandler.channelInactive(). That skips notifyDrainQueuedCommands, the DEACTIVATED transition, and super.channelInactive(ctx) in that method; because ConnectionWatchdog is added downstream by ConnectionBuilder.buildHandlers(), it never receives the inactive event and auto-reconnect is not scheduled, leaving the connection permanently disconnected. Convert the timeout to a result or catch it in the lifecycle path so cleanup and event propagation still run.
AGENTS.md reference: AGENTS.md:L89-L93
Useful? React with 👍 / 👎.
|
Hey @ronitgupta138 |
…ycle on lock timeout (redis#3880) - Use dynamic MethodHandle for Thread.onSpinWait() to preserve Java 8 compiler and runtime compatibility - Guard endpoint lifecycle methods (channelInactive, notifyDrainQueuedCommands, closeAsync) against lock acquisition timeouts to ensure connection cleanup and auto-reconnect are not bypassed
|
Addressed the review findings in commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f25b3a14b8
ℹ️ 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".
| if (System.nanoTime() >= deadline) { | ||
| throw new RedisException( | ||
| "Failed to acquire exclusive lock: shared writers failed to drain within " + exclusiveLockTimeout); |
There was a problem hiding this comment.
Guard remaining lifecycle calls from the lock timeout
When a shared writer is still held at this deadline, the exception also escapes the unguarded DefaultEndpoint.notifyChannelActive() and the cancelBufferedCommands() call reached from closeAsync(). In the reconnect path, CommandHandler.channelActive() never reaches super.channelActive(), so the downstream watchdog/event trigger never sees the new channel and buffered commands remain disconnected; in the close path, ST_CLOSED has already been set but the channel and closeFuture have not been completed, causing every later close to return the same permanently incomplete future. Handle this timeout in both paths as is now done for inactive/drain handling.
Useful? React with 👍 / 👎.
…ufferedCommands against lock timeout (redis#3880) - Catch RedisException in DefaultEndpoint.notifyChannelActive() to activate directly and ensure super.channelActive() always runs - Guard cancelBufferedCommands() and closeAsync() so channel closure and closeFuture completion are never blocked by lock timeouts - Fall back to direct command draining in flushCommands() when exclusive lock acquisition times out
|
@a-TODO-rov Thanks for the review! Addressed the feedback and bot findings across commits
All unit tests ( |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ac00d863b
ℹ️ 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".
| sharedLock.doExclusive(activate); | ||
| } catch (RedisException e) { | ||
| logger.warn("{} Failed to acquire exclusive lock during notifyChannelActive, activating directly", logPrefix(), e); | ||
| activate.run(); |
There was a problem hiding this comment.
Avoid a second lock wait in the activation fallback
When the shared writer is still held after the first 30-second deadline, the new activate.run() fallback is not actually lock-free: it reaches flushCommands(...), which calls sharedLock.doExclusive again before even checking whether the buffer is empty. The channel-active event loop therefore spins for a second full timeout (about 60 seconds total) before super.channelActive, so reconnect processing remains stalled; reuse the original timeout result or otherwise avoid starting a fresh lock-acquisition period here.
Useful? React with 👍 / 👎.
| } catch (RedisException e) { | ||
| logger.warn("{} Failed to acquire exclusive lock during cancelBufferedCommands, draining commands directly", | ||
| logPrefix(), e); | ||
| cancelCommands(message, drainCommands(), RedisCommand::cancel); |
There was a problem hiding this comment.
Prevent writes from enqueueing after timed-out close
When an in-flight disconnected write is the writer that misses the deadline (for example, it is blocked in a configured replayFilter and resumes just after the timeout), this direct drain can finish before that command has been added. closeAsync then completes, but the writer resumes and writeToDisconnectedBuffer enqueues without rechecking ST_CLOSED; later closes only return the completed future, leaving the command queued indefinitely when command expiry is disabled. Ensure timed-out writers cannot enqueue after close or cancel them when they release.
Useful? React with 👍 / 👎.
| } catch (RedisException e) { | ||
| logger.warn("{} Failed to acquire exclusive lock during notifyChannelActive, activating directly", logPrefix(), e); | ||
| activate.run(); |
There was a problem hiding this comment.
Distinguish lock timeout from activation RedisExceptions
When connectionFacade.activated() throws a RedisException after the exclusive lock was successfully acquired, this catch treats it as an acquisition timeout and executes the entire activation a second time. A streaming credentials provider or Pub/Sub resubscription that fails after partial side effects can therefore be subscribed or dispatched twice while the original failure is misreported; use a timeout-specific exception or result instead of catching the base client exception.
Useful? React with 👍 / 👎.
| } catch (Exception e) { | ||
| logger.warn("{} notifyChannelActive failed", logPrefix(), e); | ||
| } |
There was a problem hiding this comment.
Do not report a failed endpoint activation as active
If notifyChannelActive throws for a non-timeout activation error (for example, a streaming credentials provider throws from ConnectionFacade.activated()), this catch suppresses it and still invokes super.channelActive. In the inspected ConnectionBuilder pipeline, that publishes ConnectionActivatedEvent and resets the watchdog even though DefaultEndpoint set the channel but never reached flushCommands, so buffered commands can remain stuck while consumers are told the connection succeeded; propagate or close the channel for non-recoverable activation failures.
Useful? React with 👍 / 👎.
… socket closure (redis#3880) - Use try-finally in CommandHandler.channelInactive to guarantee setState(DEACTIVATED) and super.channelInactive(ctx) always run - Use try-finally in DefaultEndpoint.closeAsync to guarantee socket closure and closeFuture completion without unlocked state fallbacks - Revert unlocked mutation fallbacks in DefaultEndpoint and ClusterNodeEndpoint to prevent data races with concurrent writers
|
Addressed the review findings in commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d6c23eb216
ℹ️ 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".
| return; | ||
| } | ||
|
|
||
| long deadline = System.nanoTime() + exclusiveLockTimeout.toNanos(); |
There was a problem hiding this comment.
Include the outer lock wait in the timeout
When multiple threads call doExclusive while one shared writer remains stuck, every caller except the first blocks at the outer lock.lock() in doExclusive, and this deadline is created only after that wait. The first caller times out after 30 seconds, then the second receives a fresh 30-second window, so its configured 30-second acquisition can take about 60 seconds (and N queued lifecycle calls can take N times 30 seconds), continuing to stall Netty event loops; apply one deadline to both the initial lock acquisition and the writer-drain loop.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in commit f3e2af8: bounded outer lock acquisition via lock.tryLock(timeout), applying a single unified timeout budget across both the outer lock wait and the writer-drain spin loop.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aba86d243d
ℹ️ 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".
| if (System.nanoTime() >= deadline) { | ||
| throw new RedisException( | ||
| "Failed to acquire exclusive lock: shared writers failed to drain within " + exclusiveLockTimeout); |
There was a problem hiding this comment.
Ensure cluster endpoints close after lock timeouts
When a cluster-node connection hits the stuck-writer case, this exception escapes ClusterNodeEndpoint.closeAsync(): its doExclusive(this::drainCommands) runs before super.closeAsync() (lines 50–58), so the endpoint's watchdog and channel are never closed. Through the normal RedisChannelHandler.closeAsync() path the handler has already set CLOSED before invoking the writer, leaving its closeFuture incomplete and making later close calls unable to retry; ensure the base endpoint close runs even when the cluster drain times out.
AGENTS.md reference: AGENTS.md:L86-L87
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in commit f3e2af8: ClusterNodeEndpoint.closeAsync() now guarantees that super.closeAsync() is always executed so the channel, watchdog, and closeFuture complete properly even if the cluster drain times out.
…leanup (redis#3880) - Bound outer lock acquisition with tryLock(timeout) so queued exclusive lock callers share the single configured timeout budget - Guarantee this.channel = null in DefaultEndpoint.notifyChannelInactive via finally block even if deactivation times out - Guarantee super.closeAsync() in ClusterNodeEndpoint.closeAsync() so watchdog and channels always close even if cluster drain times out Signed-off-by: Ronit Gupta <[email protected]>
|
Addressed the review findings in commit
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f3e2af8553
ℹ️ 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".
| setState(LifecycleState.DEACTIVATED); | ||
| PristineFallbackCommand command = this.fallbackCommand; | ||
| if (isProtectedMode(command)) { | ||
| onProtectedMode(command.getOutput().getError()); |
There was a problem hiding this comment.
Put inactive propagation in an inner finally
When a protected-mode fallback is present and the shared writer is still held, onProtectedMode() calls DefaultEndpoint.notifyException(), whose exclusive acquisition reaches the new timeout and throws. Because this call precedes super.channelInactive(ctx) inside the outer finally, this exception aborts the remainder of the block, so the downstream ConnectionWatchdog in ConnectionBuilder never sees the inactive event and reconnect is not scheduled. Fresh evidence beyond the earlier inactive-path report is this unguarded protected-mode acquisition in the new finally; put propagation in an inner finally.
Useful? React with 👍 / 👎.
| if (drainError != null) { | ||
| closeFuture.completeExceptionally(drainError); |
There was a problem hiding this comment.
Apply drain errors after the base close settles
When cluster draining fails while an open node channel is still closing, this completes the endpoint future immediately, before the ChannelFuture adapted by super.closeAsync() settles; the wrapping RedisChannelHandler can therefore fire its closed event and finish closeAsync() while the socket remains open. For an already-disconnected node whose base cleanup succeeds synchronously, the same future is already normally completed and completeExceptionally is a no-op, so the drain failure is reported as success instead. Compose a separate result that waits for the base close before applying drainError.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit f3e2af8. Configure here.
| CompletableFuture<Void> closeFuture = super.closeAsync(); | ||
| if (drainError != null) { | ||
| closeFuture.completeExceptionally(drainError); | ||
| } |
There was a problem hiding this comment.
Cluster close loses drain failure
High Severity
closeFuture.completeExceptionally(drainError) runs only after super.closeAsync(). A drain timeout makes super.closeAsync() wait on the same exclusive lock again and throw, so completeExceptionally never runs. If the parent future is already completed (typical when the node is disconnected and the channel is null), completeExceptionally is a no-op and queued commands are not retriggered.
Reviewed by Cursor Bugbot for commit f3e2af8. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df4a2ce1dd
ℹ️ 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".
| } | ||
|
|
||
| return super.closeAsync(); | ||
| CompletableFuture<Void> closeFuture = super.closeAsync(); |
There was a problem hiding this comment.
Reuse the first timeout during cluster close
When a cluster-node writer remains held across both deadlines, the initial doExclusive(this::drainCommands) has already spun for 30 seconds before throwing, and this unconditional base close enters DefaultEndpoint.closeAsync(), where cancelBufferedCommands() starts a second doExclusive against the same writer. Fresh evidence beyond the earlier activation-path report is this sequential cluster-drain/base-close pair in the final tree; cluster shutdown or stale-node cleanup can therefore block and burn CPU for about 60 seconds per affected endpoint despite the 30-second bound, so carry the timeout result into a lock-free base cleanup instead of reacquiring the lock.
Useful? React with 👍 / 👎.


Make sure that:
mvn formatter:formattarget. Don’t submit any formatting related changes.Fixes #3880
Description
In
SharedLock.lockWritersExclusive(), when another thread holds or leaks a shared writer without draining, the calling thread spins in an unboundedfor (;;)busy-loop at 100% CPU. Because the spinner holds the innerReentrantLockduring this loop, other threads (including IO event loops attemptingchannelInactive->doExclusive) become permanently blocked or wedged.As discussed in #3880 with @a-TODO-rov:
Thread.onSpinWait()in the spin loop to hint to the processor and mitigate CPU saturation.RedisExceptionif shared writers fail to drain within the deadline.SharedLock(Duration)allowing configuration/testing of timeouts.SharedLockUnitTestsverifying the fail-fast timeout and validation.Note
Medium Risk
Changes connection teardown and exclusive locking on Netty event loops; bounded timeouts can surface new
RedisExceptions during close/disconnect, but they replace prior deadlock and CPU spin scenarios.Overview
Fixes unbounded CPU spin and indefinite blocking when
SharedLockcannot acquire an exclusive lock because shared writers never drain (e.g. duringchannelInactive→doExclusive).SharedLock: The exclusive-acquire spin loop now usesThread.onSpinWait()when available (fallbackThread.yield()), and waits are bounded by a 30s default timeout viatryLockplus a deadline while draining writers. Failures surface asRedisException; a package-privateSharedLock(Duration)supports tests and custom timeouts.Lifecycle hardening:
CommandHandler.channelInactive,DefaultEndpoint.notifyChannelInactive, andDefaultEndpoint.closeAsyncwrap deactivation/cleanup intry/finallyso channel clearing,DEACTIVATEDstate, protected-mode handling, and channel close still run if earlier steps throw (including the new lock timeout).Cluster:
ClusterNodeEndpoint.closeAsynccatches drain failures, logs a warning, and completes the close future exceptionally instead of silently continuing.Unit tests cover timeout fail-fast, default timeout, and invalid timeout validation.
Reviewed by Cursor Bugbot for commit df4a2ce. Bugbot is set up for automated code reviews on this repo. Configure here.