feat(introspection): add Excache.Command.Introspection.Client

This commit is contained in:
dualfroz committed 2026-09-07 17:34:37 +02:00
1 parent eb1528087c
commit c1d29f2ae7
1 file changed
+157
+157
View File
@@ -0,0 +1,157 @@
defmodule Excache.Command.Introspection.Client do
@moduledoc false
alias Excache.Clients
alias Excache.Command.Reply
@help [
"CLIENT <subcommand> [<arg> [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 <name>",
" Assign the name <name> to the current connection.",
"LIST [ID <id> [<id> ...]] [TYPE normal|master|replica|pubsub]",
" Return one line of information per connection.",
"KILL <ip:port>",
" Close the connection reachable at <ip:port>.",
"KILL <filter> <value> [<filter> <value> ...]",
" 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