Add module for managing multiple running sessions (#3)

* Add dynamic supervisor for session processes

* Add basic UI listing current sessions

* Handle review comments

* Make tests not dependent on restarting SessionSupervisor
This commit is contained in:
Jonatan Kłosko 2021-01-08 14:14:26 +01:00 committed by GitHub
parent 5877180934
commit 5cdcb15e3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 383 additions and 8 deletions

View file

@ -11,10 +11,10 @@ defmodule LiveBook.Application do
LiveBookWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: LiveBook.PubSub},
# Start the supervisor dynamically managing sessions
LiveBook.SessionSupervisor,
# Start the Endpoint (http/https)
LiveBookWeb.Endpoint
# Start a worker by calling: LiveBook.Worker.start_link(arg)
# {LiveBook.Worker, arg}
]
# See https://hexdocs.pm/elixir/Supervisor.html

47
lib/live_book/session.ex Normal file
View file

@ -0,0 +1,47 @@
defmodule LiveBook.Session do
@moduledoc false
# Server corresponding to a single notebook session.
#
# The process keeps the current notebook state and serves
# as a source of truth that multiple clients talk to.
# Receives update requests from the clients and notifies
# them of any changes applied to the notebook.
use GenServer, restart: :temporary
@typedoc """
An id assigned to every running session process.
"""
@type session_id :: LiveBook.Utils.id()
## API
@doc """
Starts the server process and registers it globally using the `:global` module,
so that it's identifiable by the given id.
"""
@spec start_link(session_id()) :: GenServer.on_start()
def start_link(session_id) do
GenServer.start_link(__MODULE__, [session_id: session_id], name: name(session_id))
end
defp name(session_id) do
{:global, {:session, session_id}}
end
@doc """
Synchronously stops the server.
"""
@spec stop(session_id()) :: :ok
def stop(session_id) do
GenServer.stop(name(session_id))
end
## Callbacks
@impl true
def init(session_id: _id) do
{:ok, %{}}
end
end

View file

