From c1d29f2ae7a83527ceb653ab7a8f501ae7728ebe Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:34:37 +0200 Subject: [PATCH] feat(introspection): add Excache.Command.Introspection.Client --- lib/excache/command/introspection/client.ex | 157 ++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 lib/excache/command/introspection/client.ex diff --git a/lib/excache/command/introspection/client.ex b/lib/excache/command/introspection/client.ex new file mode 100644 index 0000000..a1621bc --- /dev/null +++ b/lib/excache/command/introspection/client.ex @@ -0,0 +1,157 @@ +defmodule Excache.Command.Introspection.Client do + @moduledoc false + + alias Excache.Clients + alias Excache.Command.Reply + + @help [ + "CLIENT [ [value] [opt] ...]. Subcommands are:", + "ID", + " Return the client id of the current connection.", + "INFO", + " Return information about the current connection.", + "GETNAME", + " Return the name of the current connection.", + "SETNAME ", + " Assign the name to the current connection.", + "LIST [ID [ ...]] [TYPE normal|master|replica|pubsub]", + " Return one line of information per connection.", + "KILL ", + " Close the connection reachable at .", + "KILL [ ...]", + " Close connections matching ID, ADDR, LADDR, TYPE, MAXAGE or SKIPME.", + "HELP", + " Print this help." + ] + + @killFilters ~w(ID ADDR LADDR TYPE MAXAGE SKIPME) + @clientTypes ~w(normal master replica pubsub) + + def dispatch("ID", [], state), + do: withClient(state, fn client -> {:integer, client.id} end) + + def dispatch("INFO", [], state), + do: withClient(state, fn client -> {:bulk, line(client, now())} end) + + def dispatch("GETNAME", [], state), + do: withClient(state, fn client -> {:bulk, client.name} end) + + def dispatch("SETNAME", [name], state) do + if validName?(name) do + withClient(state, fn client -> + :ok = Clients.setName(client.pid, name) + {:simple, "OK"} + end) + else + {[{:error, "ERR Client names cannot contain spaces, newlines or special characters."}], + state} + end + end + + def dispatch("LIST", args, state) do + case listFilter(args) do + {:ok, matcher} -> + moment = now() + + text = + Clients.snapshot().clients + |> Enum.filter(matcher) + |> Enum.map_join("", fn client -> line(client, moment) <> "\n" end) + + {[{:bulk, text}], state} + + :error -> + {[Reply.syntax()], state} + end + end + + def dispatch("KILL", [address], state) do + killed = Clients.kill(fn client -> client.addr == address end, self()) + + if killed > 0 do + {[{:simple, "OK"}], state} + else + {[{:error, "ERR No such client address in the client list"}], state} + end + end + + def dispatch("KILL", [_, _ | _] = args, state) do + case killFilter(args) do + {:ok, matcher} -> {[{:integer, Clients.kill(matcher, self())}], state} + :error -> {[Reply.syntax()], state} + end + end + + def dispatch("HELP", [], state), + do: {[{:array, Enum.map(@help, fn text -> {:simple, text} end)}], state} + + def dispatch(subcommand, _args, state) do + {[ + {:error, + "ERR Unknown subcommand or wrong number of arguments for '#{subcommand}'. " <> + "Try CLIENT HELP."} + ], state} + end + + defp line(client, moment) do + Enum.join( + [ + "id=#{client.id}", + "addr=#{client.addr}", + "laddr=#{client.laddr}", + "name=#{client.name}", + "age=#{elapsed(moment, client.connectedAt)}", + "idle=#{elapsed(moment, client.lastActiveAt)}", + "flags=N", + "db=0", + "sub=#{Clients.subscriptionCount(client.pid)}", + "psub=0", + "tot-net-in=#{client.netIn}", + "tot-net-out=#{client.netOut}" + ], + " " + ) + end + + defp withClient(state, build) do + case Clients.find(self()) do + nil -> {[{:error, "ERR CLIENT is only available on a server connection"}], state} + client -> {[build.(client)], state} + end + end + + defp listFilter([]), do: {:ok, fn _client -> true end} + + defp listFilter([keyword | [_ | _] = rest]) do + case String.upcase(keyword) do + "ID" -> idFilter(rest) + "TYPE" -> typeFilter(rest) + _other -> :error + end + end + + defp listFilter(_args), do: :error + + defp idFilter(raw) do + ids = Enum.map(raw, &parseId/1) + + if :error in ids do + :error + else + wanted = MapSet.new(ids) + {:ok, fn client -> MapSet.member?(wanted, client.id) end} + end + end + + defp typeFilter([type]) do + case String.downcase(type) do + "normal" -> {:ok, fn client -> Clients.subscriptionCount(client.pid) == 0 end} + "pubsub" -> {:ok, fn client -> Clients.subscriptionCount(client.pid) > 0 end} + "master" -> {:ok, fn _client -> false end} + "replica" -> {:ok, fn _client -> false end} + _other -> :error + end + end + + defp typeFilter(_raw), do: :error +