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); 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); + } }