@ -0,0 +1,96 @@
defmodule LiveBook.SessionSupervisor do
@moduledoc false
# Supervisor responsible for managing running notebook sessions.
#
# Allows for creating new session processes on demand
# and managing them using random ids.
use DynamicSupervisor
alias LiveBook.{Session, Utils}
@name __MODULE__
def start_link(opts \\ []) do
DynamicSupervisor.start_link(__MODULE__, opts, name: @name)
end
@impl true
def init(_opts) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@doc """
Spawns a new session process.
Broadcasts `{:session_created, id}` message under the `"sessions"` topic.
"""
@spec create_session() :: {:ok, Session.session_id()} | {:error, any()}
def create_session() do
id = Utils.random_id()
case DynamicSupervisor.start_child(@name, {Session, id}) do
{:ok, _} ->
broadcast_sessions_message({:session_created, id})
{:ok, id}
{:ok, _, _} ->
broadcast_sessions_message({:session_created, id})
{:ok, id}
:ignore ->
{:error, :ignore}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Synchronously stops a session process identified by the given id.
Broadcasts `{:session_delete, id}` message under the `"sessions"` topic.
"""
@spec delete_session(Session.session_id()) :: :ok
def delete_session(id) do
Session.stop(id)
broadcast_sessions_message({:session_deleted, id})
:ok
end
defp broadcast_sessions_message(message) do
Phoenix.PubSub.broadcast(LiveBook.PubSub, "sessions", message)
end
@doc """
Returns ids of all the running session processes.
"""
@spec get_session_ids() :: list(Session.session_id())
def get_session_ids() do
:global.registered_names()
|> Enum.flat_map(fn
{:session, id} -> [id]
_ -> []
end)
end
@doc """
Checks if a session process with the given id exists.
"""
@spec session_exists?(Session.session_id()) :: boolean()
def session_exists?(id) do
:global.whereis_name({:session, id}) != :undefined
end
@doc """
Retrieves pid of a session process identified by the given id.
"""
@spec get_session_pid(Session.session_id()) :: {:ok, pid()} | {:error, :nonexistent}
def get_session_pid(id) do
case :global.whereis_name({:session, id}) do
:undefined -> {:error, :nonexistent}
pid -> {:ok, pid}
end
end
end

13
lib/live_book/utils.ex Normal file
View file

@ -0,0 +1,13 @@
defmodule LiveBook.Utils do
@moduledoc false
@type id :: binary()
@doc """
Generates a random binary id.
"""
@spec random_id() :: binary()
def random_id() do
:crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower)
end
end

View file

@ -0,0 +1,17 @@
defmodule LiveBookWeb.SessionLive do
use LiveBookWeb, :live_view
@impl true
def mount(%{"id" => session_id}, _session, socket) do
{:ok, assign(socket, session_id: session_id)}
end
@impl true
def render(assigns) do
~L"""
<div class="container max-w-screen-md p-4 mx-auto">
Session <%= @session_id %>
</div>
"""
end
end

View file

@ -0,0 +1,75 @@
defmodule LiveBookWeb.SessionsLive do
use LiveBookWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions")
end
session_ids = LiveBook.SessionSupervisor.get_session_ids()
{:ok, assign(socket, session_ids: session_ids)}
end
@impl true
def render(assigns) do
~L"""
<div class="container max-w-screen-md p-4 mx-auto">
<div class="flex flex-col shadow-md rounded px-3 py-2 mb-4">
<div class="text-gray-700 text-lg font-semibold p-2">
Sessions
</div>
<%= for session_id <- Enum.sort(@session_ids) do %>
<div class="p-3 flex">
<div class="flex-grow text-lg hover:opacity-70">
<%= live_redirect session_id, to: Routes.live_path(@socket, LiveBookWeb.SessionLive, session_id) %>
</div>
<div>
<button phx-click="delete_session" phx-value-id="<%= session_id %>" aria-label="delete">
<svg class="h-6 w-6 hover:opacity-70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<% end %>
</div>
<button phx-click="create_session" class="text-base font-medium rounded py-2 px-3 bg-purple-400 text-white shadow-md focus">
New session
</button>
</div>
"""
end
@impl true
def handle_event("create_session", _params, socket) do
case LiveBook.SessionSupervisor.create_session() do
{:ok, id} ->
{:noreply,
push_redirect(socket, to: Routes.live_path(socket, LiveBookWeb.SessionLive, id))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to create a notebook: #{reason}")}
end
end
def handle_event("delete_session", %{"id" => session_id}, socket) do
LiveBook.SessionSupervisor.delete_session(session_id)
{:noreply, socket}
end
@impl true
def handle_info({:session_created, id}, socket) do
session_ids = [id | socket.assigns.session_ids]
{:noreply, assign(socket, :session_ids, session_ids)}
end
def handle_info({:session_deleted, id}, socket) do
session_ids = List.delete(socket.assigns.session_ids, id)
{:noreply, assign(socket, :session_ids, session_ids)}
end
end

View file

@ -18,6 +18,8 @@ defmodule LiveBookWeb.Router do
pipe_through :browser
live "/", HomeLive
live "/sessions", SessionsLive
live "/sessions/:id", SessionLive
end
# Other scopes may use custom stacks.

View file

@ -1,7 +1,12 @@
<nav class="flex items-center justify-between px-2 py-3 bg-purple-600">
<div class="w-full px-8">
<%= live_redirect "LiveBook", to: "/", class: "text-sm font-bold leading-relaxed inline-block mr-4 py-2 whitespace-no-wrap uppercase text-white" %>
<nav class="flex items-center justify-between px-8 py-3 bg-purple-600">
<div class="w-full">
<%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
</div>
<ul class="flex flex-row">
<li class="flex items-center">
<%= live_redirect "Sessions", to: "/sessions", class: "px-3 text-xs uppercase font-bold text-white hover:opacity-75" %>
</li>
</ul>
</nav>
<main role="main" class="container mx-auto flex flex-col align-center">

View file

@ -9,7 +9,7 @@
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<body class="bg-gray-50">
<%= @inner_content %>
</body>
</html>

View file

@ -0,0 +1,78 @@
defmodule LiveBook.SessionSupervisorTest do
use ExUnit.Case
alias LiveBook.SessionSupervisor
describe "create_session/0" do
test "creates a new session process and returns its id" do
{:ok, id} = SessionSupervisor.create_session()
{:ok, pid} = SessionSupervisor.get_session_pid(id)
assert has_child_with_pid?(SessionSupervisor, pid)
end
test "broadcasts a message" do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions")
{:ok, id} = SessionSupervisor.create_session()
assert_receive {:session_created, ^id}
end
end
describe "delete_session/1" do
test "stops the session process identified by the given id" do
{:ok, id} = SessionSupervisor.create_session()
{:ok, pid} = SessionSupervisor.get_session_pid(id)
SessionSupervisor.delete_session(id)
refute has_child_with_pid?(SessionSupervisor, pid)
end
test "broadcasts a message" do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions")
{:ok, id} = SessionSupervisor.create_session()
SessionSupervisor.delete_session(id)
assert_receive {:session_deleted, ^id}
end
end
describe "get_session_ids/0" do
test "lists ids identifying sessions running under the supervisor" do
{:ok, id} = SessionSupervisor.create_session()
assert id in SessionSupervisor.get_session_ids()
end
end
describe "session_exists?/1" do
test "returns true if a session process with the given id exists" do
{:ok, id} = SessionSupervisor.create_session()
assert SessionSupervisor.session_exists?(id)
end
test "returns false if a session process with the given id does not exist" do
refute SessionSupervisor.session_exists?("nonexistent")
end
end
describe "get_session_pid/1" do
test "returns pid if a session process with the given id is running" do
{:ok, id} = SessionSupervisor.create_session()
assert {:ok, pid} = SessionSupervisor.get_session_pid(id)
assert :global.whereis_name({:session, id}) == pid
end
test "returns an error if a session process with the given id does not exist" do
assert {:error, :nonexistent} = SessionSupervisor.get_session_pid("nonexistent")
end
end
defp has_child_with_pid?(supervisor, pid) do
List.keymember?(DynamicSupervisor.which_children(supervisor), pid, 1)
end
end

View file

@ -4,8 +4,8 @@ defmodule LiveBookWeb.HomeLiveTest do
import Phoenix.LiveViewTest
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/")
{:ok, view, disconnected_html} = live(conn, "/")
assert disconnected_html =~ "Welcome to LiveBook"
assert render(page_live) =~ "Welcome to LiveBook"
assert render(view) =~ "Welcome to LiveBook"
end
end

View file

@ -0,0 +1,42 @@
defmodule LiveBookWeb.SessionsLiveTest do
use LiveBookWeb.ConnCase
import Phoenix.LiveViewTest
test "disconnected and connected render", %{conn: conn} do
{:ok, view, disconnected_html} = live(conn, "/sessions")
assert disconnected_html =~ "Sessions"
assert render(view) =~ "Sessions"
end
test "lists running sessions", %{conn: conn} do
{:ok, id1} = LiveBook.SessionSupervisor.create_session()
{:ok, id2} = LiveBook.SessionSupervisor.create_session()
{:ok, view, _} = live(conn, "/sessions")
assert render(view) =~ id1
assert render(view) =~ id2
end
test "redirects to session upon creation", %{conn: conn} do
{:ok, view, _} = live(conn, "/sessions")
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element("button", "New session")
|> render_click()
assert to =~ "/sessions/"
end
test "updates UI whenever a session is added or deleted", %{conn: conn} do
{:ok, view, _} = live(conn, "/sessions")
{:ok, id} = LiveBook.SessionSupervisor.create_session()
assert render(view) =~ id
LiveBook.SessionSupervisor.delete_session(id)
refute render(view) =~ id
end
end