Implement evaluation cancellation (#7)

* Implement evaluation cancellation

* Forger cell evaluation on deletion

* Further operation fixes

* Implement new side effects approach
This commit is contained in:
Jonatan Kłosko 2021-01-14 19:41:11 +01:00 committed by GitHub
parent 00b06f6e7a
commit 8beeb48d1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 353 additions and 139 deletions

View file

@ -96,6 +96,14 @@ defmodule LiveBook.Session do
GenServer.cast(name(session_id), {:queue_cell_evaluation, cell_id}) GenServer.cast(name(session_id), {:queue_cell_evaluation, cell_id})
end end
@doc """
Asynchronously sends cell evaluation cancellation request to the server.
"""
@spec cancel_cell_evaluation(id(), Cell.id()) :: :ok
def cancel_cell_evaluation(session_id, cell_id) do
GenServer.cast(name(session_id), {:cancel_cell_evaluation, cell_id})
end
@doc """ @doc """
Synchronously stops the server. Synchronously stops the server.
""" """
@ -137,10 +145,7 @@ defmodule LiveBook.Session do
def handle_cast({:delete_section, section_id}, state) do def handle_cast({:delete_section, section_id}, state) do
operation = {:delete_section, section_id} operation = {:delete_section, section_id}
handle_operation(state, operation)
handle_operation(state, operation, fn new_state ->
delete_section_evaluator(new_state, section_id)
end)
end end
def handle_cast({:delete_cell, cell_id}, state) do def handle_cast({:delete_cell, cell_id}, state) do
@ -150,10 +155,12 @@ defmodule LiveBook.Session do
def handle_cast({:queue_cell_evaluation, cell_id}, state) do def handle_cast({:queue_cell_evaluation, cell_id}, state) do
operation = {:queue_cell_evaluation, cell_id} operation = {:queue_cell_evaluation, cell_id}
handle_operation(state, operation)
end
handle_operation(state, operation, fn new_state -> def handle_cast({:cancel_cell_evaluation, cell_id}, state) do
maybe_trigger_evaluations(state, new_state) operation = {:cancel_cell_evaluation, cell_id}
end) handle_operation(state, operation)
end end
@impl true @impl true
@ -168,10 +175,7 @@ defmodule LiveBook.Session do
def handle_info({:evaluator_response, cell_id, response}, state) do def handle_info({:evaluator_response, cell_id, response}, state) do
operation = {:add_cell_evaluation_response, cell_id, response} operation = {:add_cell_evaluation_response, cell_id, response}
handle_operation(state, operation)
handle_operation(state, operation, fn new_state ->
maybe_trigger_evaluations(state, new_state)
end)
end end
# --- # ---
@ -181,71 +185,67 @@ defmodule LiveBook.Session do
# * broadcasts the operation to all clients immediately, # * broadcasts the operation to all clients immediately,
# so that they can update their local `Data` # so that they can update their local `Data`
# * applies the operation to own local `Data` # * applies the operation to own local `Data`
# * optionally performs a relevant task (e.g. starts cell evaluation), # * if necessary, performs the relevant tasks (e.g. starts cell evaluation),
# to reflect the new `Data` # to reflect the new `Data`
# #
defp handle_operation(state, operation) do 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) broadcast_operation(state.session_id, operation)
case Data.apply_operation(state.data, operation) do case Data.apply_operation(state.data, operation) do
{:ok, new_data} -> {:ok, new_data, actions} ->
new_state = %{state | data: new_data} new_state = %{state | data: new_data}
{:noreply, handle_new_state.(new_state)} {:noreply, handle_actions(new_state, actions)}
:error -> :error ->
{:noreply, state} {:noreply, state}
end end
end end
defp handle_actions(state, actions) do
Enum.reduce(actions, state, &handle_action(&2, &1))
end
defp handle_action(state, {:start_evaluation, cell, section}) do
trigger_evaluation(state, cell, section)
end
defp handle_action(state, {:stop_evaluation, section}) do
delete_section_evaluator(state, section.id)
end
defp handle_action(state, {:forget_evaluation, cell, section}) do
with {:ok, evaluator} <- fetch_section_evaluator(state, section.id) do
Evaluator.forget_evaluation(evaluator, cell.id)
end
state
end
defp broadcast_operation(session_id, operation) do defp broadcast_operation(session_id, operation) do
message = {:operation, operation} message = {:operation, operation}
Phoenix.PubSub.broadcast(LiveBook.PubSub, "sessions:#{session_id}", message) Phoenix.PubSub.broadcast(LiveBook.PubSub, "sessions:#{session_id}", message)
end end
# Compares sections in the old and new state and if a new cell defp trigger_evaluation(state, cell, section) do
# 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) {state, evaluator} = get_section_evaluator(state, section.id)
%{source: source} = cell
prev_ref = prev_ref =
case Notebook.parent_cells(notebook, cell_id) do case Notebook.parent_cells(state.data.notebook, cell.id) do
[parent | _] -> parent.id [parent | _] -> parent.id
[] -> :initial [] -> :initial
end end
Evaluator.evaluate_code(evaluator, self(), source, cell_id, prev_ref) Evaluator.evaluate_code(evaluator, self(), cell.source, cell.id, prev_ref)
state state
end end
defp fetch_section_evaluator(state, section_id) do
Map.fetch(state.evaluators, section_id)
end
defp get_section_evaluator(state, section_id) do defp get_section_evaluator(state, section_id) do
case Map.fetch(state.evaluators, section_id) do case fetch_section_evaluator(state, section_id) do
{:ok, evaluator} -> {:ok, evaluator} ->
{state, evaluator} {state, evaluator}
@ -257,7 +257,7 @@ defmodule LiveBook.Session do
end end
defp delete_section_evaluator(state, section_id) do defp delete_section_evaluator(state, section_id) do
case Map.fetch(state.evaluators, section_id) do case fetch_section_evaluator(state, section_id) do
{:ok, evaluator} -> {:ok, evaluator} ->
EvaluatorSupervisor.terminate_evaluator(evaluator) EvaluatorSupervisor.terminate_evaluator(evaluator)
%{state | evaluators: Map.delete(state.evaluators, section_id)} %{state | evaluators: Map.delete(state.evaluators, section_id)}

