diff --git a/docs/Failover.md b/docs/Failover.md
index 98d717a90..44ffc7e7b 100644
--- a/docs/Failover.md
+++ b/docs/Failover.md
@@ -281,6 +281,27 @@ foreach (var member in conn.GetMembers())
These are the same instances that were passed into `ConnectGroupAsync`.
+## Which member raised an event
+
+Events the group forwards from its members (`ConnectionFailed`, `ConnectionRestored`, `ErrorMessage`, `ServerMaintenanceEvent`,
+`HashSlotMoved`, and so on) arrive with the member's own connection as `sender`, not the group. To say which member an event
+came from, use `FindMember`, or ask a specific member with `IsSenderOf`:
+
+```csharp
+conn.ConnectionFailed += (sender, args) =>
+{
+ var member = conn.FindMember(sender); // null if the sender is not (or is no longer) a member
+ Console.WriteLine($"{member?.Name ?? "(unknown)"}: {args.FailureType}");
+
+ if (conn.ActiveMember?.IsSenderOf(sender) == true)
+ {
+ Console.WriteLine("...and that is the member currently in use");
+ }
+};
+```
+
+Bear in mind that the active member can change at any time, including just after this check.
+
## Health Checks
Configurable health checking monitors the health of all endpoints and automatically routes traffic away from unhealthy instances.
diff --git a/src/StackExchange.Redis/Availability/ConnectionGroupExtensions.cs b/src/StackExchange.Redis/Availability/ConnectionGroupExtensions.cs
new file mode 100644
index 000000000..29350b039
--- /dev/null
+++ b/src/StackExchange.Redis/Availability/ConnectionGroupExtensions.cs
@@ -0,0 +1,25 @@
+namespace StackExchange.Redis.Availability;
+
+///
+/// Convenience methods for working with an .
+///
+public static class ConnectionGroupExtensions
+{
+ ///
+ /// Find the member of the group that the sender of an event originated from, if any.
+ ///
+ /// The group that raised the event.
+ /// The sender passed to an event handler.
+ /// The member that the event came from, or null if the sender is not (or is no longer) one of this group's members,
+ /// including when the event was raised by the group itself.
+ /// See .
+ public static ConnectionGroupMember? FindMember(this IConnectionGroup group, object? sender)
+ {
+ if (group is null || sender is null) return null;
+ foreach (var member in group.GetMembers())
+ {
+ if (member.IsSenderOf(sender)) return member;
+ }
+ return null;
+ }
+}
diff --git a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs
index f4bf36872..448faa3cf 100644
--- a/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs
+++ b/src/StackExchange.Redis/Availability/MultiGroupMultiplexer.cs
@@ -134,6 +134,17 @@ internal void SetUnhealthy()
///
public bool IsUnhealthy => GetFlag(MemberFlags.Unhealthy);
+ ///
+ /// Whether the sender of an event originated from this member: either this member itself, or the connection it currently
+ /// holds. Events that a group forwards from its members (for example
+ /// or ) are raised with the member's own connection as the sender, so this
+ /// identifies which member an event came from; see also .
+ ///
+ /// The sender passed to an event handler.
+ /// This is false for the group itself, and for a member that has been removed from its group.
+ public bool IsSenderOf(object? sender)
+ => sender is not null && (ReferenceEquals(sender, this) || ReferenceEquals(sender, Volatile.Read(ref _muxer)));
+
[DoesNotReturn]
private static ConnectionMultiplexer ThrowNoMuxer() =>
throw new InvalidOperationException("Member is not connected.");
@@ -424,7 +435,7 @@ public override string ToString()
{
foreach (var member in _members)
{
- if (ReferenceEquals(muxer, member.Multiplexer))
+ if (member.IsSenderOf(muxer))
{
return member;
}
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index ab058de62..ce6a048bb 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1 +1,4 @@
#nullable enable
+StackExchange.Redis.Availability.ConnectionGroupExtensions
+StackExchange.Redis.Availability.ConnectionGroupMember.IsSenderOf(object? sender) -> bool
+static StackExchange.Redis.Availability.ConnectionGroupExtensions.FindMember(this StackExchange.Redis.Availability.IConnectionGroup! group, object? sender) -> StackExchange.Redis.Availability.ConnectionGroupMember?
diff --git a/tests/StackExchange.Redis.Tests/GroupMemberSenderUnitTests.cs b/tests/StackExchange.Redis.Tests/GroupMemberSenderUnitTests.cs
new file mode 100644
index 000000000..5506b2ce1
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/GroupMemberSenderUnitTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using StackExchange.Redis.Availability;
+using StackExchange.Redis.Maintenance;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+///
+/// Events that a group forwards from its members arrive with the member's own connection as the sender; a subscriber
+/// must be able to say which member that was, without the connection itself being part of the public surface.
+///
+public class GroupMemberSenderUnitTests(ITestOutputHelper log)
+{
+ [Fact]
+ public async Task ForwardedEventsAreAttributedToTheirMember()
+ {
+ using var server0 = new InProcessTestServer(log);
+ using var server1 = new InProcessTestServer(log);
+ var member0 = new ConnectionGroupMember(server0.GetClientConfig(defaultOnly: true), "zero");
+ var member1 = new ConnectionGroupMember(server1.GetClientConfig(defaultOnly: true), "one");
+
+ var group = await ConnectionMultiplexer.ConnectGroupAsync(member0, member1);
+ await using var _ = (IAsyncDisposable)group;
+
+ var senders = new ConcurrentQueue