Ankyra: deleting one works, and its worker stops with it

17c8ef1 · Gabe Morell · 2026-11-04 22:51

17 files +95 -3
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/accounts.ex
+30 −1
@@ -86,6 +86,12 @@ defmodule RoomSanctum.Accounts do
86 86 @doc """
87 87 Deletes a rabbit_user.
88 88
89 + The row is all there is to undo. Nothing is set up on the broker when one
90 + is created: RabbitMQ asks room_hermes's HTTP auth backend about every
91 + login and topic, and that answers from this table, so once the row is gone
92 + the username and password stop working. Its Ankyra worker is told, so it
93 + stops rather than finding out on its next re-read.
94 +
89 95 ## Examples
90 96
91 97 iex> delete_rabbit_user(rabbit_user)
@@ -96,9 +102,32 @@ defmodule RoomSanctum.Accounts do
96 102
97 103 """
98 104 def delete_rabbit_user(%RabbitUser{} = rabbit_user) do
99 − Repo.delete(rabbit_user)
105 + rabbit_user |> Repo.delete() |> tap(&stop_worker/1)
106 + end
107 +
108 + @doc """
109 + Deletes a rabbit_user as `delete_rabbit_user/1` does, raising if it cannot
110 + -- `Ecto.StaleEntryError` when it is already gone.
111 +
112 + ## Examples
113 +
114 + iex> delete_rabbit_user!(rabbit_user)
115 + %RabbitUser{}
116 +
117 + """
118 + def delete_rabbit_user!(%RabbitUser{} = rabbit_user) do
119 + deleted = Repo.delete!(rabbit_user)
120 + stop_worker({:ok, deleted})
121 + deleted
100 122 end
101 123
124 + # The worker re-reads its row, and stops when there is none. A cast, so it
125 + # is a no-op when no worker is running.
126 + defp stop_worker({:ok, %RabbitUser{id: id}}),
127 + do: RoomSanctum.Worker.Ankyra.refresh_db_cfg(to_string(id))
128 +
129 + defp stop_worker(_result), do: :ok
130 +
102 131 @doc """
103 132 Returns an `%Ecto.Changeset{}` for tracking rabbit_user changes.
104 133
modified apps/room_sanctum/lib/room_sanctum/workers/ankyra_worker.ex
+7 −1
@@ -1,6 +1,8 @@
1 1 defmodule RoomSanctum.Worker.Ankyra do
2 2 @moduledoc false
3 − use GenServer
3 + # Transient: a worker whose rabbit user has been deleted stops normally and
4 + # stays stopped, rather than being restarted into the same missing row.
5 + use GenServer, restart: :transient
4 6
5 7 require Logger
6 8
@@ -180,6 +182,10 @@ defmodule RoomSanctum.Worker.Ankyra do
180 182 def handle_cast(:refresh_db_cfg, state) do
181 183 p = Accounts.get_rabbit_user!(state[:id])
182 184 {:noreply, state |> Map.put(:ankyra, p) |> listen_for_requests()}
185 + rescue
186 + # Deleted. Raising here had the supervisor restart it straight back into
187 + # the same missing row, until it gave up and took every Ankyra with it.
188 + Ecto.NoResultsError -> {:stop, :normal, state}
183 189 end
184 190
185 191 def handle_cast({:publish, data}, state) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/index.ex
+1 −1
@@ -42,7 +42,7 @@ defmodule RoomSanctumWeb.AnkyraLive.Index do
42 42 @impl true
43 43 def handle_event("delete", %{"id" => id}, socket) do
44 44 ankyra = Accounts.get_rabbit_user!(owner(socket), id)
45 − {:ok, _} = Accounts.delete_rabbit_user!(ankyra)
45 + Accounts.delete_rabbit_user!(ankyra)
46 46
47 47 {:noreply, stream_delete(socket, :ankyras, ankyra)}
48 48 end
added apps/room_sanctum/test/room_sanctum/delete_rabbit_user_test.exs
+48 −0
@@ -0,0 +1,48 @@
1 +defmodule RoomSanctum.DeleteRabbitUserTest do
2 + @moduledoc """
3 + Deleting an Ankyra -- a rabbit user -- removes its row, which is all the
4 + broker knows it by: RabbitMQ asks room_hermes's HTTP auth backend, which
5 + reads this table, and nothing is provisioned on the broker itself. Its
6 + worker, re-reading a row that is gone, stops and stays stopped.
7 + """
8 + use RoomSanctum.DataCase
9 +
10 + import RoomSanctum.OwnershipFixtures
11 +
12 + alias RoomSanctum.{Accounts, Repo}
13 + alias RoomSanctum.Worker.Ankyra
14 +
15 + setup :users_rabbit!
16 +
17 + test "delete_rabbit_user!/1 removes the row and returns it" do
18 + ankyra = ankyra_for(owner())
19 +
20 + assert %Accounts.RabbitUser{id: id} = Accounts.delete_rabbit_user!(ankyra)
21 + assert id == ankyra.id
22 + refute Repo.reload(ankyra)
23 + refute Accounts.find_rabbit_user(ankyra.username, ankyra.password)
24 + end
25 +
26 + test "delete_rabbit_user!/1 raises when there is nothing left to delete" do
27 + ankyra = ankyra_for(owner())
28 + {:ok, _} = Accounts.delete_rabbit_user(ankyra)
29 +
30 + assert_raise Ecto.StaleEntryError, fn -> Accounts.delete_rabbit_user!(ankyra) end
31 + end
32 +
33 + test "delete_rabbit_user/1 removes the row" do
34 + ankyra = ankyra_for(owner())
35 +
36 + assert {:ok, _} = Accounts.delete_rabbit_user(ankyra)
37 + refute Repo.reload(ankyra)
38 + end
39 +
40 + test "a worker whose rabbit user is gone stops normally, and is not restarted" do
41 + ankyra = ankyra_for(owner())
42 + {:ok, _} = Accounts.delete_rabbit_user(ankyra)
43 + state = %{id: to_string(ankyra.id), ankyra: ankyra, requests: nil}
44 +
45 + assert {:stop, :normal, _state} = Ankyra.handle_cast(:refresh_db_cfg, state)
46 + assert %{restart: :transient} = Ankyra.child_spec(id: "1")
47 + end
48 +end
modified apps/room_sanctum/test/room_sanctum_web/live/ownership_test.exs
+9 −0
@@ -495,6 +495,15 @@ defmodule RoomSanctumWeb.OwnershipTest do
495 495
496 496 assert Repo.reload(theirs)
497 497 end
498 +
499 + test "deletes the user's own ankyra", ctx do
500 + mine = ankyra_for(ctx.me)
501 + {:ok, live, _html} = live(ctx.conn, Routes.ankyra_index_path(ctx.conn, :index))
502 +
503 + render_click(live, "delete", %{"id" => to_string(mine.id)})
504 +
505 + refute Repo.reload(mine)
506 + end
498 507 end
499 508
500 509 defp scribus_for(user) do

Parents: feea568