diff --git a/lib/live_book/application.ex b/lib/live_book/application.ex index 56193cde8..b095587c4 100644 --- a/lib/live_book/application.ex +++ b/lib/live_book/application.ex @@ -12,9 +12,9 @@ defmodule LiveBook.Application do # Start the PubSub system {Phoenix.PubSub, name: LiveBook.PubSub}, # Start the Endpoint (http/https) - LiveBookWeb.Endpoint - # Start a worker by calling: LiveBook.Worker.start_link(arg) - # {LiveBook.Worker, arg} + LiveBookWeb.Endpoint, + # Start the supervisor dynamically managing sessions + LiveBook.SessionSupervisor ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex new file mode 100644 index 000000000..7f265df91 --- /dev/null +++ b/lib/live_book/session.ex @@ -0,0 +1,47 @@ +defmodule LiveBook.Session do + @moduledoc """ + 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 """ + A UUID assigned to every running session process. + """ + @type session_id :: String.t() + + ## 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 diff --git a/lib/live_book/session_supervisor.ex b/lib/live_book/session_supervisor.ex new file mode 100644 index 000000000..6052f67c2 --- /dev/null +++ b/lib/live_book/session_supervisor.ex @@ -0,0 +1,85 @@ +defmodule LiveBook.SessionSupervisor do + @moduledoc """ + Supervisor responsible for managing running notebook sessions. + + Allows for creating new session processes on demand + and managing them using UUIDs. + """ + + use DynamicSupervisor + + alias LiveBook.Session + + @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 = UUID.uuid4() + + 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 +end diff --git a/mix.exs b/mix.exs index e407de340..77e807b4d 100644 --- a/mix.exs +++ b/mix.exs @@ -41,7 +41,8 @@ defmodule LiveBook.MixProject do {:telemetry_metrics, "~> 0.4"}, {:telemetry_poller, "~> 0.4"}, {:jason, "~> 1.0"}, - {:plug_cowboy, "~> 2.0"} + {:plug_cowboy, "~> 2.0"}, + {:elixir_uuid, "~> 1.2"} ] end diff --git a/mix.lock b/mix.lock index 31d2a80ec..6fe38bea4 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, diff --git a/test/live_book/session_supervisor_test.exs b/test/live_book/session_supervisor_test.exs new file mode 100644 index 000000000..ad7541fab --- /dev/null +++ b/test/live_book/session_supervisor_test.exs @@ -0,0 +1,70 @@ +defmodule LiveBook.SessionSupervisorTest do + use ExUnit.Case + + alias LiveBook.SessionSupervisor + + setup do + on_exit(fn -> + # Start a fresh SessionSupervisor for each test + Supervisor.terminate_child(LiveBook.Supervisor, LiveBook.SessionSupervisor) + Supervisor.restart_child(LiveBook.Supervisor, LiveBook.SessionSupervisor) + end) + end + + describe "create_session/0" do + test "creates a new session process and returns its id" do + {:ok, id} = SessionSupervisor.create_session() + + assert [{_, pid, _, [LiveBook.Session]}] = + DynamicSupervisor.which_children(SessionSupervisor) + + assert pid == :global.whereis_name({:session, id}) + 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() + + SessionSupervisor.delete_session(id) + + assert [] = DynamicSupervisor.which_children(SessionSupervisor) + 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 SessionSupervisor.get_session_ids() == [id] + 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 +end