diff --git a/src/StackExchange.Redis/APITypes/ClientKillFilter.cs b/src/StackExchange.Redis/APITypes/ClientKillFilter.cs new file mode 100644 index 000000000..b5c5e845c --- /dev/null +++ b/src/StackExchange.Redis/APITypes/ClientKillFilter.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Net; + +namespace StackExchange.Redis; + +/// +/// Filter determining which Redis clients to kill. +/// +/// +public class ClientKillFilter +{ + /// + /// Filter arguments builder for `CLIENT KILL`. + /// + public ClientKillFilter() { } + + /// + /// The ID of the client to kill. + /// + public long? Id { get; private set; } + + /// + /// The type of client. + /// + public ClientType? ClientType { get; private set; } + + /// + /// The authenticated ACL username. + /// + public string? Username { get; private set; } + + /// + /// The endpoint to kill. + /// + public EndPoint? Endpoint { get; private set; } + + /// + /// The server endpoint to kill. + /// + public EndPoint? ServerEndpoint { get; private set; } + + /// + /// Whether to skip the current connection. + /// + public bool? SkipMe { get; private set; } + + /// + /// Age of connection in seconds. + /// + public long? MaxAgeInSeconds { get; private set; } + + /// + /// Sets client id filter. + /// + /// Id of the client to kill. + public ClientKillFilter WithId(long? id) + { + Id = id; + return this; + } + + /// + /// Sets client type filter. + /// + /// The type of the client. + public ClientKillFilter WithClientType(ClientType? clientType) + { + ClientType = clientType; + return this; + } + + /// + /// Sets the username filter. + /// + /// Authenticated ACL username. + public ClientKillFilter WithUsername(string? username) + { + Username = username; + return this; + } + + /// + /// Set the endpoint filter. + /// + /// The endpoint to kill. + public ClientKillFilter WithEndpoint(EndPoint? endpoint) + { + Endpoint = endpoint; + return this; + } + + /// + /// Set the server endpoint filter. + /// + /// The server endpoint to kill. + public ClientKillFilter WithServerEndpoint(EndPoint? serverEndpoint) + { + ServerEndpoint = serverEndpoint; + return this; + } + + /// + /// Set the skipMe filter (whether to skip the current connection). + /// + /// Whether to skip the current connection. + public ClientKillFilter WithSkipMe(bool? skipMe) + { + SkipMe = skipMe; + return this; + } + + /// + /// Set the MaxAgeInSeconds filter. + /// + /// Age of connection in seconds + public ClientKillFilter WithMaxAgeInSeconds(long? maxAgeInSeconds) + { + MaxAgeInSeconds = maxAgeInSeconds; + return this; + } + + internal List ToList(bool withReplicaCommands) + { + var parts = new List(15) + { + RedisLiterals.KILL + }; + if (Id != null) + { + parts.Add(RedisLiterals.ID); + parts.Add(Id.Value); + } + if (ClientType != null) + { + parts.Add(RedisLiterals.TYPE); + switch (ClientType.Value) + { + case Redis.ClientType.Normal: + parts.Add(RedisLiterals.normal); + break; + case Redis.ClientType.Replica: + parts.Add(withReplicaCommands ? RedisLiterals.replica : RedisLiterals.slave); + break; + case Redis.ClientType.PubSub: + parts.Add(RedisLiterals.pubsub); + break; + default: + throw new ArgumentOutOfRangeException(nameof(ClientType)); + } + } + if (Username != null) + { + parts.Add(RedisLiterals.USERNAME); + parts.Add(Username); + } + if (Endpoint != null) + { + parts.Add(RedisLiterals.ADDR); + parts.Add((RedisValue)Format.ToString(Endpoint)); + } + if (ServerEndpoint != null) + { + parts.Add(RedisLiterals.LADDR); + parts.Add((RedisValue)Format.ToString(ServerEndpoint)); + } + if (SkipMe != null) + { + parts.Add(RedisLiterals.SKIPME); + parts.Add(SkipMe.Value ? RedisLiterals.yes : RedisLiterals.no); + } + if (MaxAgeInSeconds != null) + { + parts.Add(RedisLiterals.MAXAGE); + parts.Add(MaxAgeInSeconds); + } + return parts; + } +} diff --git a/src/StackExchange.Redis/Interfaces/IServer.cs b/src/StackExchange.Redis/Interfaces/IServer.cs index 9f8f0b075..94baf5cd6 100644 --- a/src/StackExchange.Redis/Interfaces/IServer.cs +++ b/src/StackExchange.Redis/Interfaces/IServer.cs @@ -107,6 +107,18 @@ public partial interface IServer : IRedis /// Task ClientKillAsync(long? id = null, ClientType? clientType = null, EndPoint? endpoint = null, bool skipMe = true, CommandFlags flags = CommandFlags.None); + /// + /// The CLIENT KILL command closes multiple connections that match the specified filters. + /// + /// + /// + /// + long ClientKill(ClientKillFilter filter, CommandFlags flags = CommandFlags.None); + + /// + Task ClientKillAsync(ClientKillFilter filter, CommandFlags flags = CommandFlags.None); + + /// /// The CLIENT LIST command returns information and statistics about the client connections server in a mostly human readable format. /// diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index 4504423fe..8707cc1b4 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -126,6 +126,22 @@ StackExchange.Redis.ClientInfo.ProtocolVersion.get -> string? StackExchange.Redis.ClientInfo.Raw.get -> string? StackExchange.Redis.ClientInfo.SubscriptionCount.get -> int StackExchange.Redis.ClientInfo.TransactionCommandLength.get -> int +StackExchange.Redis.ClientKillFilter +StackExchange.Redis.ClientKillFilter.ClientKillFilter() -> void +StackExchange.Redis.ClientKillFilter.ClientType.get -> StackExchange.Redis.ClientType? +StackExchange.Redis.ClientKillFilter.Endpoint.get -> System.Net.EndPoint? +StackExchange.Redis.ClientKillFilter.Id.get -> long? +StackExchange.Redis.ClientKillFilter.MaxAgeInSeconds.get -> long? +StackExchange.Redis.ClientKillFilter.ServerEndpoint.get -> System.Net.EndPoint? +StackExchange.Redis.ClientKillFilter.SkipMe.get -> bool? +StackExchange.Redis.ClientKillFilter.Username.get -> string? +StackExchange.Redis.ClientKillFilter.WithClientType(StackExchange.Redis.ClientType? clientType) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithEndpoint(System.Net.EndPoint? endpoint) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithId(long? id) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithMaxAgeInSeconds(long? maxAgeInSeconds) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithServerEndpoint(System.Net.EndPoint? serverEndpoint) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithSkipMe(bool? skipMe) -> StackExchange.Redis.ClientKillFilter! +StackExchange.Redis.ClientKillFilter.WithUsername(string? username) -> StackExchange.Redis.ClientKillFilter! StackExchange.Redis.ClientType StackExchange.Redis.ClientType.Normal = 0 -> StackExchange.Redis.ClientType StackExchange.Redis.ClientType.PubSub = 2 -> StackExchange.Redis.ClientType @@ -1006,8 +1022,10 @@ StackExchange.Redis.IServer.AllowSlaveWrites.get -> bool StackExchange.Redis.IServer.AllowSlaveWrites.set -> void StackExchange.Redis.IServer.ClientKill(long? id = null, StackExchange.Redis.ClientType? clientType = null, System.Net.EndPoint? endpoint = null, bool skipMe = true, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IServer.ClientKill(System.Net.EndPoint! endpoint, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void +StackExchange.Redis.IServer.ClientKill(StackExchange.Redis.ClientKillFilter! filter, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long StackExchange.Redis.IServer.ClientKillAsync(long? id = null, StackExchange.Redis.ClientType? clientType = null, System.Net.EndPoint? endpoint = null, bool skipMe = true, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.ClientKillAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! +StackExchange.Redis.IServer.ClientKillAsync(StackExchange.Redis.ClientKillFilter! filter, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.ClientList(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> StackExchange.Redis.ClientInfo![]! StackExchange.Redis.IServer.ClientListAsync(StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task! StackExchange.Redis.IServer.ClusterConfiguration.get -> StackExchange.Redis.ClusterConfiguration? @@ -1851,4 +1869,5 @@ static StackExchange.Redis.RedisResult.Create(StackExchange.Redis.RedisValue[]! virtual StackExchange.Redis.RedisResult.Length.get -> int virtual StackExchange.Redis.RedisResult.this[int index].get -> StackExchange.Redis.RedisResult! StackExchange.Redis.ConnectionMultiplexer.AddLibraryNameSuffix(string! suffix) -> void -StackExchange.Redis.IConnectionMultiplexer.AddLibraryNameSuffix(string! suffix) -> void \ No newline at end of file +StackExchange.Redis.IConnectionMultiplexer.AddLibraryNameSuffix(string! suffix) -> void + diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs index bfd6b1a44..11d2ba016 100644 --- a/src/StackExchange.Redis/RedisLiterals.cs +++ b/src/StackExchange.Redis/RedisLiterals.cs @@ -89,6 +89,7 @@ public static readonly RedisValue IDLETIME = "IDLETIME", KEEPTTL = "KEEPTTL", KILL = "KILL", + LADDR = "LADDR", LATEST = "LATEST", LEFT = "LEFT", LEN = "LEN", @@ -101,6 +102,7 @@ public static readonly RedisValue MATCH = "MATCH", MALLOC_STATS = "MALLOC-STATS", MAX = "MAX", + MAXAGE = "MAXAGE", MAXLEN = "MAXLEN", MIN = "MIN", MINMATCHLEN = "MINMATCHLEN", @@ -137,6 +139,7 @@ public static readonly RedisValue STATS = "STATS", STORE = "STORE", TYPE = "TYPE", + USERNAME = "USERNAME", WEIGHTS = "WEIGHTS", WITHMATCHLEN = "WITHMATCHLEN", WITHSCORES = "WITHSCORES", diff --git a/src/StackExchange.Redis/RedisServer.cs b/src/StackExchange.Redis/RedisServer.cs index 0fec00428..1f7791dd2 100644 --- a/src/StackExchange.Redis/RedisServer.cs +++ b/src/StackExchange.Redis/RedisServer.cs @@ -75,46 +75,22 @@ public Task ClientKillAsync(long? id = null, ClientType? clientType = null return ExecuteAsync(msg, ResultProcessor.Int64); } - private Message GetClientKillMessage(EndPoint? endpoint, long? id, ClientType? clientType, bool skipMe, CommandFlags flags) + public long ClientKill(ClientKillFilter filter, CommandFlags flags = CommandFlags.None) { - var parts = new List(9) - { - RedisLiterals.KILL - }; - if (id != null) - { - parts.Add(RedisLiterals.ID); - parts.Add(id.Value); - } - if (clientType != null) - { - parts.Add(RedisLiterals.TYPE); - switch (clientType.Value) - { - case ClientType.Normal: - parts.Add(RedisLiterals.normal); - break; - case ClientType.Replica: - parts.Add(Features.ReplicaCommands ? RedisLiterals.replica : RedisLiterals.slave); - break; - case ClientType.PubSub: - parts.Add(RedisLiterals.pubsub); - break; - default: - throw new ArgumentOutOfRangeException(nameof(clientType)); - } - } - if (endpoint != null) - { - parts.Add(RedisLiterals.ADDR); - parts.Add((RedisValue)Format.ToString(endpoint)); - } - if (!skipMe) - { - parts.Add(RedisLiterals.SKIPME); - parts.Add(RedisLiterals.no); - } - return Message.Create(-1, flags, RedisCommand.CLIENT, parts); + var msg = Message.Create(-1, flags, RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); + return ExecuteSync(msg, ResultProcessor.Int64); + } + + public Task ClientKillAsync(ClientKillFilter filter, CommandFlags flags = CommandFlags.None) + { + var msg = Message.Create(-1, flags, RedisCommand.CLIENT, filter.ToList(Features.ReplicaCommands)); + return ExecuteAsync(msg, ResultProcessor.Int64); + } + + private Message GetClientKillMessage(EndPoint? endpoint, long? id, ClientType? clientType, bool? skipMe, CommandFlags flags) + { + var args = new ClientKillFilter().WithId(id).WithClientType(clientType).WithEndpoint(endpoint).WithSkipMe(skipMe).ToList(Features.ReplicaCommands); + return Message.Create(-1, flags, RedisCommand.CLIENT, args); } public ClientInfo[] ClientList(CommandFlags flags = CommandFlags.None) @@ -408,7 +384,7 @@ public Task LastSaveAsync(CommandFlags flags = CommandFlags.None) } public void MakeMaster(ReplicationChangeOptions options, TextWriter? log = null) - { + { // Do you believe in magic? multiplexer.MakePrimaryAsync(server, options, log).Wait(60000); } diff --git a/tests/StackExchange.Redis.Tests/ClientKillTests.cs b/tests/StackExchange.Redis.Tests/ClientKillTests.cs new file mode 100644 index 000000000..34f00c6bc --- /dev/null +++ b/tests/StackExchange.Redis.Tests/ClientKillTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Xunit; +using Xunit.Abstractions; + +namespace StackExchange.Redis.Tests; + +[RunPerProtocol] + +public class ClientKillTests : TestBase +{ + protected override string GetConfiguration() => TestConfig.Current.PrimaryServerAndPort; + public ClientKillTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public void ClientKill() + { + var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + + SetExpectedAmbientFailureCount(-1); + using var otherConnection = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); + var id = otherConnection.GetDatabase().Execute(RedisCommand.CLIENT.ToString(), RedisLiterals.ID); + + using var conn = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); + var server = conn.GetServer(conn.GetEndPoints()[0]); + long result = server.ClientKill(id.AsInt64(), ClientType.Normal, null, true); + Assert.Equal(1, result); + } + + [Fact] + public void ClientKillWithMaxAge() + { + var db = Create(require: RedisFeatures.v7_4_0_rc1).GetDatabase(); + + SetExpectedAmbientFailureCount(-1); + using var otherConnection = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); + var id = otherConnection.GetDatabase().Execute(RedisCommand.CLIENT.ToString(), RedisLiterals.ID); + Thread.Sleep(1000); + + using var conn = Create(allowAdmin: true, shared: false, backlogPolicy: BacklogPolicy.FailFast); + var server = conn.GetServer(conn.GetEndPoints()[0]); + var filter = new ClientKillFilter().WithId(id.AsInt64()).WithMaxAgeInSeconds(1).WithSkipMe(true); + long result = server.ClientKill(filter, CommandFlags.DemandMaster); + Assert.Equal(1, result); + } + + [Fact] + public void TestClientKillMessageWithAllArguments() + { + long id = 101; + ClientType type = ClientType.Normal; + string userName = "user1"; + EndPoint endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 1234); + EndPoint serverEndpoint = new IPEndPoint(IPAddress.Parse("198.0.0.1"), 6379); ; + bool skipMe = true; + long maxAge = 102; + + var filter = new ClientKillFilter().WithId(id).WithClientType(type).WithUsername(userName).WithEndpoint(endpoint).WithServerEndpoint(serverEndpoint).WithSkipMe(skipMe).WithMaxAgeInSeconds(maxAge); + List expected = new List() + { + "KILL", "ID", "101", "TYPE", "normal", "USERNAME", "user1", "ADDR", "127.0.0.1:1234", "LADDR", "198.0.0.1:6379", "SKIPME", "yes", "MAXAGE", "102" + }; + Assert.Equal(expected, filter.ToList(true)); + } +}