View file

@ -60,6 +60,12 @@ defmodule LiveBook.Session.Data do
| {:queue_cell_evaluation, Cell.id()} | {:queue_cell_evaluation, Cell.id()}
| {:add_cell_evaluation_stdout, Cell.id(), String.t()} | {:add_cell_evaluation_stdout, Cell.id(), String.t()}
| {:add_cell_evaluation_response, Cell.id(), Evaluator.evaluation_response()} | {:add_cell_evaluation_response, Cell.id(), Evaluator.evaluation_response()}
| {:cancel_cell_evaluation, Cell.id()}
@type action ::
{:start_evaluation, Cell.t(), Section.t()}
| {:stop_evaluation, Section.t()}
| {:forget_evaluation, Cell.t(), Section.t()}
@doc """ @doc """
Returns a fresh notebook session state. Returns a fresh notebook session state.
@ -88,21 +94,25 @@ defmodule LiveBook.Session.Data do
the system reflects the new structure. For instance, when a new cell is marked the system reflects the new structure. For instance, when a new cell is marked
as evaluating, the session process should take care of triggering actual evaluation. as evaluating, the session process should take care of triggering actual evaluation.
Returns `{:ok, data}` on correct application or `:error` if the operation Returns `{:ok, data, actions}` if the operation is valid, where `data` is the result
is not valid. The `:error` is generally expected given the collaborative of applying said operation to the given data, and `actions` is a list
nature of sessions. For example if there are simultaneous deletion of side effects that should be performed for the new data to hold true.
and evaluation operations on the same cell, we may perform delete first,
Returns `:error` if the operation is not valid. The `:error` is generally
expected given the collaborative nature of sessions. For example if there are
simultaneous deletion and evaluation operations on the same cell, we may perform delete first,
in which case the evaluation is no longer valid (there's no cell with the given id). in which case the evaluation is no longer valid (there's no cell with the given id).
By returning `:error` we simply notify the caller that no changes were applied, By returning `:error` we simply notify the caller that no changes were applied,
so any related actions can be ignored. so any related actions can be ignored.
""" """
@spec apply_operation(t(), operation()) :: {:ok, t()} | :error @spec apply_operation(t(), operation()) :: {:ok, t(), list(action())} | :error
def apply_operation(data, operation) def apply_operation(data, operation)
def apply_operation(data, {:insert_section, index, id}) do def apply_operation(data, {:insert_section, index, id}) do
section = %{Section.new() | id: id} section = %{Section.new() | id: id}
data data
|> with_actions()
|> insert_section(index, section) |> insert_section(index, section)
|> wrap_ok() |> wrap_ok()
end end
@ -112,34 +122,44 @@ defmodule LiveBook.Session.Data do
cell = %{Cell.new(type) | id: id} cell = %{Cell.new(type) | id: id}
data data
|> with_actions()
|> insert_cell(section_id, index, cell) |> insert_cell(section_id, index, cell)
|> wrap_ok() |> wrap_ok()
end end
end end
def apply_operation(data, {:delete_section, id}) do def apply_operation(data, {:delete_section, id}) do
with {:ok, section} <- Notebook.fetch_section(data.notebook, id), with {:ok, section} <- Notebook.fetch_section(data.notebook, id) do
# If a cell within this section is being evaluated, it should be cancelled first
nil <- data.section_infos[section.id].evaluating_cell_id do
data data
|> with_actions()
|> delete_section(section) |> delete_section(section)
|> wrap_ok() |> wrap_ok()
else
_ -> :error
end end
end end
def apply_operation(data, {:delete_cell, id}) do def apply_operation(data, {:delete_cell, id}) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id), with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id) do
# If the cell is being evaluated, it should be cancelled first case data.cell_infos[cell.id].status do
false <- data.cell_infos[cell.id].status == :evaluating do :evaluating ->
data data
|> unqueue_cell_evaluation_if_any(cell, section) |> with_actions()
|> mark_dependent_cells_as_stale(cell) |> clear_section_evaluation(section)
:queued ->
data
|> with_actions()
|> unqueue_cell_evaluation(cell, section)
|> unqueue_dependent_cells_evaluation(cell, section)
|> mark_dependent_cells_as_stale(cell)
_ ->
data
|> with_actions()
|> mark_dependent_cells_as_stale(cell)
end
|> delete_cell(cell) |> delete_cell(cell)
|> add_action({:forget_evaluation, cell, section})
|> wrap_ok() |> wrap_ok()
else
_ -> :error
end end
end end
@ -147,10 +167,9 @@ defmodule LiveBook.Session.Data do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id), with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id),
:elixir <- cell.type, :elixir <- cell.type,
false <- data.cell_infos[cell.id].status in [:queued, :evaluating] do false <- data.cell_infos[cell.id].status in [:queued, :evaluating] do
prerequisites_queue = fresh_parent_cells_queue(data, cell)
data data
|> reduce(prerequisites_queue, &queue_cell_evaluation(&1, &2, section)) |> with_actions()
|> queue_prerequisite_cells_evaluation(cell, section)
|> queue_cell_evaluation(cell, section) |> queue_cell_evaluation(cell, section)
|> maybe_evaluate_queued() |> maybe_evaluate_queued()
|> wrap_ok() |> wrap_ok()
@ -162,6 +181,7 @@ defmodule LiveBook.Session.Data do
def apply_operation(data, {:add_cell_evaluation_stdout, id, string}) do def apply_operation(data, {:add_cell_evaluation_stdout, id, string}) do
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, id) do with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, id) do
data data
|> with_actions()
|> add_cell_evaluation_stdout(cell, string) |> add_cell_evaluation_stdout(cell, string)
|> wrap_ok() |> wrap_ok()
end end
@ -170,6 +190,7 @@ defmodule LiveBook.Session.Data do
def apply_operation(data, {:add_cell_evaluation_response, id, response}) do def apply_operation(data, {:add_cell_evaluation_response, id, response}) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id) do with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id) do
data data
|> with_actions()
|> add_cell_evaluation_response(cell, response) |> add_cell_evaluation_response(cell, response)
|> finish_cell_evaluation(cell, section) |> finish_cell_evaluation(cell, section)
|> mark_dependent_cells_as_stale(cell) |> mark_dependent_cells_as_stale(cell)
@ -178,26 +199,53 @@ defmodule LiveBook.Session.Data do
end end
end end
def apply_operation(data, {:cancel_cell_evaluation, id}) do
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(data.notebook, id) do
case data.cell_infos[cell.id].status do
:evaluating ->
data
|> with_actions()
|> clear_section_evaluation(section)
|> wrap_ok()
:queued ->
data
|> with_actions()
|> unqueue_cell_evaluation(cell, section)
|> unqueue_dependent_cells_evaluation(cell, section)
|> mark_dependent_cells_as_stale(cell)
|> wrap_ok()
_ ->
:error
end
end
end
# === # ===
defp insert_section(data, index, section) do defp with_actions(data, actions \\ []), do: {data, actions}
data
defp wrap_ok({data, actions}), do: {:ok, data, actions}
defp insert_section({data, _} = data_actions, index, section) do
data_actions
|> set!( |> set!(
notebook: Notebook.insert_section(data.notebook, index, section), notebook: Notebook.insert_section(data.notebook, index, section),
section_infos: Map.put(data.section_infos, section.id, new_section_info()) section_infos: Map.put(data.section_infos, section.id, new_section_info())
) )
end end
defp insert_cell(data, section_id, index, cell) do defp insert_cell({data, _} = data_actions, section_id, index, cell) do
data data_actions
|> set!( |> set!(
notebook: Notebook.insert_cell(data.notebook, section_id, index, cell), notebook: Notebook.insert_cell(data.notebook, section_id, index, cell),
cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info()) cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info())
) )
end end
defp delete_section(data, section) do defp delete_section({data, _} = data_actions, section) do
data data_actions
|> set!( |> set!(
notebook: Notebook.delete_section(data.notebook, section.id), notebook: Notebook.delete_section(data.notebook, section.id),
section_infos: Map.delete(data.section_infos, section.id), section_infos: Map.delete(data.section_infos, section.id),
@ -206,8 +254,8 @@ defmodule LiveBook.Session.Data do
|> reduce(section.cells, &delete_cell_info/2) |> reduce(section.cells, &delete_cell_info/2)
end end
defp delete_cell(data, cell) do defp delete_cell({data, _} = data_actions, cell) do
data data_actions
|> set!( |> set!(
notebook: Notebook.delete_cell(data.notebook, cell.id), notebook: Notebook.delete_cell(data.notebook, cell.id),
deleted_cells: [cell | data.deleted_cells] deleted_cells: [cell | data.deleted_cells]
@ -215,88 +263,115 @@ defmodule LiveBook.Session.Data do
|> delete_cell_info(cell) |> delete_cell_info(cell)
end end
defp delete_cell_info(data, cell) do defp delete_cell_info({data, _} = data_actions, cell) do
data data_actions
|> set!(cell_infos: Map.delete(data.cell_infos, cell.id)) |> set!(cell_infos: Map.delete(data.cell_infos, cell.id))
end end
defp queue_cell_evaluation(data, cell, section) do defp queue_cell_evaluation(data_actions, cell, section) do
data data_actions
|> update_section_info!(section.id, fn section -> |> update_section_info!(section.id, fn section ->
%{section | evaluation_queue: section.evaluation_queue ++ [cell.id]} %{section | evaluation_queue: section.evaluation_queue ++ [cell.id]}
end) end)
|> set_cell_info!(cell.id, status: :queued) |> set_cell_info!(cell.id, status: :queued)
end end
defp unqueue_cell_evaluation_if_any(data, cell, section) do defp unqueue_cell_evaluation(data_actions, cell, section) do
data data_actions
|> update_section_info!(section.id, fn section -> |> update_section_info!(section.id, fn section ->
%{section | evaluation_queue: List.delete(section.evaluation_queue, cell.id)} %{section | evaluation_queue: List.delete(section.evaluation_queue, cell.id)}
end) end)
|> set_cell_info!(cell.id, status: :stale) |> set_cell_info!(cell.id, status: :stale)
end end
defp add_cell_evaluation_stdout(data, _cell, _string) do defp add_cell_evaluation_stdout({data, _} = data_actions, _cell, _string) do
data data_actions
|> set!( |> set!(
# TODO: add stdout to cell outputs # TODO: add stdout to cell outputs
notebook: data.notebook notebook: data.notebook
) )
end end
defp add_cell_evaluation_response(data, _cell, _response) do defp add_cell_evaluation_response({data, _} = data_actions, _cell, _response) do
data data_actions
|> set!( |> set!(
# TODO: add result to outputs # TODO: add result to outputs
notebook: data.notebook notebook: data.notebook
) )
end end
defp finish_cell_evaluation(data, cell, section) do defp finish_cell_evaluation(data_actions, cell, section) do
data data_actions
|> set_cell_info!(cell.id, status: :evaluated, evaluated_at: DateTime.utc_now()) |> set_cell_info!(cell.id, status: :evaluated, evaluated_at: DateTime.utc_now())
|> set_section_info!(section.id, evaluating_cell_id: nil) |> set_section_info!(section.id, evaluating_cell_id: nil)
end end
defp mark_dependent_cells_as_stale(data, cell) do defp mark_dependent_cells_as_stale({data, _} = data_actions, cell) do
invalidated_cells = evaluated_child_cells(data, cell) invalidated_cells = child_cells_with_status(data, cell, :evaluated)
data data_actions
|> reduce(invalidated_cells, &set_cell_info!(&1, &2.id, status: :stale)) |> reduce(invalidated_cells, &set_cell_info!(&1, &2.id, status: :stale))
end end
defp fresh_parent_cells_queue(data, cell) do defp fresh_parent_cells_queue(data, cell) do
data.notebook data.notebook
|> Notebook.parent_cells(cell.id) |> Notebook.parent_cells(cell.id)
|> Enum.filter(fn parent -> data.cell_infos[parent.id].status == :fresh end) |> Enum.take_while(fn parent -> data.cell_infos[parent.id].status == :fresh end)
|> Enum.reverse() |> Enum.reverse()
end end
defp evaluated_child_cells(data, cell) do defp child_cells_with_status(data, cell, status) do
data.notebook data.notebook
|> Notebook.child_cells(cell.id) |> Notebook.child_cells(cell.id)
# Mark only evaluted cells as stale |> Enum.filter(fn cell -> data.cell_infos[cell.id].status == status end)
|> Enum.filter(fn cell -> data.cell_infos[cell.id].status == :evaluated end)
end end
# If there are idle sections with non-empty evaluation queue, # If there are idle sections with non-empty evaluation queue,
# the next queued cell for evaluation. # the next queued cell for evaluation.
defp maybe_evaluate_queued(data) do defp maybe_evaluate_queued({data, _} = data_actions) do
Enum.reduce(data.notebook.sections, data, fn section, data -> Enum.reduce(data.notebook.sections, data_actions, fn section, data_actions ->
{data, _} = data_actions
case data.section_infos[section.id] do case data.section_infos[section.id] do
%{evaluating_cell_id: nil, evaluation_queue: [id | ids]} -> %{evaluating_cell_id: nil, evaluation_queue: [id | ids]} ->
data cell = Enum.find(section.cells, &(&1.id == id))
data_actions
|> set!(notebook: Notebook.update_cell(data.notebook, id, &%{&1 | outputs: []})) |> set!(notebook: Notebook.update_cell(data.notebook, id, &%{&1 | outputs: []}))
|> set_cell_info!(id, status: :evaluating) |> set_cell_info!(id, status: :evaluating)
|> set_section_info!(section.id, evaluating_cell_id: id, evaluation_queue: ids) |> set_section_info!(section.id, evaluating_cell_id: id, evaluation_queue: ids)
|> add_action({:start_evaluation, cell, section})
_ -> _ ->
data data_actions
end end
end) end)
end end
defp wrap_ok(value), do: {:ok, value} defp clear_section_evaluation(data_actions, section) do
data_actions
|> set_section_info!(section.id, evaluating_cell_id: nil, evaluation_queue: [])
|> reduce(section.cells, &set_cell_info!(&1, &2.id, status: :fresh))
|> add_action({:stop_evaluation, section})
end
defp queue_prerequisite_cells_evaluation({data, _} = data_actions, cell, section) do
prerequisites_queue = fresh_parent_cells_queue(data, cell)
data_actions
|> reduce(prerequisites_queue, &queue_cell_evaluation(&1, &2, section))
end
defp unqueue_dependent_cells_evaluation({data, _} = data_actions, cell, section) do
queued_dependent_cells = child_cells_with_status(data, cell, :queued)
data_actions
|> reduce(queued_dependent_cells, &unqueue_cell_evaluation(&1, &2, section))
end
defp add_action({data, actions}, action) do
{data, actions ++ [action]}
end
defp new_section_info() do defp new_section_info() do
%{ %{
@ -314,40 +389,41 @@ defmodule LiveBook.Session.Data do
} }
end end
defp set!(data, changes) do defp set!({data, actions}, changes) do
Enum.reduce(changes, data, fn {key, value}, info -> Enum.reduce(changes, data, fn {key, value}, info ->
Map.replace!(info, key, value) Map.replace!(info, key, value)
end) end)
|> with_actions(actions)
end end
defp set_cell_info!(data, cell_id, changes) do defp set_cell_info!(data_actions, cell_id, changes) do
update_cell_info!(data, cell_id, fn info -> update_cell_info!(data_actions, cell_id, fn info ->
Enum.reduce(changes, info, fn {key, value}, info -> Enum.reduce(changes, info, fn {key, value}, info ->
Map.replace!(info, key, value) Map.replace!(info, key, value)
end) end)
end) end)
end end
defp update_cell_info!(data, cell_id, fun) do defp update_cell_info!({data, _} = data_actions, cell_id, fun) do
cell_infos = Map.update!(data.cell_infos, cell_id, fun) cell_infos = Map.update!(data.cell_infos, cell_id, fun)
set!(data, cell_infos: cell_infos) set!(data_actions, cell_infos: cell_infos)
end end
defp set_section_info!(data, section_id, changes) do defp set_section_info!(data_actions, section_id, changes) do
update_section_info!(data, section_id, fn info -> update_section_info!(data_actions, section_id, fn info ->
Enum.reduce(changes, info, fn {key, value}, info -> Enum.reduce(changes, info, fn {key, value}, info ->
Map.replace!(info, key, value) Map.replace!(info, key, value)
end) end)
end) end)
end end
defp update_section_info!(data, section_id, fun) do defp update_section_info!({data, _} = data_actions, section_id, fun) do
section_infos = Map.update!(data.section_infos, section_id, fun) section_infos = Map.update!(data.section_infos, section_id, fun)
set!(data, section_infos: section_infos) set!(data_actions, section_infos: section_infos)
end end
defp reduce(data, list, reducer) do defp reduce(data_actions, list, reducer) do
Enum.reduce(list, data, fn elem, data -> reducer.(data, elem) end) Enum.reduce(list, data_actions, fn elem, data_actions -> reducer.(data_actions, elem) end)
end end
@doc """ @doc """

View file

@ -15,7 +15,7 @@ defmodule LiveBook.Session.DataTest do
sections: [%{id: "s1"}] sections: [%{id: "s1"}]
}, },
section_infos: %{"s1" => _} section_infos: %{"s1" => _}
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end end
end end
@ -42,7 +42,7 @@ defmodule LiveBook.Session.DataTest do
] ]
}, },
cell_infos: %{"c1" => _} cell_infos: %{"c1" => _}
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end end
end end
@ -53,18 +53,6 @@ defmodule LiveBook.Session.DataTest do
assert :error = Data.apply_operation(data, operation) assert :error = Data.apply_operation(data, operation)
end end
test "returns an error for an evaluating section" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"}
])
operation = {:delete_section, "s1"}
assert :error = Data.apply_operation(data, operation)
end
test "removes the section from notebook and session info, adds to deleted sections" do test "removes the section from notebook and session info, adds to deleted sections" do
data = data =
data_after_operations!([ data_after_operations!([
@ -81,7 +69,7 @@ defmodule LiveBook.Session.DataTest do
}, },
section_infos: ^empty_map, section_infos: ^empty_map,
deleted_sections: [%{id: "s1"}] deleted_sections: [%{id: "s1"}]
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end end
end end
@ -92,16 +80,23 @@ defmodule LiveBook.Session.DataTest do
assert :error = Data.apply_operation(data, operation) assert :error = Data.apply_operation(data, operation)
end end
test "returns an error for an evaluating cell" do test "if the cell is evaluating, cencels section evaluation" do
data = data =
data_after_operations!([ data_after_operations!([
{:insert_section, 0, "s1"}, {:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"}, {:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"} {:insert_cell, "s1", 1, :elixir, "c2"},
{:queue_cell_evaluation, "c1"},
{:queue_cell_evaluation, "c2"}
]) ])
operation = {:delete_cell, "c1"} operation = {:delete_cell, "c1"}
assert :error = Data.apply_operation(data, operation)
assert {:ok,
%{
cell_infos: %{"c2" => %{status: :fresh}},
section_infos: %{"s1" => %{evaluating_cell_id: nil, evaluation_queue: []}}
}, _actions} = Data.apply_operation(data, operation)
end end
test "removes the cell from notebook and session info, adds to deleted cells" do test "removes the cell from notebook and session info, adds to deleted cells" do
@ -121,7 +116,7 @@ defmodule LiveBook.Session.DataTest do
}, },
cell_infos: ^empty_map, cell_infos: ^empty_map,
deleted_cells: [%{id: "c1"}] deleted_cells: [%{id: "c1"}]
}} = Data.apply_operation(data, operation) }, _actions} = Data.apply_operation(data, operation)
end end
test "unqueues the cell if it's queued for evaluation" do test "unqueues the cell if it's queued for evaluation" do
@ -139,7 +134,7 @@ defmodule LiveBook.Session.DataTest do
assert {:ok, assert {:ok,
%{ %{
section_infos: %{"s1" => %{evaluation_queue: []}} section_infos: %{"s1" => %{evaluation_queue: []}}
}} = Data.apply_operation(data, operation) }, _actions} = Data.apply_operation(data, operation)
end end
test "marks evaluated child cells as stale" do test "marks evaluated child cells as stale" do
@ -160,7 +155,20 @@ defmodule LiveBook.Session.DataTest do
assert {:ok, assert {:ok,
%{ %{
cell_infos: %{"c2" => %{status: :stale}} cell_infos: %{"c2" => %{status: :stale}}
}} = Data.apply_operation(data, operation) }, _actions} = Data.apply_operation(data, operation)
end
test "returns forget evaluation action" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"}
])
operation = {:delete_cell, "c1"}
assert {:ok, _data, [{:forget_evaluation, %{id: "c1"}, %{id: "s1"}}]} =
Data.apply_operation(data, operation)
end end
end end
@ -207,7 +215,20 @@ defmodule LiveBook.Session.DataTest do
%{ %{
cell_infos: %{"c1" => %{status: :evaluating}}, cell_infos: %{"c1" => %{status: :evaluating}},
section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: []}} section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: []}}
}} = Data.apply_operation(data, operation) }, _actions} = Data.apply_operation(data, operation)
end
test "returns start evaluation action if the corresponding section is idle" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"}
])
operation = {:queue_cell_evaluation, "c1"}
assert {:ok, _data, [{:start_evaluation, %{id: "c1"}, %{id: "s1"}}]} =
Data.apply_operation(data, operation)
end end
test "marks the cell as queued if the corresponding section is already evaluating" do test "marks the cell as queued if the corresponding section is already evaluating" do
@ -225,7 +246,7 @@ defmodule LiveBook.Session.DataTest do
%{ %{
cell_infos: %{"c2" => %{status: :queued}}, cell_infos: %{"c2" => %{status: :queued}},
section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: ["c2"]}} section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: ["c2"]}}
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end end
end end
@ -255,7 +276,7 @@ defmodule LiveBook.Session.DataTest do
%{ %{
cell_infos: %{"c1" => %{status: :evaluated}}, cell_infos: %{"c1" => %{status: :evaluated}},
section_infos: %{"s1" => %{evaluating_cell_id: nil, evaluation_queue: []}} section_infos: %{"s1" => %{evaluating_cell_id: nil, evaluation_queue: []}}
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end end
test "marks next queued cell in this section as evaluating if there is one" do test "marks next queued cell in this section as evaluating if there is one" do
@ -274,7 +295,9 @@ defmodule LiveBook.Session.DataTest do
%{ %{
cell_infos: %{"c2" => %{status: :evaluating}}, cell_infos: %{"c2" => %{status: :evaluating}},
section_infos: %{"s1" => %{evaluating_cell_id: "c2", evaluation_queue: []}} section_infos: %{"s1" => %{evaluating_cell_id: "c2", evaluation_queue: []}}
}} = Data.apply_operation(data, operation) },
[{:start_evaluation, %{id: "c2"}, %{id: "s1"}}]} =
Data.apply_operation(data, operation)
end end
test "if parent cells are not executed, marks them for evaluation first" do test "if parent cells are not executed, marks them for evaluation first" do
@ -294,7 +317,9 @@ defmodule LiveBook.Session.DataTest do
"c2" => %{status: :queued} "c2" => %{status: :queued}
}, },
section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: ["c2"]}} section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: ["c2"]}}
}} = Data.apply_operation(data, operation) },
[{:start_evaluation, %{id: "c1"}, %{id: "s1"}}]} =
Data.apply_operation(data, operation)
end end
test "marks evaluated child cells as stale" do test "marks evaluated child cells as stale" do
@ -317,14 +342,110 @@ defmodule LiveBook.Session.DataTest do
assert {:ok, assert {:ok,
%{ %{
cell_infos: %{"c2" => %{status: :stale}} cell_infos: %{"c2" => %{status: :stale}}
}} = Data.apply_operation(data, operation) }, []} = Data.apply_operation(data, operation)
end
end
describe "apply_operation/2 given :cancel_cell_evaluation" do
test "returns an error given invalid cell id" do
data = Data.new()
operation = {:cancel_cell_evaluation, "nonexistent"}
assert :error = Data.apply_operation(data, operation)
end
test "returns an error for an evaluated cell" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:queue_cell_evaluation, "c1"},
{:add_cell_evaluation_response, "c1", {:ok, [1, 2, 3]}}
])
operation = {:cancel_cell_evaluation, "c1"}
assert :error = Data.apply_operation(data, operation)
end
test "if the cell is evaluating, clears the corresponding section evaluation and the queue" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:insert_cell, "s1", 1, :elixir, "c2"},
{:queue_cell_evaluation, "c1"},
{:queue_cell_evaluation, "c2"}
])
operation = {:cancel_cell_evaluation, "c1"}
assert {:ok,
%{
cell_infos: %{"c1" => %{status: :fresh}, "c2" => %{status: :fresh}},
section_infos: %{"s1" => %{evaluating_cell_id: nil, evaluation_queue: []}}
}, _actions} = Data.apply_operation(data, operation)
end
test "if the cell is evaluating, returns stop evaluation action" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:insert_cell, "s1", 1, :elixir, "c2"},
{:queue_cell_evaluation, "c1"},
{:queue_cell_evaluation, "c2"}
])
operation = {:cancel_cell_evaluation, "c1"}
assert {:ok, _data, [{:stop_evaluation, %{id: "s1"}}]} =
Data.apply_operation(data, operation)
end
test "if the cell is queued, unqueues it" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:insert_cell, "s1", 1, :elixir, "c2"},
{:queue_cell_evaluation, "c1"},
{:queue_cell_evaluation, "c2"}
])
operation = {:cancel_cell_evaluation, "c2"}
assert {:ok,
%{
cell_infos: %{"c2" => %{status: :stale}},
section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: []}}
}, []} = Data.apply_operation(data, operation)
end
test "if the cell is queued, unqueues dependent cells that are also queued" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:insert_cell, "s1", 1, :elixir, "c2"},
{:insert_cell, "s1", 2, :elixir, "c3"},
{:queue_cell_evaluation, "c1"},
{:queue_cell_evaluation, "c2"},
{:queue_cell_evaluation, "c3"}
])
operation = {:cancel_cell_evaluation, "c2"}
assert {:ok,
%{
cell_infos: %{"c3" => %{status: :stale}},
section_infos: %{"s1" => %{evaluating_cell_id: "c1", evaluation_queue: []}}
}, []} = Data.apply_operation(data, operation)
end end
end end
defp data_after_operations!(operations) do defp data_after_operations!(operations) do
Enum.reduce(operations, Data.new(), fn operation, data -> Enum.reduce(operations, Data.new(), fn operation, data ->
case Data.apply_operation(data, operation) do case Data.apply_operation(data, operation) do
{:ok, data} -> {:ok, data, _action} ->
data data
:error -> :error ->

View file

@ -72,6 +72,18 @@ defmodule LiveBook.SessionTest do
end end
end end
describe "cancel_cell_evaluation/2" do
test "sends a cancel evaluation operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
{_section_id, cell_id} = insert_section_and_cell(session_id)
queue_evaluation(session_id, cell_id)
Session.cancel_cell_evaluation(session_id, cell_id)
assert_receive {:operation, {:cancel_cell_evaluation, ^cell_id}}
end
end
defp insert_section_and_cell(session_id) do defp insert_section_and_cell(session_id) do
Session.insert_section(session_id, 0) Session.insert_section(session_id, 0)
assert_receive {:operation, {:insert_section, 0, section_id}} assert_receive {:operation, {:insert_section, 0, section_id}}
@ -80,4 +92,9 @@ defmodule LiveBook.SessionTest do
{section_id, cell_id} {section_id, cell_id}
end end
defp queue_evaluation(session_id, cell_id) do
Session.queue_cell_evaluation(session_id, cell_id)
assert_receive {:operation, {:add_cell_evaluation_response, ^cell_id, _}}
end
end end