refactoring folders
Some checks failed
CI/CD Pipeline / build (push) Failing after 8s

This commit is contained in:
2026-03-25 12:05:56 -06:00
parent d857e91241
commit 0041c25f19
32 changed files with 139 additions and 78 deletions

View File

@@ -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},

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -1,108 +1,129 @@
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
if connected?(socket) do socket =
:net_kernel.monitor_nodes(true) if connected?(socket) do
:pg.join(ElixirAi.LiveViewPG, {:liveview, __MODULE__}, self()) :net_kernel.monitor_nodes(true)
schedule_refresh() # Join before monitoring so our own join doesn't trigger a spurious refresh.
end :pg.join(ElixirAi.LiveViewPG, {:liveview, __MODULE__}, self())
{pg_ref, _} = :pg.monitor_scope(ElixirAi.LiveViewPG)
{runner_pg_ref, _} = :pg.monitor_scope(ElixirAi.RunnerPG)
{singleton_pg_ref, _} = :pg.monitor_scope(ElixirAi.SingletonPG)
{:ok, assign(socket, cluster_info: gather_info())} 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
{: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 try do
try do ElixirAi.ClusterSingleton.status()
ElixirAi.ClusterSingleton.status() catch
catch _, _ -> :unreachable
_, _ -> :unreachable
end
else
case :rpc.call(node, ElixirAi.ClusterSingleton, :status, [], 3_000) do
{:badrpc, _} -> :unreachable
result -> result
end
end end
else
{node, status} case :rpc.call(node, ElixirAi.ClusterSingleton, :status, [], 3_000) do
end) {:badrpc, _} -> :unreachable
result -> result
singleton_locations =
Enum.map(configured, fn module ->
location =
case Horde.Registry.lookup(ElixirAi.ChatRegistry, module) do
[{pid, _}] -> node(pid)
_ -> nil
end end
end
{module, location} {node, status}
end) end)
end
# All ChatRunner entries in the distributed registry, keyed by conversation name. defp gather_singleton_locations do
# Each entry is a {name, node, pid, supervisor_node} tuple. running =
chat_runners = :pg.which_groups(ElixirAi.SingletonPG)
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)
|> Enum.sort_by(&elem(&1, 0)) |> Map.new()
# :pg is cluster-wide — one local call returns members from all nodes. ElixirAi.ClusterSingleton.configured_singletons()
# Processes are automatically removed from their group when they die. |> Enum.map(fn module -> {module, Map.get(running, module)} end)
liveviews = end
:pg.which_groups(ElixirAi.LiveViewPG)
|> Enum.flat_map(fn
{:liveview, view} ->
:pg.get_members(ElixirAi.LiveViewPG, {:liveview, view})
|> Enum.map(fn pid -> {view, node(pid)} end)
_ -> # All ChatRunner entries via :pg membership, keyed by conversation name.
[] # Each entry is a {name, node, pid} tuple.
end) 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)
%{ _ ->
nodes: node_statuses, []
configured_singletons: configured, end)
singleton_locations: singleton_locations, |> Enum.sort_by(&elem(&1, 0))
chat_runners: chat_runners, end
liveviews: liveviews
} # :pg is cluster-wide — one local call returns members from all nodes.
# Processes are automatically removed from their group when they die.
defp gather_liveviews do
:pg.which_groups(ElixirAi.LiveViewPG)
|> Enum.flat_map(fn
{:liveview, view} ->
:pg.get_members(ElixirAi.LiveViewPG, {:liveview, view})
|> Enum.map(fn pid -> {view, node(pid)} end)
_ ->
[]
end)
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 &amp; runners all refresh on membership changes.
</p>
</div> </div>
""" """
end end