mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-15 07:56:31 +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
12 changed files with 383 additions and 8 deletions
|
@ -11,10 +11,10 @@ defmodule LiveBook.Application do
|
||||||
LiveBookWeb.Telemetry,
|
LiveBookWeb.Telemetry,
|
||||||
# Start the PubSub system
|
# Start the PubSub system
|
||||||
{Phoenix.PubSub, name: LiveBook.PubSub},
|
{Phoenix.PubSub, name: LiveBook.PubSub},
|
||||||
|
# Start the supervisor dynamically managing sessions
|
||||||
|
LiveBook.SessionSupervisor,
|
||||||
# Start the Endpoint (http/https)
|
# Start the Endpoint (http/https)
|
||||||
LiveBookWeb.Endpoint
|
LiveBookWeb.Endpoint
|
||||||
# Start a worker by calling: LiveBook.Worker.start_link(arg)
|
|
||||||
# {LiveBook.Worker, arg}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# 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
|
pipe_through :browser
|
||||||
|
|
||||||
live "/", HomeLive
|
live "/", HomeLive
|
||||||
|
live "/sessions", SessionsLive
|
||||||
|
live "/sessions/:id", SessionLive
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Other scopes may use custom stacks.
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
<nav class="flex items-center justify-between px-2 py-3 bg-purple-600">
|
<nav class="flex items-center justify-between px-8 py-3 bg-purple-600">
|
||||||
<div class="w-full px-8">
|
<div class="w-full">
|
||||||
<%= live_redirect "LiveBook", to: "/", class: "text-sm font-bold leading-relaxed inline-block mr-4 py-2 whitespace-no-wrap uppercase text-white" %>
|
<%= live_redirect "LiveBook", to: "/", class: "font-bold inline-block mr-4 py-2 text-white" %>
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="container mx-auto flex flex-col align-center">
|
<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") %>"/>
|
<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>
|
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-50">
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
test "disconnected and connected render", %{conn: conn} do
|
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 disconnected_html =~ "Welcome to LiveBook"
|
||||||
assert render(page_live) =~ "Welcome to LiveBook"
|
assert render(view) =~ "Welcome to LiveBook"
|
||||||
end
|
end
|
||||||
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…
Add table
Reference in a new issue