mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-09 06:24:29 +08:00
* Define session data structure and some operations * Make code evaluation request async, so that we don't need an intermediary process * Simplify id typespecs * Make operation application composable * Keep a separate evaluation queue per section and actually support concurrent evaluation * Small fixes * Validate queued cell type and set evaluation timestamp * Apply review suggestions * Add tests * Store evaluating_cell_id instead of section status * Add dynamic supervisor for managing evaluator processes * Some fixes * Refactor operation application * Upon cell deletion mark dependent cells as stale
269 lines
7.9 KiB
Elixir
269 lines
7.9 KiB
Elixir
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.
|
|
#
|
|
# The core concept is the `Data` structure
|
|
# to which we can apply reproducible opreations.
|
|
# See `Data` for more information.
|
|
|
|
use GenServer, restart: :temporary
|
|
|
|
alias LiveBook.Session.Data
|
|
alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook}
|
|
alias LiveBook.Notebook.{Cell, Section}
|
|
|
|
@type state :: %{
|
|
session_id: id(),
|
|
data: Data.t(),
|
|
evaluators: %{Section.t() => Evaluator.t()},
|
|
client_pids: list(pid())
|
|
}
|
|
|
|
@typedoc """
|
|
An id assigned to every running session process.
|
|
"""
|
|
@type id :: 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(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 """
|
|
Registers a session client, so that it receives updates from the server.
|
|
|
|
The client process is automatically unregistered when it terminates.
|
|
"""
|
|
@spec register_client(id(), pid()) :: :ok
|
|
def register_client(session_id, pid) do
|
|
GenServer.cast(name(session_id), {:register_client, pid})
|
|
end
|
|
|
|
@doc """
|
|
Asynchronously sends section insertion request to the server.
|
|
"""
|
|
@spec insert_section(id(), non_neg_integer()) :: :ok
|
|
def insert_section(session_id, index) do
|
|
GenServer.cast(name(session_id), {:insert_section, index})
|
|
end
|
|
|
|
@doc """
|
|
Asynchronously sends cell insertion request to the server.
|
|
"""
|
|
@spec insert_cell(id(), Section.id(), non_neg_integer(), Cell.type()) ::
|
|
:ok
|
|
def insert_cell(session_id, section_id, index, type) do
|
|
GenServer.cast(name(session_id), {:insert_cell, section_id, index, type})
|
|
end
|
|
|
|
@doc """
|
|
Asynchronously sends section deletion request to the server.
|
|
"""
|
|
@spec delete_section(id(), Section.id()) :: :ok
|
|
def delete_section(session_id, section_id) do
|
|
GenServer.cast(name(session_id), {:delete_section, section_id})
|
|
end
|
|
|
|
@doc """
|
|
Asynchronously sends cell deletion request to the server.
|
|
"""
|
|
@spec delete_cell(id(), Cell.id()) :: :ok
|
|
def delete_cell(session_id, cell_id) do
|
|
GenServer.cast(name(session_id), {:delete_cell, cell_id})
|
|
end
|
|
|
|
@doc """
|
|
Asynchronously sends cell evaluation request to the server.
|
|
"""
|
|
@spec queue_cell_evaluation(id(), Cell.id()) :: :ok
|
|
def queue_cell_evaluation(session_id, cell_id) do
|
|
GenServer.cast(name(session_id), {:queue_cell_evaluation, cell_id})
|
|
end
|
|
|
|
@doc """
|
|
Synchronously stops the server.
|
|
"""
|
|
@spec stop(id()) :: :ok
|
|
def stop(session_id) do
|
|
GenServer.stop(name(session_id))
|
|
end
|
|
|
|
## Callbacks
|
|
|
|
@impl true
|
|
def init(session_id: session_id) do
|
|
{:ok,
|
|
%{
|
|
session_id: session_id,
|
|
data: Data.new(),
|
|
evaluators: %{},
|
|
client_pids: []
|
|
}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast({:register_client, pid}, state) do
|
|
Process.monitor(pid)
|
|
{:noreply, %{state | client_pids: [pid | state.client_pids]}}
|
|
end
|
|
|
|
def handle_cast({:insert_section, index}, state) do
|
|
# Include new id in the operation, so it's reproducible
|
|
operation = {:insert_section, index, Utils.random_id()}
|
|
handle_operation(state, operation)
|
|
end
|
|
|
|
def handle_cast({:insert_cell, section_id, index, type}, state) do
|
|
# Include new id in the operation, so it's reproducible
|
|
operation = {:insert_cell, section_id, index, type, Utils.random_id()}
|
|
handle_operation(state, operation)
|
|
end
|
|
|
|
def handle_cast({:delete_section, section_id}, state) do
|
|
operation = {:delete_section, section_id}
|
|
|
|
handle_operation(state, operation, fn new_state ->
|
|
delete_section_evaluator(new_state, section_id)
|
|
end)
|
|
end
|
|
|
|
def handle_cast({:delete_cell, cell_id}, state) do
|
|
operation = {:delete_cell, cell_id}
|
|
handle_operation(state, operation)
|
|
end
|
|
|
|
def handle_cast({:queue_cell_evaluation, cell_id}, state) do
|
|
operation = {:queue_cell_evaluation, cell_id}
|
|
|
|
handle_operation(state, operation, fn new_state ->
|
|
maybe_trigger_evaluations(state, new_state)
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:DOWN, _, :process, pid, _}, state) do
|
|
{:noreply, %{state | client_pids: List.delete(state.client_pids, pid)}}
|
|
end
|
|
|
|
def handle_info({:evaluator_stdout, cell_id, string}, state) do
|
|
operation = {:add_cell_evaluation_stdout, cell_id, string}
|
|
handle_operation(state, operation)
|
|
end
|
|
|
|
def handle_info({:evaluator_response, cell_id, response}, state) do
|
|
operation = {:add_cell_evaluation_response, cell_id, response}
|
|
|
|
handle_operation(state, operation, fn new_state ->
|
|
maybe_trigger_evaluations(state, new_state)
|
|
end)
|
|
end
|
|
|
|
# ---
|
|
|
|
# Given any opeation on `Data`, the process does the following:
|
|
#
|
|
# * broadcasts the operation to all clients immediately,
|
|
# so that they can update their local `Data`
|
|
# * applies the operation to own local `Data`
|
|
# * optionally performs a relevant task (e.g. starts cell evaluation),
|
|
# to reflect the new `Data`
|
|
#
|
|
defp handle_operation(state, operation) do
|
|
handle_operation(state, operation, fn state -> state end)
|
|
end
|
|
|
|
defp handle_operation(state, operation, handle_new_state) do
|
|
broadcast_operation(state.session_id, operation)
|
|
|
|
case Data.apply_operation(state.data, operation) do
|
|
{:ok, new_data} ->
|
|
new_state = %{state | data: new_data}
|
|
{:noreply, handle_new_state.(new_state)}
|
|
|
|
:error ->
|
|
{:noreply, state}
|
|
end
|
|
end
|
|
|
|
defp broadcast_operation(session_id, operation) do
|
|
message = {:operation, operation}
|
|
Phoenix.PubSub.broadcast(LiveBook.PubSub, "sessions:#{session_id}", message)
|
|
end
|
|
|
|
# Compares sections in the old and new state and if a new cell
|
|
# has been marked as evaluating it triggers the actual evaluation task.
|
|
defp maybe_trigger_evaluations(old_state, new_state) do
|
|
Enum.reduce(new_state.data.notebook.sections, new_state, fn section, state ->
|
|
case {Data.get_evaluating_cell_id(old_state.data, section.id),
|
|
Data.get_evaluating_cell_id(new_state.data, section.id)} do
|
|
{_, nil} ->
|
|
# No cell to evaluate
|
|
state
|
|
|
|
{cell_id, cell_id} ->
|
|
# The evaluating cell hasn't changed, so it must be already evaluating
|
|
state
|
|
|
|
{_, cell_id} ->
|
|
# The evaluating cell changed, so we trigger the evaluation to reflect that
|
|
trigger_evaluation(state, cell_id)
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp trigger_evaluation(state, cell_id) do
|
|
notebook = state.data.notebook
|
|
{:ok, cell, section} = Notebook.fetch_cell_and_section(notebook, cell_id)
|
|
{state, evaluator} = get_section_evaluator(state, section.id)
|
|
%{source: source} = cell
|
|
|
|
prev_ref =
|
|
case Notebook.parent_cells(notebook, cell_id) do
|
|
[parent | _] -> parent.id
|
|
[] -> :initial
|
|
end
|
|
|
|
Evaluator.evaluate_code(evaluator, self(), source, cell_id, prev_ref)
|
|
|
|
state
|
|
end
|
|
|
|
defp get_section_evaluator(state, section_id) do
|
|
case Map.fetch(state.evaluators, section_id) do
|
|
{:ok, evaluator} ->
|
|
{state, evaluator}
|
|
|
|
:error ->
|
|
{:ok, evaluator} = EvaluatorSupervisor.start_evaluator()
|
|
state = %{state | evaluators: Map.put(state.evaluators, section_id, evaluator)}
|
|
{state, evaluator}
|
|
end
|
|
end
|
|
|
|
defp delete_section_evaluator(state, section_id) do
|
|
case Map.fetch(state.evaluators, section_id) do
|
|
{:ok, evaluator} ->
|
|
EvaluatorSupervisor.terminate_evaluator(evaluator)
|
|
%{state | evaluators: Map.delete(state.evaluators, section_id)}
|
|
|
|
:error ->
|
|
state
|
|
end
|
|
end
|
|
end
|