From cc5731bc6919bbdb2d7baea2844c1b40fc7c677c Mon Sep 17 00:00:00 2001 From: Meera Sridharan Date: Mon, 2 Nov 2026 21:14:33 -0800 Subject: [PATCH 1/4] fixreview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/StackExchange.Redis/ClusterTopology.cs | 6 ++ .../ConnectionMultiplexer.cs | 81 +++++++++++++++---- .../ClusterTopologyUnitTests.cs | 29 ++++++- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/StackExchange.Redis/ClusterTopology.cs b/src/StackExchange.Redis/ClusterTopology.cs index a6ae7e3c2..52b549bfa 100644 --- a/src/StackExchange.Redis/ClusterTopology.cs +++ b/src/StackExchange.Redis/ClusterTopology.cs @@ -110,6 +110,12 @@ internal ClusterTopologyNode(ClusterSlotNode node, string? primaryNodeId) /// public IReadOnlyList Identities => _identities ?? []; + /// + /// The announced endpoint form to use for dialing. Companion identities such as an IP address must not be + /// registered as separate dialable servers when this preferred endpoint is a hostname. + /// + public EndPoint? PreferredDialEndPoint => Identities.Count > 0 ? Identities[0] : null; + /// The slots this node serves as primary; empty for a replica. public IReadOnlyList Slots => _slots ?? []; diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index 49a5682e5..b767a766e 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -2286,12 +2286,12 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => else if (listedInSlots!.Contains(node.EndPoint)) { // SLOTS lists it, so it is in clusterEndpoints and we are about to connect to it and - // wait for it. Only the *existence* of a server was ever checked here, which a node - // in this state fails - a replica of a shard we have not reached yet is created by - // its primary's genealogy pass, and that has not run. Registering it inert then left - // it waited on but undialled, for the whole ConnectTimeout (#3232) - log?.LogInformationRegisteringSlotMapNode(new(node.EndPoint)); - GetServerEndPoint(node.EndPoint, ServerProvenance.ClusterTopology, log); + // wait for it. Prefer the SLOTS hostname over its companion IP from NODES: that IP + // may be an unreachable pod address using the ingress client port. + var dialEndPoint = FindPreferredDialEndPoint(topology, node.EndPoint) ?? node.EndPoint; + log?.LogInformationRegisteringSlotMapNode(new(dialEndPoint)); + GetServerEndPoint(dialEndPoint, ServerProvenance.ClusterTopology, log); + if (!Equals(dialEndPoint, node.EndPoint)) RegisterServerIdentities(topology); } else { @@ -2320,18 +2320,44 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => return null; } - // an identity we already hold if there is one - otherwise the form the answering node advertised, - // which is what Identities is ordered by, and which is the form TLS can validate EndPoint? SelectIdentity(ClusterTopologyNode node) { + var preferred = node.PreferredDialEndPoint; + ServerEndPoint? preferredKnown = null, dnsKnown = null, anyKnown = null; + foreach (var identity in node.Identities) { - if (TryResolveServerEndPoint(identity) is { } known) return known.EndPoint; + if (TryResolveServerEndPoint(identity) is not { } known) continue; + anyKnown ??= known; + if (preferred is not null && Equals(known.EndPoint, preferred)) preferredKnown = known; + if (known.EndPoint is DnsEndPoint) dnsKnown ??= known; + } + + if (preferred is DnsEndPoint) + { + return (preferredKnown ?? dnsKnown)?.EndPoint ?? preferred; } - return node.Identities.Count > 0 ? node.Identities[0] : null; + + return (preferredKnown ?? anyKnown)?.EndPoint ?? preferred; } } + /// + /// Finds the SLOTS preferred dial endpoint for a node that also answers at + /// (typically the IP form from CLUSTER NODES). + /// + private static EndPoint? FindPreferredDialEndPoint(ClusterTopology topology, EndPoint identity) + { + foreach (var node in topology.Nodes) + { + foreach (var id in node.Identities) + { + if (Equals(id, identity)) return node.PreferredDialEndPoint; + } + } + return null; + } + private void ResetAllNonConnected() { var snapshot = GetServerSnapshot(); @@ -2552,8 +2578,8 @@ internal void UpdateClusterRange(ClusterTopology topology) { if (node.IsReplica || node.Slots.Count == 0) continue; - // resolve by *any* identity the node answers to before falling back to the form this reply - // happened to use; that is what keeps one node from becoming two ServerEndPoints + // Resolve the preferred server first, and do not adopt a companion IP server when hostnames + // are preferred. var server = ResolveOrCreate(node); if (server is null) continue; @@ -2565,15 +2591,36 @@ internal void UpdateClusterRange(ClusterTopology topology) ServerEndPoint? ResolveOrCreate(ClusterTopologyNode node) { + var preferred = node.PreferredDialEndPoint; + ServerEndPoint? preferredKnown = null, dnsKnown = null, anyKnown = null; + foreach (var identity in node.Identities) { - if (TryResolveServerEndPoint(identity) is { } known) return known; + if (TryResolveServerEndPoint(identity) is not { } known) continue; + anyKnown ??= known; + if (preferred is not null && Equals(known.EndPoint, preferred)) preferredKnown = known; + if (known.EndPoint is DnsEndPoint) dnsKnown ??= known; } - // unknown node: dial the form the answering node *advertised*, which Identities is ordered by. - // Not the address by preference: a certificate validates against a name, and where hostnames - // are preferred the advertised address may not even be routable (#2826) - return node.Identities.Count > 0 ? GetServerEndPoint(node.Identities[0], ServerProvenance.ClusterTopology) : null; + if (preferred is DnsEndPoint) + { + // Hostname-routed TLS/SNI clusters: never adopt a companion IP ServerEndPoint for + // the slot map. That IP is often the pod address with the ingress client port and + // does not survive pod restart. + if (preferredKnown is not null) return preferredKnown; + if (dnsKnown is not null) return dnsKnown; + return preferred is not null + ? GetServerEndPoint(preferred, ServerProvenance.ClusterTopology) + : null; + } + + if (preferredKnown is not null) return preferredKnown; + if (anyKnown is not null) return anyKnown; + + // Unknown node: dial the form the answering node advertised (#2826). + return preferred is not null + ? GetServerEndPoint(preferred, ServerProvenance.ClusterTopology) + : null; } } diff --git a/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs b/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs index dae5641e9..164815622 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs @@ -101,6 +101,23 @@ public async Task ShadowTopologyKnowsBothIdentities(ClusterEndpointType preferre Assert.Contains(new DnsEndPoint(Hostname, port), node.Identities); } + [Theory] + [InlineData(ClusterEndpointType.Ip)] + [InlineData(ClusterEndpointType.Hostname)] + public async Task PreferredDialEndPointFollowsAnnouncedForm(ClusterEndpointType preferred) + { + using var server = CreateServer(log, preferred); + await using var conn = await server.ConnectAsync(defaultOnly: true); + + var node = Assert.Single((await GetShadowAsync(conn, server.DefaultEndPoint)).Nodes); + GetHost(server.DefaultEndPoint, out var port); + EndPoint expected = preferred == ClusterEndpointType.Hostname + ? new DnsEndPoint(Hostname, port) + : new IPEndPoint(IPAddress.Loopback, port); + Assert.Equal(expected, node.PreferredDialEndPoint); + Assert.Equal(node.Identities[0], node.PreferredDialEndPoint); + } + [Fact] public async Task ShadowTopologyAgreesWithTheRoutingView() { @@ -173,19 +190,25 @@ public async Task SlotMapIsDrivenByTheSlotsView() [Fact] public async Task HostnamePreferredClusterRoutesWithoutDuplicatingEndpoints() { - // the case the flip exists for: SLOTS names every node by hostname while NODES names them by - // address, so a slot map fed from SLOTS must still resolve to the servers we already hold + // Discover the new shard from a single seed. SLOTS names it by hostname, while NODES reports its + // companion IP; routing and registration must use the hostname and retain the IP only as an alias. using var server = CreateServer(log, ClusterEndpointType.Hostname); GetHost(server.DefaultEndPoint, out var port); var other = server.AddEmptyNode(new IPEndPoint(IPAddress.Loopback, port + 1)); server.SetHostname(other, "host-2.redis.example.com"); server.Migrate((RedisKey)"hostname-routed-key", other); - await using var conn = await server.ConnectAsync(); + await using var conn = await server.ConnectAsync(defaultOnly: true); var db = conn.GetDatabase(); await db.StringSetAsync("hostname-routed-key", "value"); Assert.Equal("value", await db.StringGetAsync("hostname-routed-key")); + var hostnameEndpoint = new DnsEndPoint("host-2.redis.example.com", port + 1); + var companionIp = new IPEndPoint(IPAddress.Loopback, port + 1); + Assert.Contains(hostnameEndpoint, conn.GetEndPoints()); + Assert.DoesNotContain(companionIp, conn.GetEndPoints()); + Assert.Equal(hostnameEndpoint, conn.GetServer(companionIp).EndPoint); + foreach (var ep in conn.GetEndPoints()) { log.WriteLine($"endpoint: {ep}"); From a09a6a36bd9d3f1a8428322a1e3d5a340132fe2e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 11 Nov 2026 16:04:40 +0000 Subject: [PATCH 2/4] The announced dial preference is applied where identities are canonicalized, on every topology application The selection-branch approach is reverted: SelectIdentity and ResolveOrCreate return to 'any identity we already hold', and the preference moves into ApplyTopologyGenerationAsync, which already owns survivor choice, alias overwrite and duplicate merging. A node held by a companion address while SLOTS announces a hostname first is re-homed - a server is adopted at the hostname form (exactForm, so the repair does not resolve through the alias being repaired) and the companion server is retired through the existing merge machinery, which also re-points every alias. Running at topology application makes the preference hold however the companion server came to exist (a MOVED redirect, a NODES-only refresh, state from an earlier preference), not only on first discovery. Discovery also creates a SLOTS-listed node at its announced form in the first place, via an identity -> preferred-form map built in the loop that already visits every identity, so the common path never creates the companion server at all. Configured endpoints are exempt throughout: a user's explicit seed is never re-homed from under them. Tests: the seeded regression case returns (a slot map fed from SLOTS must resolve to servers we already hold); the single-seed discovery case stays, made deterministic with an explicit applied topology; and a new case covers re-homing a node already held by its address when the announced preference changes. --- src/StackExchange.Redis/ClusterTopology.cs | 5 +- .../ConnectionMultiplexer.cs | 129 +++++++++--------- .../ClusterTopologyUnitTests.cs | 93 +++++++++++-- 3 files changed, 150 insertions(+), 77 deletions(-) diff --git a/src/StackExchange.Redis/ClusterTopology.cs b/src/StackExchange.Redis/ClusterTopology.cs index 52b549bfa..71bcb9b02 100644 --- a/src/StackExchange.Redis/ClusterTopology.cs +++ b/src/StackExchange.Redis/ClusterTopology.cs @@ -111,8 +111,9 @@ internal ClusterTopologyNode(ClusterSlotNode node, string? primaryNodeId) public IReadOnlyList Identities => _identities ?? []; /// - /// The announced endpoint form to use for dialing. Companion identities such as an IP address must not be - /// registered as separate dialable servers when this preferred endpoint is a hostname. + /// The endpoint form the deployment announced first (cluster-preferred-endpoint-type), which is the + /// form the client prefers to dial: a certificate validates against a name, and a companion address may not + /// even be routable from the client. The other are names the node also answers to. /// public EndPoint? PreferredDialEndPoint => Identities.Count > 0 ? Identities[0] : null; diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs index b767a766e..ae02b8b74 100644 --- a/src/StackExchange.Redis/ConnectionMultiplexer.cs +++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs @@ -1057,6 +1057,29 @@ internal async Task ApplyTopologyGenerationAsync(ClusterTopology topology, ILogg if (survivor is not null) { + // the identity list is ordered with the deployment's stated dial preference first; when that + // form is a hostname and the survivor dials something else, the node is held by a companion + // address - typically the pod IP from CLUSTER NODES, which may be unreachable from the + // client or reused after a restart (#3309) - and the hostname is also the form TLS can + // validate (#2826). Re-home it: adopt a server at the preferred form and retire the one + // dialing the companion address. Running here, on every applied topology, is what makes the + // preference hold however the companion server came to exist (a MOVED redirect, a NODES-only + // refresh, state inherited from an earlier preference) - not just on first discovery. A + // configured endpoint stays: the user's explicit seed may never be retired. + if (node.PreferredDialEndPoint is DnsEndPoint preferred + && survivor.Provenance != ServerProvenance.Configured + && !Equals(survivor.EndPoint, preferred)) + { + // exactForm: the preferred name may still be aliased to the companion server - that + // alias is the state being repaired, so resolution must not follow it here + var adopted = GetServerEndPoint(preferred, ServerProvenance.ClusterTopology, log, exactForm: true); + if (!ReferenceEquals(adopted, survivor)) + { + (merge ??= new()).Add(new DuplicateServers(adopted, survivor)); + survivor = adopted; + } + } + survivor.OnSeenInTopology(generation); seen.Add(survivor); @@ -1186,10 +1209,14 @@ internal void RegisterServerIdentities(ClusterTopology topology) EndPoint? endpoint, ServerProvenance provenance, ILogger? log = null, - bool activate = true) + bool activate = true, + bool exactForm = false) { if (endpoint == null) return null; - var server = (ServerEndPoint?)servers[endpoint] ?? TryResolveServerEndPoint(endpoint); + + // exactForm: a server keyed at this endpoint, created if absent, never one reached through an + // identity alias - for the caller whose job is to repair what an alias currently points at + var server = (ServerEndPoint?)servers[endpoint] ?? (exactForm ? null : TryResolveServerEndPoint(endpoint)); if (server == null) { bool isNew = false; @@ -2249,6 +2276,7 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => var topology = ClusterTopology.From(slots); var clusterEndpoints = new EndPointCollection(); HashSet? listedInSlots = null; + Dictionary? preferredDialForm = null; if (topology is not null) { @@ -2263,7 +2291,17 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => // every name the node answers to, not just the one chosen above: NODES may well // report it under a different form than SLOTS did - foreach (var identity in node.Identities) listedInSlots.Add(identity); + foreach (var identity in node.Identities) + { + listedInSlots.Add(identity); + + // ...and remember the stated dial preference for each secondary name, so the + // NODES loop below can create a missing node at the form the deployment announced + if (node.PreferredDialEndPoint is DnsEndPoint preferred && !Equals(identity, preferred)) + { + (preferredDialForm ??= new())[identity] = preferred; + } + } } } @@ -2286,12 +2324,16 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => else if (listedInSlots!.Contains(node.EndPoint)) { // SLOTS lists it, so it is in clusterEndpoints and we are about to connect to it and - // wait for it. Prefer the SLOTS hostname over its companion IP from NODES: that IP - // may be an unreachable pod address using the ingress client port. - var dialEndPoint = FindPreferredDialEndPoint(topology, node.EndPoint) ?? node.EndPoint; - log?.LogInformationRegisteringSlotMapNode(new(dialEndPoint)); - GetServerEndPoint(dialEndPoint, ServerProvenance.ClusterTopology, log); - if (!Equals(dialEndPoint, node.EndPoint)) RegisterServerIdentities(topology); + // wait for it. Only the *existence* of a server was ever checked here, which a node + // in this state fails - a replica of a shard we have not reached yet is created by + // its primary's genealogy pass, and that has not run. Registering it inert then left + // it waited on but undialled, for the whole ConnectTimeout (#3232). + // Created at the announced dial form where the deployment prefers hostnames: the form + // NODES reports here may be a companion pod IP not reachable from the client (#3309) + EndPoint dial = node.EndPoint; + if (preferredDialForm is not null && preferredDialForm.TryGetValue(dial, out var announced)) dial = announced; + log?.LogInformationRegisteringSlotMapNode(new(dial)); + GetServerEndPoint(dial, ServerProvenance.ClusterTopology, log); } else { @@ -2320,42 +2362,16 @@ public EndPoint[] GetEndPoints(bool configuredOnly = false) => return null; } + // an identity we already hold if there is one - otherwise the form the answering node advertised, + // which is what Identities is ordered by, and which is the form TLS can validate EndPoint? SelectIdentity(ClusterTopologyNode node) { - var preferred = node.PreferredDialEndPoint; - ServerEndPoint? preferredKnown = null, dnsKnown = null, anyKnown = null; - foreach (var identity in node.Identities) { - if (TryResolveServerEndPoint(identity) is not { } known) continue; - anyKnown ??= known; - if (preferred is not null && Equals(known.EndPoint, preferred)) preferredKnown = known; - if (known.EndPoint is DnsEndPoint) dnsKnown ??= known; - } - - if (preferred is DnsEndPoint) - { - return (preferredKnown ?? dnsKnown)?.EndPoint ?? preferred; - } - - return (preferredKnown ?? anyKnown)?.EndPoint ?? preferred; - } - } - - /// - /// Finds the SLOTS preferred dial endpoint for a node that also answers at - /// (typically the IP form from CLUSTER NODES). - /// - private static EndPoint? FindPreferredDialEndPoint(ClusterTopology topology, EndPoint identity) - { - foreach (var node in topology.Nodes) - { - foreach (var id in node.Identities) - { - if (Equals(id, identity)) return node.PreferredDialEndPoint; + if (TryResolveServerEndPoint(identity) is { } known) return known.EndPoint; } + return node.Identities.Count > 0 ? node.Identities[0] : null; } - return null; } private void ResetAllNonConnected() @@ -2578,8 +2594,8 @@ internal void UpdateClusterRange(ClusterTopology topology) { if (node.IsReplica || node.Slots.Count == 0) continue; - // Resolve the preferred server first, and do not adopt a companion IP server when hostnames - // are preferred. + // resolve by *any* identity the node answers to before falling back to the form this reply + // happened to use; that is what keeps one node from becoming two ServerEndPoints var server = ResolveOrCreate(node); if (server is null) continue; @@ -2591,36 +2607,15 @@ internal void UpdateClusterRange(ClusterTopology topology) ServerEndPoint? ResolveOrCreate(ClusterTopologyNode node) { - var preferred = node.PreferredDialEndPoint; - ServerEndPoint? preferredKnown = null, dnsKnown = null, anyKnown = null; - foreach (var identity in node.Identities) { - if (TryResolveServerEndPoint(identity) is not { } known) continue; - anyKnown ??= known; - if (preferred is not null && Equals(known.EndPoint, preferred)) preferredKnown = known; - if (known.EndPoint is DnsEndPoint) dnsKnown ??= known; + if (TryResolveServerEndPoint(identity) is { } known) return known; } - if (preferred is DnsEndPoint) - { - // Hostname-routed TLS/SNI clusters: never adopt a companion IP ServerEndPoint for - // the slot map. That IP is often the pod address with the ingress client port and - // does not survive pod restart. - if (preferredKnown is not null) return preferredKnown; - if (dnsKnown is not null) return dnsKnown; - return preferred is not null - ? GetServerEndPoint(preferred, ServerProvenance.ClusterTopology) - : null; - } - - if (preferredKnown is not null) return preferredKnown; - if (anyKnown is not null) return anyKnown; - - // Unknown node: dial the form the answering node advertised (#2826). - return preferred is not null - ? GetServerEndPoint(preferred, ServerProvenance.ClusterTopology) - : null; + // unknown node: dial the form the answering node *advertised*, which Identities is ordered by. + // Not the address by preference: a certificate validates against a name, and where hostnames + // are preferred the advertised address may not even be routable (#2826) + return node.Identities.Count > 0 ? GetServerEndPoint(node.Identities[0], ServerProvenance.ClusterTopology) : null; } } diff --git a/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs b/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs index 164815622..6bd236fb9 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTopologyUnitTests.cs @@ -115,7 +115,6 @@ public async Task PreferredDialEndPointFollowsAnnouncedForm(ClusterEndpointType ? new DnsEndPoint(Hostname, port) : new IPEndPoint(IPAddress.Loopback, port); Assert.Equal(expected, node.PreferredDialEndPoint); - Assert.Equal(node.Identities[0], node.PreferredDialEndPoint); } [Fact] @@ -190,33 +189,111 @@ public async Task SlotMapIsDrivenByTheSlotsView() [Fact] public async Task HostnamePreferredClusterRoutesWithoutDuplicatingEndpoints() { - // Discover the new shard from a single seed. SLOTS names it by hostname, while NODES reports its - // companion IP; routing and registration must use the hostname and retain the IP only as an alias. + // the case the flip exists for: SLOTS names every node by hostname while NODES names them by + // address, so a slot map fed from SLOTS must still resolve to the servers we already hold using var server = CreateServer(log, ClusterEndpointType.Hostname); GetHost(server.DefaultEndPoint, out var port); var other = server.AddEmptyNode(new IPEndPoint(IPAddress.Loopback, port + 1)); server.SetHostname(other, "host-2.redis.example.com"); server.Migrate((RedisKey)"hostname-routed-key", other); + await using var conn = await server.ConnectAsync(); + var db = conn.GetDatabase(); + await db.StringSetAsync("hostname-routed-key", "value"); + Assert.Equal("value", await db.StringGetAsync("hostname-routed-key")); + + foreach (var ep in conn.GetEndPoints()) + { + log.WriteLine($"endpoint: {ep}"); + } + EndpointResolutionUnitTests.AssertOneEndpointPerNode(conn, log); + + // two nodes, two endpoints - not four + Assert.Equal(2, conn.GetEndPoints().Length); + } + + [Fact] + public async Task HostnameAnnouncedShardIsDiscoveredByHostname() + { + // the scenario from #3309: discover a new shard from a single seed. SLOTS names it by hostname while + // NODES reports its companion IP - often a pod address that is not reachable from the client - so + // discovery must dial the hostname and keep the IP only as an alias + using var server = CreateServer(log, ClusterEndpointType.Hostname); + GetHost(server.DefaultEndPoint, out var port); + var companionIp = new IPEndPoint(IPAddress.Loopback, port + 1); + var other = server.AddEmptyNode(companionIp); + server.SetHostname(other, "host-2.redis.example.com"); + server.Migrate((RedisKey)"hostname-routed-key", other); + await using var conn = await server.ConnectAsync(defaultOnly: true); var db = conn.GetDatabase(); await db.StringSetAsync("hostname-routed-key", "value"); Assert.Equal("value", await db.StringGetAsync("hostname-routed-key")); var hostnameEndpoint = new DnsEndPoint("host-2.redis.example.com", port + 1); - var companionIp = new IPEndPoint(IPAddress.Loopback, port + 1); - Assert.Contains(hostnameEndpoint, conn.GetEndPoints()); + Assert.True( + await Poll.UntilAsync(() => conn.GetEndPoints().Contains(hostnameEndpoint)), + $"{hostnameEndpoint} was never discovered"); + + // one explicit pass, so the assertions observe an applied topology rather than racing the + // passes that ran during connect and redirect handling + await ApplyTopologyAsync(conn, server.DefaultEndPoint); + Assert.DoesNotContain(companionIp, conn.GetEndPoints()); - Assert.Equal(hostnameEndpoint, conn.GetServer(companionIp).EndPoint); + Assert.Equal(hostnameEndpoint, conn.GetServer(companionIp).EndPoint); // the IP resolves as an alias foreach (var ep in conn.GetEndPoints()) { log.WriteLine($"endpoint: {ep}"); } EndpointResolutionUnitTests.AssertOneEndpointPerNode(conn, log); + } - // two nodes, two endpoints - not four - Assert.Equal(2, conn.GetEndPoints().Length); + [Fact] + public async Task ShardHeldByAddressMovesToItsAnnouncedHostname() + { + // the durable half of #3309: the announced dial preference is applied on *every* topology + // application, not only at first discovery. A node already held by its companion address - learned + // while the deployment still preferred IPs, or created by a MOVED redirect or a NODES-only refresh - + // is re-homed to the hostname once the topology announces it + using var server = CreateServer(log, ClusterEndpointType.Ip); + GetHost(server.DefaultEndPoint, out var port); + var companionIp = new IPEndPoint(IPAddress.Loopback, port + 1); + var other = server.AddEmptyNode(companionIp); + server.SetHostname(other, "host-2.redis.example.com"); + server.Migrate((RedisKey)"hostname-routed-key", other); + + await using var conn = await server.ConnectAsync(defaultOnly: true); + Assert.True( + await Poll.UntilAsync(() => conn.GetEndPoints().Contains(companionIp)), + $"{companionIp} was never discovered"); + + // the deployment flips to hostname announcement... + server.PreferredEndpointType = ClusterEndpointType.Hostname; + await ApplyTopologyAsync(conn, server.DefaultEndPoint); + + // ...and the shard moves to its hostname, with the address kept as an alias + var hostnameEndpoint = new DnsEndPoint("host-2.redis.example.com", port + 1); + Assert.Contains(hostnameEndpoint, conn.GetEndPoints()); + Assert.DoesNotContain(companionIp, conn.GetEndPoints()); + Assert.Equal(hostnameEndpoint, conn.GetServer(companionIp).EndPoint); + + // the configured seed is exempt: an explicit endpoint is never re-homed from under the caller + Assert.Contains(server.DefaultEndPoint, conn.GetEndPoints()); + + EndpointResolutionUnitTests.AssertOneEndpointPerNode(conn, log); + } + + /// + /// Applies one topology pass - slot map and generation, as the production path does - from a fresh + /// CLUSTER SLOTS call, so a test can observe the next application without waiting for one. + /// + private static async Task ApplyTopologyAsync(IConnectionMultiplexer conn, EndPoint askOf) + { + var mux = (ConnectionMultiplexer)conn; + var topology = await GetShadowAsync(conn, askOf); + mux.UpdateClusterRange(topology); + await mux.ApplyTopologyGenerationAsync(topology); } [Fact] From 0fbe98b7d05fe211450287e6c58a4c2644360c99 Mon Sep 17 00:00:00 2001 From: Meera Sridharan Date: Wed, 11 Nov 2026 13:17:11 -0800 Subject: [PATCH 3/4] Retry CI after transient package feed failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 6af1f82117b962989b9eb04fb39001d47c1bb0bc Mon Sep 17 00:00:00 2001 From: Meera Sridharan Date: Wed, 11 Nov 2026 13:56:58 -0800 Subject: [PATCH 4/4] Trigger fresh CI run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>