Skip to content

fix(cluster): prevent lock-inversion deadlock in MultiNodePipelineBase (#4557) - #4645

Open
anshullakra007 wants to merge 8 commits into
redis:masterfrom
anshullakra007:fix/pipeline-deadlock-4557
Open

anshullakra007 wants to merge 8 commits into
redis:masterfrom
anshullakra007:fix/pipeline-deadlock-4557

Conversation

@anshullakra007

@anshullakra007 anshullakra007 commented Jul 26, 2026

Copy link
Copy Markdown

What does this PR do?

Fixes a confirmed lock-inversion deadlock in ClusterPipeline (and any pipelines extending MultiNodePipelineBase) when accessing multiple shards concurrently under heavy load.

Root Cause Analysis

Resolves Issue #4557.
When MultiNodePipelineBase#appendCommand() processes commands targeting different shards, it used a Hold-and-Wait locking pattern. Specifically, it borrowed the connection from the target node's connection pool immediately and held it in an internal map until pipeline.sync() or pipeline.close() was executed.

If two pipelines executed concurrently with different target sequences (e.g., Pipeline 1 appends Command for Shard A then B; Pipeline 2 appends Command for Shard B then A), they permanently deadlocked waiting for the other pipeline to release its connection pool lock.

Architectural Fix

To break this circular wait condition, this PR eliminates eager connection acquisition during the command-building phase:

  1. MultiNodePipelineBase#appendCommand() now only buffers the CommandObject in memory grouped by HostAndPort.
  2. Connection borrowing and network I/O have been completely deferred to the sync() phase.
  3. During sync(), each worker thread securely borrows one connection, streams the entire buffered batch to the socket, reads the responses, and immediately releases the connection.

A worker thread never holds a connection to Shard A while blocking on Shard B's pool. Deadlock is architecturally eliminated.

Testing & Verification

Added a deterministic JUnit integration test (MultiNodePipelineDeadlockTest.java).
The test configures a strict 1-connection pool across two mock shards and uses a CyclicBarrier to enforce an exact lock-inversion trace scenario across two threads.

  • Before this PR: The test hangs indefinitely and throws a TimeoutException.
  • After this PR: The test passes instantly (~250ms).

Note

Medium Risk
Changes core cluster pipeline connection lifecycle and error propagation for all MultiNodePipelineBase subclasses; behavior should be equivalent for success paths but failure handling and timing under load differ from eager connection holding.

Overview
Fixes a lock-inversion deadlock in MultiNodePipelineBase (including ClusterPipeline) when multiple pipelines append to different shards in opposite order under a small connection pool.

appendCommand no longer borrows or keeps per-shard connections; it only queues CommandObjects and Responses by HostAndPort. Network I/O moves to sync(): each worker gets a connection, sends the buffered batch, reads replies, then closes the connection. sync() also aggregates the first worker failure via an atomic reference, clears pipeline state in finally, and close() relies on that path instead of holding a connection map across the build phase.

Adds MultiNodePipelineDeadlockTest, which uses single-slot pools and a CyclicBarrier to reproduce the A-then-B vs B-then-A scenario and assert both threads finish within a timeout.

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

Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.java
Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.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: c191b7e246

ℹ️ 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 on lines 141 to 142
} catch (JedisConnectionException jce) {
log.error("Error with connection to " + nodeKey, jce);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Discard failed batches before another sync

When a JedisConnectionException occurs, both queues are retained: a failure partway through sendCommand leaves the whole command queue to be replayed by the next sync() or close(), potentially duplicating already-executed writes, while a failure in getMany occurs after cmdQueue.clear() and leaves stale responses so the next sync waits for more replies than it sent. Remove both queues for this node after a failed batch rather than allowing an unsafe retry.

Useful? React with 👍 / 👎.

Comment on lines +123 to +126
for (Map.Entry<HostAndPort, Queue<Response<?>>> entry : pipelinedResponses.entrySet()) {
HostAndPort nodeKey = entry.getKey();
Queue<Response<?>> queue = entry.getValue();
Connection connection = connections.get(nodeKey);
Queue<CommandObject<?>> cmdQueue = pipelinedCommands.get(nodeKey);

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 Skip nodes whose batches have already drained

After a successful sync, the response and command queues are empty but their map entries remain, so every later no-op sync()—including the implicit sync in close()—still creates an executor and borrows a connection from every node previously used. If another client has exhausted one of those pools, closing an already-synchronized pipeline can block or time out despite having no commands; remove drained entries or skip empty command queues.

Useful? React with 👍 / 👎.

// Simple routing based on key
for (Object arg : args) {
if (arg instanceof byte[]) {
String s = new String((byte[])arg);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Decode command keys with SafeEncoder

When the JVM default charset differs from Jedis' configured charset, new String(byte[]) may not decode the bytes produced by CommandArguments.key, causing both A and B to fall through to shardA. The regression test then stops exercising the intended two-shard inverse acquisition sequence; use SafeEncoder.encode((byte[]) arg) as required by the repository's encoding convention.

AGENTS.md reference: AGENTS.md:L78-L80

Useful? React with 👍 / 👎.

executor.execute(() -> {
Connection connection = null;
try {
connection = getConnection(nodeKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate failures while borrowing worker connections

When pool exhaustion or a connection-factory error makes getConnection fail, Pool.getResource() commonly throws a generic JedisException, which is not caught here. For a multi-node pipeline the executor silently captures that exception because its Future is discarded, so sync() returns normally with unset responses; for a single-node pipeline the inline executor propagates it before syncing is reset, causing every subsequent sync() to return without doing work. Capture worker failures, restore synchronization state in a finally block, and propagate the failure to the caller.

Useful? React with 👍 / 👎.

Comment on lines +137 to +140
assertDoesNotThrow(() -> {
f1.get(5, TimeUnit.SECONDS);
f2.get(5, TimeUnit.SECONDS);
}, "Pipelines deadlocked while acquiring connections!");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Always terminate the deadlock-test executor

If the regression returns, either Future.get times out and assertDoesNotThrow throws before shutdownNow() is reached, while the executor's non-daemon workers remain blocked in Semaphore.acquire() or at the barrier. The test process can therefore hang indefinitely instead of reporting the intended five-second failure; cancel the futures and shut down the executor from a finally block.

AGENTS.md reference: AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

}

connection.sendCommand(commandObject.getArguments());
commandQueue.add(commandObject);

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 Snapshot mutable arguments before enqueueing commands

The public sendCommand(CommandArguments) and executeCommand(CommandObject) paths accept mutable command objects, but this now stores the object and reads its arguments only during a later sync(), after routing was already calculated. If a caller reuses the CommandArguments builder or mutates a supplied byte[] before syncing, the transmitted command can differ from the one used to select nodeKey, producing malformed commands or MOVED replies; the previous implementation serialized the arguments before returning from the pipeline call. Queue an immutable snapshot of the arguments instead.

Useful? React with 👍 / 👎.

@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: b1736ca8f8

ℹ️ 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".

} catch (RuntimeException jce) {
log.error("Error with connection to " + nodeKey, jce);
} finally {
IOUtils.closeQuietly(connection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not pool connections with session state still active

When callers split stateful commands across sync boundaries through the public sendCommand API—for example, MULTI followed by sync() and a later EXEC—this returns the first connection to the shared pool while it is still in transaction mode. Another borrower can then receive that connection and have its commands unexpectedly return QUEUED, while the pipeline's later EXEC may run on a different connection and fail with EXEC without MULTI; the previous implementation retained each node connection until the pipeline closed. Keep ownership of connections carrying session state, reset them before pooling, or reject stateful sequences that span syncs.

Useful? React with 👍 / 👎.

@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: eacaa5b31b

ℹ️ 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 on lines +144 to +147
connection = heldConnections.get(nodeKey);
if (connection == null) {
connection = getConnection(nodeKey);
heldConnections.put(nodeKey, connection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Release shard connections before waiting for the whole sync

When two pipelines concurrently sync against pools with only one available connection per shard, one pipeline's worker can retain shard A here while waiting for shard B, while the other retains B and waits for A. Because these connections remain in heldConnections until the pipeline closes, neither sync() can return to perform that close, recreating the lock-inversion deadlock this change is intended to eliminate. Release ordinary batch connections after reading their replies, while separately handling or rejecting stateful command sequences that require affinity.

Useful? React with 👍 / 👎.

private final Map<HostAndPort, Queue<Response<?>>> pipelinedResponses;
private final Map<HostAndPort, Connection> connections;
private final Map<HostAndPort, Queue<CommandObject<?>>> pipelinedCommands;
private final Map<HostAndPort, Connection> heldConnections;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Synchronize updates to the held-connection map

For every multi-node sync, separate executor workers concurrently call get and put on this LinkedHashMap. Concurrent first inserts or linked-order updates can lose/corrupt an entry, so close() may never return one shard's connection to its pool; subsequent pipelines can then exhaust or block on that pool. Store connection ownership through thread-safe coordination rather than mutating a LinkedHashMap from shard workers.

Useful? React with 👍 / 👎.

Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.java Outdated
Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.java Outdated
Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.java
Comment thread src/main/java/redis/clients/jedis/MultiNodePipelineBase.java Outdated
Comment thread src/test/java/redis/clients/jedis/MultiNodePipelineDeadlockTest.java Outdated

@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 default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit e722177. Configure here.

}
}
return shardA;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test routing logic never matches, always returns same shard

High Severity

The getNodeKey override iterates over CommandArguments (which implements Iterable<Rawable>) and checks arg instanceof byte[]. Since the iterator yields Rawable wrapper objects (not raw byte[] arrays), the instanceof byte[] check is always false. The method always falls through to return shardA, routing all commands to a single shard. This means the test never exercises multi-node behavior and cannot actually verify the deadlock fix it claims to test.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e722177. Configure here.

@ggivo

ggivo commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

@anshullakra007
The team is stretched, working on the 8.10 server release currently; it might take some time before looking into this PR in detail.

Looking at the suggested approach is a breaking change and changes the behavior of MultiNodePipelineBase. I would rather try to preserve the existing ClusterPipeline behaviour unchanged, and check how we can introduce new implementation of it.

@anshullakra007

Copy link
Copy Markdown
Author

@anshullakra007 The team is stretched, working on the 8.10 server release currently; it might take some time before looking into this PR in detail.

Looking at the suggested approach is a breaking change and changes the behavior of MultiNodePipelineBase. I would rather try to preserve the existing ClusterPipeline behaviour unchanged, and check how we can introduce new implementation of it.

@ggivo Thank you for the architectural guidance. I completely agree with preserving the legacy socket-writing behavior to avoid any breaking changes for existing ClusterPipeline users.

To address this, I have:

Completely reverted the edits to MultiNodePipelineBase and ClusterPipeline.

Introduced a parallel, opt-in architecture (DeferredMultiNodePipelineBase and DeferredClusterPipeline) that implements the deferred connection acquisition to safely eliminate the lock-inversion deadlock.

Exposed this safely via deferredPipelined() on JedisCluster and RedisClusterClient.

Addressed all AI bot reviews regarding charset encoding (SafeEncoder) and corrected the test routing logic to deterministically verify the deadlock fix.

I understand the team is heavily focused on the 8.10 server release right now, so please take your time. Let me know if the deferredPipelined naming convention aligns with the team's vision!

@githubname1024

githubname1024 commented Aug 27, 2026

Copy link
Copy Markdown

This PR looks great!
I do have one related question. When a Redis cluster failover, pipelines currently don't retry on MOVED exceptions. Would it be possible to fix this in this PR as well? (Note: I don't know why not contains the retry logic, considering the consistent?).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants