Skip to content

Commit 2a9b81b

Browse files
authored
feat: add group lookup to admin muster dashboard (#2062)
1 parent 3dfce67 commit 2a9b81b

5 files changed

Lines changed: 339 additions & 3 deletions

File tree

forum/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Forum.Muster.join(scope, group, pid) # :ok | {:error, :rpc_failed | :not_
8282
Forum.Muster.leave(scope, group, pid) # :ok | {:error, term}
8383
Forum.Muster.router(scope, group) # {:ok, node} | {:rebalancing, [node]}
8484
Forum.Muster.targets(scope, group, sender_view_hash) # (on router) {:ok, [node]} | {:error, :flood}
85+
Forum.Muster.occupancy(scope, group) # (on router) raw held [node], NOT barrier-gated
8586
Forum.Muster.members(scope) # [node]
8687
Forum.Muster.status(scope) # :ready | :converging | :rebalancing | :unknown
8788
Forum.Muster.local_members(scope, group) # [pid]

forum/lib/forum/muster.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,19 @@ defmodule Forum.Muster do
355355
end
356356
end
357357

358+
@doc """
359+
Returns the source nodes this node's router-role occupancy table currently
360+
holds for `group` in `scope` — the raw table content.
361+
362+
Unlike `targets/3` this is **not** barrier-gated: it does not consult
363+
`can_decide?/2`, so it reflects what this node believes about `group` even when
364+
a broadcast would flood. Intended for inspection/debugging
365+
"""
366+
@spec occupancy(atom, group) :: [node]
367+
def occupancy(scope, group) when is_atom(scope) do
368+
Scope.occupancy(scope, group)
369+
end
370+
358371
defp ring_name(scope), do: :"#{scope}_muster_ring"
359372

360373
@doc "List local pids registered to `group` in `scope`."

forum/test/forum/muster_test.exs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,13 @@ defmodule Forum.MusterTest do
462462
assert :src@nowhere in Scope.occupancy(scope, :rg1)
463463
end
464464

465+
test "Muster.occupancy/2 exposes the raw router-held rows", %{scope: scope} do
466+
assert :ok = Scope.occupied(scope, :rg_pub, :src@nowhere, 1, fake_pid())
467+
# Public, non-barrier-gated read; delegates to Scope.occupancy/2.
468+
assert Muster.occupancy(scope, :rg_pub) == Scope.occupancy(scope, :rg_pub)
469+
assert :src@nowhere in Muster.occupancy(scope, :rg_pub)
470+
end
471+
465472
test "vacant_batch/4 deletes multiple {group, source_node} rows", %{scope: scope} do
466473
src = fake_pid()
467474
:ok = Scope.occupied(scope, :rg2a, :src@nowhere, 1, src)

lib/realtime_web/dashboard/muster.ex

Lines changed: 217 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,18 @@ defmodule RealtimeWeb.Dashboard.Muster do
88
node's health at a glance. Use the node picker (top-right) to switch nodes;
99
the page reads the selected node from `page.node` and RPCs it for the snapshot
1010
(the LiveView itself always runs on the serving node).
11+
12+
It also offers a **group lookup**: given a group id, it resolves the group's
13+
router on the inspected node (`Forum.Muster.router/2`), RPCs that router for
14+
the barrier-gated fan-out targets (`Forum.Muster.targets/3`), and shows both
15+
the inspected node's local member count and the raw occupancy the router holds
16+
for the group. This makes it easy to see, for one group, who would receive a
17+
broadcast and why (targets vs. flood).
1118
"""
1219
use Phoenix.LiveDashboard.PageBuilder
1320

21+
alias Realtime.GenRpc
22+
1423
# Lifecycle status -> Bootstrap contextual color.
1524
@status_colors %{
1625
ready: "success",
@@ -26,19 +35,48 @@ defmodule RealtimeWeb.Dashboard.Muster do
2635

2736
@impl true
2837
def mount(_params, _session, socket) do
29-
{:ok, assign(socket, node_data: node_data(socket.assigns.page.node))}
38+
{:ok, assign(socket, group_query: nil) |> refresh_data()}
3039
end
3140

3241
@impl true
3342
def handle_event("refresh", _params, socket) do
34-
{:noreply, assign(socket, node_data: node_data(socket.assigns.page.node))}
43+
{:noreply, refresh_data(socket)}
44+
end
45+
46+
@impl true
47+
def handle_event("lookup_group", %{"group" => group}, socket) do
48+
query = normalize_group(group)
49+
{:noreply, socket |> assign(group_query: query) |> refresh_data()}
50+
end
51+
52+
@impl true
53+
def handle_event("clear_group", _params, socket) do
54+
{:noreply, socket |> assign(group_query: nil) |> refresh_data()}
3555
end
3656

3757
# Invoked by LiveDashboard's automatic refresh timer (enabled by default). Without
3858
# this, the timer would tick and re-render but never re-fetch the snapshot.
3959
@impl true
4060
def handle_refresh(socket) do
41-
{:noreply, assign(socket, node_data: node_data(socket.assigns.page.node))}
61+
{:noreply, refresh_data(socket)}
62+
end
63+
64+
# Re-fetches the node snapshot and (if a group is queried) re-runs the group
65+
# lookup, so both stay live across the manual and automatic refresh paths.
66+
defp refresh_data(socket) do
67+
node = socket.assigns.page.node
68+
69+
assign(socket,
70+
node_data: node_data(node),
71+
group_data: group_data(node, socket.assigns.group_query)
72+
)
73+
end
74+
75+
defp normalize_group(group) do
76+
case String.trim(group) do
77+
"" -> nil
78+
trimmed -> trimmed
79+
end
4280
end
4381

4482
@impl true
@@ -102,12 +140,95 @@ defmodule RealtimeWeb.Dashboard.Muster do
102140
</div>
103141
<% end %>
104142
</div>
143+
144+
<div class="card mb-3">
145+
<div class="card-header"><strong>Group lookup</strong></div>
146+
<div class="card-body">
147+
<form phx-submit="lookup_group" class="d-flex gap-2 align-items-center mb-0">
148+
<input
149+
type="text"
150+
name="group"
151+
value={@group_query}
152+
placeholder="group id (e.g. tenant id)"
153+
class="form-control form-control-sm flex-grow-1"
154+
autocomplete="off"
155+
/>
156+
<button type="submit" class="btn btn-sm btn-primary text-nowrap">Look up</button>
157+
<%= if @group_query do %>
158+
<button type="button" phx-click="clear_group" class="btn btn-sm btn-outline-secondary text-nowrap">Clear</button>
159+
<% end %>
160+
</form>
161+
162+
<%= if @group_data do %>
163+
<%= if @group_data.error do %>
164+
<div class="text-danger small mt-3"><%= @group_data.error %></div>
165+
<% else %>
166+
<table class="table table-sm table-hover mb-0 mt-3" style="table-layout: fixed">
167+
<colgroup>
168+
<col style="width: 16rem" />
169+
<col />
170+
</colgroup>
171+
<tbody>
172+
<tr><td class="ps-3">Group</td><td class="text-break"><code><%= inspect(@group_data.group) %></code></td></tr>
173+
<tr>
174+
<td class="ps-3">Inspected node</td>
175+
<td class="text-break"><%= @group_data.node %></td>
176+
</tr>
177+
<tr>
178+
<td class="ps-3">Local members here</td>
179+
<td><%= @group_data.local_member_count %></td>
180+
</tr>
181+
<tr>
182+
<td class="ps-3">Sender view hash</td>
183+
<td class="text-break"><code><%= inspect(@group_data.view_hash) %></code></td>
184+
</tr>
185+
<tr>
186+
<td class="ps-3">Router (<code>router/2</code>)</td>
187+
<td class="text-break"><%= fmt_router(@group_data.router) %></td>
188+
</tr>
189+
<tr>
190+
<td class="ps-3">Targets via router (<code>targets/3</code>)</td>
191+
<td class="text-break"><%= fmt_targets(@group_data.targets) %></td>
192+
</tr>
193+
<tr>
194+
<td class="ps-3">Router occupancy (raw, count)</td>
195+
<td class="text-break"><%= fmt_occupancy(@group_data.router_occupancy) %></td>
196+
</tr>
197+
</tbody>
198+
</table>
199+
<% end %>
200+
<% end %>
201+
</div>
202+
</div>
105203
</div>
106204
"""
107205
end
108206

109207
defp status_color(status), do: Map.get(@status_colors, status, "secondary")
110208

209+
defp fmt_router({:ok, node}), do: to_string(node)
210+
defp fmt_router({:rebalancing, nodes}), do: "rebalancing — fan out to #{inspect(nodes)}"
211+
212+
defp fmt_targets({:ok, nodes}),
213+
do: "#{inspect(Enum.sort(nodes))} (#{length(nodes)} node#{plural(nodes)})"
214+
215+
defp fmt_targets({:error, :flood}),
216+
do: "flood — barrier not satisfied (converging, or sender/router view mismatch)"
217+
218+
defp fmt_targets({:rebalancing, nodes}),
219+
do: "rebalancing — no single router; fan out to #{inspect(nodes)}"
220+
221+
defp fmt_targets({:error, :rpc_error, reason}), do: "router RPC failed: #{inspect(reason)}"
222+
223+
defp fmt_occupancy(nodes) when is_list(nodes),
224+
do: "#{inspect(Enum.sort(nodes))} (#{length(nodes)})"
225+
226+
defp fmt_occupancy({:rebalancing, _nodes}), do: "—"
227+
defp fmt_occupancy({:error, :rpc_error, reason}), do: "router RPC failed: #{inspect(reason)}"
228+
229+
defp plural([_]), do: ""
230+
defp plural(_), do: "s"
231+
111232
# The page LiveView always runs on the serving node; `page.node` is the node
112233
# chosen in the picker (a URL param). So we gather locally only when that node
113234
# IS us, and RPC to gather_local_info/0 on the selected node otherwise.
@@ -152,6 +273,99 @@ defmodule RealtimeWeb.Dashboard.Muster do
152273
end
153274
end
154275

276+
# Group lookup: reads the inspected node's local routing state, then (on the
277+
# serving node) asks that state's router node for its targets/occupancy — the
278+
# two hops kept flat rather than nested. nil group => no lookup.
279+
defp group_data(_target, nil), do: nil
280+
281+
defp group_data(target, group) do
282+
base = %{group: group, node: target, error: nil}
283+
284+
# Two independent hops, orchestrated here on the serving node so neither RPC
285+
# is nested inside the other: first read the inspected node's local routing
286+
# state, then ask that state's router node for its targets/occupancy.
287+
case GenRpc.call(target, __MODULE__, :gather_local_group_info, [group], []) do
288+
{:ok, local} ->
289+
{targets, occupancy} = router_view(local.scope, group, local.view_hash, local.router)
290+
291+
Map.merge(base, %{
292+
local_member_count: local.local_member_count,
293+
view_hash: local.view_hash,
294+
router: local.router,
295+
targets: targets,
296+
router_occupancy: occupancy
297+
})
298+
299+
{:error, :rpc_error, reason} ->
300+
Map.put(base, :error, "RPC failed: #{inspect(reason)}")
301+
302+
{:error, msg} ->
303+
Map.put(base, :error, msg)
304+
end
305+
end
306+
307+
@doc """
308+
Reads the inspected node's local routing state for `group`: the configured
309+
scope, this node's cluster-view hash (the sender hash used for `targets/3`),
310+
the group's router as this node's ring sees it (`Forum.Muster.router/2`), and
311+
the local member count here.
312+
313+
Runs on the node being inspected so `router/2` and the view hash reflect that
314+
node. `group_data/2` then asks the returned router node for `targets/3` and its
315+
raw occupancy, keeping the two hops flat rather than nesting one RPC in another.
316+
317+
Returns `{:error, message}` when no scope is configured or the coordinator has
318+
not published its state yet, so the page can surface a friendly error.
319+
"""
320+
def gather_local_group_info(group) do
321+
case Application.get_env(:realtime, :muster_scope) do
322+
nil ->
323+
{:error, "No Muster scope configured on this node"}
324+
325+
scope ->
326+
try do
327+
{:ok,
328+
%{
329+
scope: scope,
330+
view_hash: Forum.Muster.view_hash(scope),
331+
router: Forum.Muster.router(scope, group),
332+
local_member_count: Forum.Muster.local_member_count(scope, group)
333+
}}
334+
rescue
335+
ArgumentError ->
336+
{:error, "Muster scope #{inspect(scope)} has no published state on this node yet"}
337+
catch
338+
:exit, reason -> {:error, "Muster group lookup failed: #{inspect(reason)}"}
339+
end
340+
end
341+
end
342+
343+
# With a settled ring, ask the router node for its targets/3 and raw occupancy
344+
# in a single RPC; while rebalancing there is no single router, so surface the
345+
# fan-out list. GenRpc.call handles router_node == node() itself; on RPC failure
346+
# both slots carry the {:error, :rpc_error, _} for the fmt_* helpers to render.
347+
defp router_view(scope, group, view_hash, {:ok, router_node}) do
348+
case GenRpc.call(router_node, __MODULE__, :router_group_info, [scope, group, view_hash], []) do
349+
{:error, :rpc_error, _} = error -> {error, error}
350+
{targets, occupancy} -> {targets, occupancy}
351+
end
352+
end
353+
354+
defp router_view(_scope, _group, _view_hash, {:rebalancing, nodes} = rebalancing) do
355+
{rebalancing, {:rebalancing, nodes}}
356+
end
357+
358+
@doc """
359+
Reads the router-role view of `group` on this (router) node in one hop: the
360+
barrier-gated fan-out targets (`Forum.Muster.targets/3`) and the raw occupancy
361+
the router currently holds (`Forum.Muster.Scope.occupancy/2`). Returned as
362+
`{targets_result, occupancy}` so `group_data/2` fetches both with a single RPC
363+
to the router.
364+
"""
365+
def router_group_info(scope, group, view_hash) do
366+
{Forum.Muster.targets(scope, group, view_hash), Forum.Muster.occupancy(scope, group)}
367+
end
368+
155369
defp to_view(s) do
156370
counts = s.group_state_counts
157371
group_counts = Enum.map(@group_state_order, fn state -> {state, Map.get(counts, state, 0)} end)

0 commit comments

Comments
 (0)