mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
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:
parent
5877180934
commit
5cdcb15e3d
|
@ -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
47
lib/live_book/session.ex
Normal 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
|
96
lib/live_book/session_supervisor.ex
Normal file
96
lib/live_book/session_supervisor.ex
Normal 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
13
lib/live_book/utils.ex
Normal 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
|
17
lib/live_book_web/live/session_live.ex
Normal file
17
lib/live_book_web/live/session_live.ex
Normal 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
|
75
lib/live_book_web/live/sessions_live.ex
Normal file
75
lib/live_book_web/live/sessions_live.ex
Normal 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
|
|
@ -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.
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
78
test/live_book/session_supervisor_test.exs
Normal file
78
test/live_book/session_supervisor_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
42
test/live_book_web/live/sessions_live_test.exs
Normal file
42
test/live_book_web/live/sessions_live_test.exs
Normal 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
|
Loading…
Reference in a new issue