This commit is contained in:
@@ -12,6 +12,8 @@ defmodule ElixirAi.Application do
|
|||||||
[Application.get_env(:libcluster, :topologies, []), [name: ElixirAi.ClusterSupervisor]]},
|
[Application.get_env(:libcluster, :topologies, []), [name: ElixirAi.ClusterSupervisor]]},
|
||||||
{Phoenix.PubSub, name: ElixirAi.PubSub},
|
{Phoenix.PubSub, name: ElixirAi.PubSub},
|
||||||
{ElixirAi.LiveViewPG, []},
|
{ElixirAi.LiveViewPG, []},
|
||||||
|
{ElixirAi.RunnerPG, []},
|
||||||
|
{ElixirAi.SingletonPG, []},
|
||||||
{ElixirAi.PageToolsPG, []},
|
{ElixirAi.PageToolsPG, []},
|
||||||
{ElixirAi.AudioProcessingPG, []},
|
{ElixirAi.AudioProcessingPG, []},
|
||||||
{DynamicSupervisor, name: ElixirAi.AudioWorkerSupervisor, strategy: :one_for_one},
|
{DynamicSupervisor, name: ElixirAi.AudioWorkerSupervisor, strategy: :one_for_one},
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
|
|
||||||
def init(name) do
|
def init(name) do
|
||||||
Phoenix.PubSub.subscribe(ElixirAi.PubSub, conversation_message_topic(name))
|
Phoenix.PubSub.subscribe(ElixirAi.PubSub, conversation_message_topic(name))
|
||||||
|
:pg.join(ElixirAi.RunnerPG, {:runner, name}, self())
|
||||||
|
|
||||||
messages =
|
messages =
|
||||||
case Conversation.find_id(name) do
|
case Conversation.find_id(name) do
|
||||||
|
|||||||
16
lib/elixir_ai/chat_runner/runner_pg.ex
Normal file
16
lib/elixir_ai/chat_runner/runner_pg.ex
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
defmodule ElixirAi.RunnerPG do
|
||||||
|
@moduledoc """
|
||||||
|
Named :pg scope for tracking ChatRunner processes across the cluster.
|
||||||
|
Each ChatRunner joins {:runner, name} on init; :pg syncs membership
|
||||||
|
automatically and removes dead processes without any additional cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def child_spec(_opts) do
|
||||||
|
%{
|
||||||
|
id: __MODULE__,
|
||||||
|
start: {:pg, :start_link, [__MODULE__]},
|
||||||
|
type: :worker,
|
||||||
|
restart: :permanent
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
16
lib/elixir_ai/cluster_singleton/singleton_pg.ex
Normal file
16
lib/elixir_ai/cluster_singleton/singleton_pg.ex
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
defmodule ElixirAi.SingletonPG do
|
||||||
|
@moduledoc """
|
||||||
|
Named :pg scope for tracking cluster singleton processes across the cluster.
|
||||||
|
Each singleton joins {:singleton, __MODULE__} on init; :pg syncs membership
|
||||||
|
automatically and removes dead processes without any additional cleanup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def child_spec(_opts) do
|
||||||
|
%{
|
||||||
|
id: __MODULE__,
|
||||||
|
start: {:pg, :start_link, [__MODULE__]},
|
||||||
|
type: :worker,
|
||||||
|
restart: :permanent
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -20,6 +20,7 @@ defmodule ElixirAi.ConversationManager do
|
|||||||
|
|
||||||
def init(_) do
|
def init(_) do
|
||||||
Logger.info("ConversationManager initializing...")
|
Logger.info("ConversationManager initializing...")
|
||||||
|
:pg.join(ElixirAi.SingletonPG, {:singleton, __MODULE__}, self())
|
||||||
send(self(), :load_conversations)
|
send(self(), :load_conversations)
|
||||||
{:ok, %{conversations: :loading, subscriptions: MapSet.new(), runners: %{}}}
|
{:ok, %{conversations: :loading, subscriptions: MapSet.new(), runners: %{}}}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,41 +1,64 @@
|
|||||||
defmodule ElixirAiWeb.AdminLive do
|
defmodule ElixirAiWeb.AdminLive do
|
||||||
|
import ElixirAi.PubsubTopics
|
||||||
use ElixirAiWeb, :live_view
|
use ElixirAiWeb, :live_view
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@refresh_ms 1_000
|
|
||||||
|
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
socket =
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
:net_kernel.monitor_nodes(true)
|
:net_kernel.monitor_nodes(true)
|
||||||
|
# Join before monitoring so our own join doesn't trigger a spurious refresh.
|
||||||
:pg.join(ElixirAi.LiveViewPG, {:liveview, __MODULE__}, self())
|
:pg.join(ElixirAi.LiveViewPG, {:liveview, __MODULE__}, self())
|
||||||
schedule_refresh()
|
{pg_ref, _} = :pg.monitor_scope(ElixirAi.LiveViewPG)
|
||||||
|
{runner_pg_ref, _} = :pg.monitor_scope(ElixirAi.RunnerPG)
|
||||||
|
{singleton_pg_ref, _} = :pg.monitor_scope(ElixirAi.SingletonPG)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(pg_ref: pg_ref)
|
||||||
|
|> assign(runner_pg_ref: runner_pg_ref)
|
||||||
|
|> assign(singleton_pg_ref: singleton_pg_ref)
|
||||||
|
else
|
||||||
|
assign(socket, pg_ref: nil, runner_pg_ref: nil, singleton_pg_ref: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, assign(socket, cluster_info: gather_info())}
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(nodes: gather_node_statuses())
|
||||||
|
|> assign(singleton_locations: gather_singleton_locations())
|
||||||
|
|> assign(chat_runners: gather_chat_runners())
|
||||||
|
|> assign(liveviews: gather_liveviews())}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:nodeup, _node}, socket) do
|
def handle_info({:nodeup, _node}, socket) do
|
||||||
{:noreply, assign(socket, cluster_info: gather_info())}
|
{:noreply, assign(socket, nodes: gather_node_statuses())}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:nodedown, _node}, socket) do
|
def handle_info({:nodedown, _node}, socket) do
|
||||||
{:noreply, assign(socket, cluster_info: gather_info())}
|
{:noreply, assign(socket, nodes: gather_node_statuses())}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(:refresh, socket) do
|
def handle_info(:refresh_singletons, socket) do
|
||||||
schedule_refresh()
|
{:noreply, assign(socket, singleton_locations: gather_singleton_locations())}
|
||||||
{:noreply, assign(socket, cluster_info: gather_info())}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp schedule_refresh, do: Process.send_after(self(), :refresh, @refresh_ms)
|
def handle_info({ref, change, _group, _pids}, %{assigns: %{singleton_pg_ref: ref}} = socket)
|
||||||
|
when is_reference(ref) and change in [:join, :leave] do
|
||||||
|
{:noreply, assign(socket, singleton_locations: gather_singleton_locations())}
|
||||||
|
end
|
||||||
|
|
||||||
defp gather_info do
|
def handle_info({ref, change, _group, _pids}, %{assigns: %{pg_ref: ref}} = socket)
|
||||||
import ElixirAi.PubsubTopics
|
when is_reference(ref) and change in [:join, :leave] do
|
||||||
|
{:noreply, assign(socket, liveviews: gather_liveviews())}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({ref, change, _group, _pids}, %{assigns: %{runner_pg_ref: ref}} = socket)
|
||||||
|
when is_reference(ref) and change in [:join, :leave] do
|
||||||
|
{:noreply, assign(socket, chat_runners: gather_chat_runners())}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gather_node_statuses do
|
||||||
all_nodes = [Node.self() | Node.list()]
|
all_nodes = [Node.self() | Node.list()]
|
||||||
configured = ElixirAi.ClusterSingleton.configured_singletons()
|
|
||||||
|
|
||||||
node_statuses =
|
|
||||||
Enum.map(all_nodes, fn node ->
|
Enum.map(all_nodes, fn node ->
|
||||||
status =
|
status =
|
||||||
if node == Node.self() do
|
if node == Node.self() do
|
||||||
@@ -53,39 +76,45 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
|
|
||||||
{node, status}
|
{node, status}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
singleton_locations =
|
|
||||||
Enum.map(configured, fn module ->
|
|
||||||
location =
|
|
||||||
case Horde.Registry.lookup(ElixirAi.ChatRegistry, module) do
|
|
||||||
[{pid, _}] -> node(pid)
|
|
||||||
_ -> nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{module, location}
|
defp gather_singleton_locations do
|
||||||
end)
|
running =
|
||||||
|
:pg.which_groups(ElixirAi.SingletonPG)
|
||||||
# All ChatRunner entries in the distributed registry, keyed by conversation name.
|
|
||||||
# Each entry is a {name, node, pid, supervisor_node} tuple.
|
|
||||||
chat_runners =
|
|
||||||
Horde.DynamicSupervisor.which_children(ElixirAi.ChatRunnerSupervisor)
|
|
||||||
|> Enum.flat_map(fn
|
|> Enum.flat_map(fn
|
||||||
{_, pid, _, _} when is_pid(pid) ->
|
{:singleton, module} ->
|
||||||
case Horde.Registry.select(ElixirAi.ChatRegistry, [
|
case :pg.get_members(ElixirAi.SingletonPG, {:singleton, module}) do
|
||||||
{{:"$1", pid, :"$2"}, [], [{{:"$1", pid, :"$2"}}]}
|
[pid | _] -> [{module, node(pid)}]
|
||||||
]) do
|
|
||||||
[{name, ^pid, _}] when is_binary(name) -> [{name, node(pid), pid}]
|
|
||||||
_ -> []
|
_ -> []
|
||||||
end
|
end
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
[]
|
[]
|
||||||
end)
|
end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
ElixirAi.ClusterSingleton.configured_singletons()
|
||||||
|
|> Enum.map(fn module -> {module, Map.get(running, module)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# All ChatRunner entries via :pg membership, keyed by conversation name.
|
||||||
|
# Each entry is a {name, node, pid} tuple.
|
||||||
|
defp gather_chat_runners do
|
||||||
|
:pg.which_groups(ElixirAi.RunnerPG)
|
||||||
|
|> Enum.flat_map(fn
|
||||||
|
{:runner, name} ->
|
||||||
|
:pg.get_members(ElixirAi.RunnerPG, {:runner, name})
|
||||||
|
|> Enum.map(fn pid -> {name, node(pid), pid} end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end)
|
||||||
|> Enum.sort_by(&elem(&1, 0))
|
|> Enum.sort_by(&elem(&1, 0))
|
||||||
|
end
|
||||||
|
|
||||||
# :pg is cluster-wide — one local call returns members from all nodes.
|
# :pg is cluster-wide — one local call returns members from all nodes.
|
||||||
# Processes are automatically removed from their group when they die.
|
# Processes are automatically removed from their group when they die.
|
||||||
liveviews =
|
defp gather_liveviews do
|
||||||
:pg.which_groups(ElixirAi.LiveViewPG)
|
:pg.which_groups(ElixirAi.LiveViewPG)
|
||||||
|> Enum.flat_map(fn
|
|> Enum.flat_map(fn
|
||||||
{:liveview, view} ->
|
{:liveview, view} ->
|
||||||
@@ -95,14 +124,6 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
_ ->
|
_ ->
|
||||||
[]
|
[]
|
||||||
end)
|
end)
|
||||||
|
|
||||||
%{
|
|
||||||
nodes: node_statuses,
|
|
||||||
configured_singletons: configured,
|
|
||||||
singleton_locations: singleton_locations,
|
|
||||||
chat_runners: chat_runners,
|
|
||||||
liveviews: liveviews
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
@@ -111,13 +132,13 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
<h1 class="text-lg font-semibold text-seafoam-200 tracking-wide">Cluster Admin</h1>
|
<h1 class="text-lg font-semibold text-seafoam-200 tracking-wide">Cluster Admin</h1>
|
||||||
|
|
||||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
<%= for {node, status} <- @cluster_info.nodes do %>
|
<%= for {node, status} <- @nodes do %>
|
||||||
<% node_singletons =
|
<% node_singletons =
|
||||||
Enum.filter(@cluster_info.singleton_locations, fn {_, loc} -> loc == node end) %>
|
Enum.filter(@singleton_locations, fn {_, loc} -> loc == node end) %>
|
||||||
<% node_runners =
|
<% node_runners =
|
||||||
Enum.filter(@cluster_info.chat_runners, fn {_, rnode, _} -> rnode == node end) %>
|
Enum.filter(@chat_runners, fn {_, rnode, _} -> rnode == node end) %>
|
||||||
<% node_liveviews =
|
<% node_liveviews =
|
||||||
@cluster_info.liveviews
|
@liveviews
|
||||||
|> Enum.filter(fn {_, n} -> n == node end)
|
|> Enum.filter(fn {_, n} -> n == node end)
|
||||||
|> Enum.group_by(fn {view, _} -> view end) %>
|
|> Enum.group_by(fn {view, _} -> view end) %>
|
||||||
|
|
||||||
@@ -126,7 +147,9 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-mono text-sm font-semibold text-seafoam-200">{node}</span>
|
<span class="font-mono text-sm font-semibold text-seafoam-200">{node}</span>
|
||||||
<%= if node == Node.self() do %>
|
<%= if node == Node.self() do %>
|
||||||
<span class="text-xs bg-seafoam-800/50 text-seafoam-400 px-1.5 py-0.5 rounded">self</span>
|
<span class="text-xs bg-seafoam-800/50 text-seafoam-400 px-1.5 py-0.5 rounded">
|
||||||
|
self
|
||||||
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<.status_badge status={status} />
|
<.status_badge status={status} />
|
||||||
@@ -191,7 +214,7 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% unlocated =
|
<% unlocated =
|
||||||
Enum.filter(@cluster_info.singleton_locations, fn {_, loc} -> is_nil(loc) end) %>
|
Enum.filter(@singleton_locations, fn {_, loc} -> is_nil(loc) end) %>
|
||||||
<%= if unlocated != [] do %>
|
<%= if unlocated != [] do %>
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-widest text-red-500 mb-2">
|
<h2 class="text-xs font-semibold uppercase tracking-widest text-red-500 mb-2">
|
||||||
@@ -207,7 +230,9 @@ defmodule ElixirAiWeb.AdminLive do
|
|||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<p class="text-xs text-seafoam-800">Refreshes every 1s or on node events.</p>
|
<p class="text-xs text-seafoam-800">
|
||||||
|
Nodes, singletons, liveviews & runners all refresh on membership changes.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
Reference in New Issue
Block a user