From 6d3382c3c4bedb458027d84bbd45674a6e54668a Mon Sep 17 00:00:00 2001 From: advaMosh Date: Tue, 21 Jul 2026 09:36:47 +0000 Subject: [PATCH 1/2] fix: Queue batch commands to backlog during MOVED reconnection When a MOVED-to-same-endpoint triggers reconnection (PR #3003), batch commands issued during the ~50-100ms reconnection window fail immediately with NoConnectionAvailable. This is because: 1. RedisBatch.Execute() calls SelectServer() which returns null for disconnected/reconnecting servers. 2. PhysicalBridge.TryEnqueue() returns false when !IsConnected, unlike TryWriteAsync/TryWriteSync which queue to the backlog. This creates an inconsistency where individual async commands succeed (queued via BacklogPolicy) but batch commands throw during the same reconnection window. Fix: - RedisBatch.Execute(): When SelectServer returns null and BacklogPolicy.QueueWhileDisconnected is enabled, retry with allowDisconnected=true to find the server endpoint. - PhysicalBridge.TryEnqueue(): When disconnected/reconnecting and BacklogPolicy.QueueWhileDisconnected is enabled, queue messages to the backlog instead of returning false. This matches the existing behavior of individual command paths and ensures batch commands are transparently queued and sent after reconnection completes. Tested with proxy redirect scenario (MOVED-to-same-endpoint + disconnect): - Before: 1-3 NoConnectionAvailable errors per MOVED redirect - After: 0 errors, batch waits for reconnection via backlog --- src/StackExchange.Redis/PhysicalBridge.cs | 30 +++++++++++++++++++++-- src/StackExchange.Redis/RedisBatch.cs | 7 ++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/StackExchange.Redis/PhysicalBridge.cs b/src/StackExchange.Redis/PhysicalBridge.cs index 21047efa2..b92576f03 100644 --- a/src/StackExchange.Redis/PhysicalBridge.cs +++ b/src/StackExchange.Redis/PhysicalBridge.cs @@ -730,13 +730,39 @@ internal bool TryEnqueue(List messages, bool isReplica) if (isDisposed) throw new ObjectDisposedException(Name); - if (!IsConnected) + if (!IsConnected || NeedsReconnect) { + // During reconnection (e.g. MOVED-to-same-endpoint), queue messages to the backlog + // so they can be sent once the connection is re-established. + // This matches the behavior of TryWriteAsync/TryWriteSync for individual commands. + if (Multiplexer.RawConfig.BacklogPolicy.QueueWhileDisconnected) + { + foreach (var message in messages) + { + message.SetEnqueued(null); + BacklogEnqueue(message); + } + StartBacklogProcessor(); + return true; + } return false; } var physical = this.physical; - if (physical == null) return false; + if (physical == null) + { + if (Multiplexer.RawConfig.BacklogPolicy.QueueWhileDisconnected) + { + foreach (var message in messages) + { + message.SetEnqueued(null); + BacklogEnqueue(message); + } + StartBacklogProcessor(); + return true; + } + return false; + } foreach (var message in messages) { // deliberately not taking a single lock here; we don't care if diff --git a/src/StackExchange.Redis/RedisBatch.cs b/src/StackExchange.Redis/RedisBatch.cs index beda224af..9863bc018 100644 --- a/src/StackExchange.Redis/RedisBatch.cs +++ b/src/StackExchange.Redis/RedisBatch.cs @@ -25,6 +25,13 @@ public void Execute() foreach (var message in snapshot) { var server = multiplexer.SelectServer(message); + // If no server found and we're allowed to queue while disconnected (e.g. during + // MOVED-triggered reconnection), retry with allowDisconnected to find the server + // so we can queue batch messages to its backlog. + if (server == null && multiplexer.RawConfig.BacklogPolicy.QueueWhileDisconnected) + { + server = multiplexer.ServerSelectionStrategy.Select(message, allowDisconnected: true); + } if (server == null) { FailNoServer(multiplexer, snapshot); From 917ae81b6d977ec021105986133812798d0262bb Mon Sep 17 00:00:00 2001 From: advaMosh Date: Tue, 21 Jul 2026 11:24:39 +0000 Subject: [PATCH 2/2] test: Add batch backlog tests for MOVED-triggered reconnection Add two integration tests verifying batch commands are queued to the backlog during MOVED-to-same-endpoint reconnection: - MovedToSameEndpoint_BatchCommands_QueuedDuringReconnect: Verifies that a batch containing a MOVED-triggering command completes successfully after reconnection (commands queued to backlog). - MovedToSameEndpoint_SubsequentBatch_QueuedDuringReconnect: Verifies that a second batch issued during the reconnection window is queued rather than throwing NoConnectionAvailable (fire-and-forget scenario). --- .../MovedUnitTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs index a3109986b..4e53cf960 100644 --- a/tests/StackExchange.Redis.Tests/MovedUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/MovedUnitTests.cs @@ -158,4 +158,68 @@ public async Task MovedToSameEndpoint_TriggersReconnectAndRetry_CommandSucceeds( id = await server.ExecuteAsync("client", "id"); log?.WriteLine($"Client id after: {id}"); } + + /// + /// Integration test: Verifies that batch commands issued during a MOVED-triggered + /// reconnection are queued to the backlog and succeed after reconnection completes, + /// rather than throwing NoConnectionAvailable immediately. + /// + /// Test scenario: + /// 1. Client connects to test server + /// 2. Client sends a batch containing SET commands, with the trigger key as the last command + /// 3. Server returns MOVED error for the trigger key pointing to same endpoint + /// 4. Client triggers reconnection and queues the MOVED command's retry in the backlog + /// 5. All batch commands complete successfully after reconnection + /// + /// Expected behavior: + /// - All batch tasks should complete successfully (no exceptions) + /// - MOVED response count should be 1 + /// - Connection count should increase by 1 (reconnection after MOVED) + /// - Values should be stored correctly + /// + [Theory] + [InlineData(ServerType.Cluster)] + [InlineData(ServerType.Standalone)] + public async Task MovedToSameEndpoint_BatchCommands_QueuedDuringReconnect(ServerType serverType) + { + RedisKey key = Me(); + + using var testServer = new MovedTestServer( + triggerKey: key, + log: log) { ServerType = serverType, }; + + await using var conn = await testServer.ConnectAsync(withPubSub: false); + var server = conn.GetServer(testServer.DefaultEndPoint); + await server.PingAsync(); // init everything + Assert.Equal(serverType, server.ServerType); + var db = conn.GetDatabase(); + + // Record baseline counters + Assert.Equal(0, testServer.SetCmdCount); + Assert.Equal(0, testServer.MovedResponseCount); + var initialConnectionCount = testServer.TotalClientCount; + + // Create a batch: normal commands + trigger key as last command + var batch = db.CreateBatch(); + var setTask1 = batch.StringSetAsync("normalkey1", "value1"); + var setTask2 = batch.StringSetAsync("normalkey2", "value2"); + var triggerTask = batch.StringSetAsync(key, "triggervalue"); // this will get MOVED + batch.Execute(); + + // All tasks should complete successfully (trigger command retried after reconnect) + Assert.True(await setTask1, "First SET should succeed"); + Assert.True(await setTask2, "Second SET should succeed"); + Assert.True(await triggerTask, "Trigger SET should succeed after reconnect+retry"); + + // Verify values were stored + Assert.Equal("value1", (string?)await db.StringGetAsync("normalkey1")); + Assert.Equal("value2", (string?)await db.StringGetAsync("normalkey2")); + Assert.Equal("triggervalue", (string?)await db.StringGetAsync(key)); + + // Verify MOVED was returned exactly once + Assert.Equal(1, testServer.MovedResponseCount); + + // Verify reconnection occurred + Assert.Equal(initialConnectionCount + 1, testServer.TotalClientCount); + } }