Packages
livebook
0.11.1
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook/storage.ex
defmodule Livebook.Storage do
# Storage for arbitrary data in [Entity-Attribute-Value](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model)
# fashion.
#
# The storage uses an ETS table and synchronizes all the changes to
# a file, so they are restored when the application is started again.
#
# `insert` and `delete` operations are supposed to be called using a GenServer
# while all the lookups can be performed by directly accessing the named table.
use GenServer
require Logger
@type namespace :: atom()
@type entity_id :: binary()
@type attribute :: atom()
@type value :: term()
@type timestamp :: non_neg_integer()
@type entity :: %{required(:id) => entity_id(), optional(attribute()) => value()}
defmodule NotFoundError do
defexception [:id, :namespace, plug_status: 404]
def message(%{namespace: namespace, id: id}) do
"could not find entry in \"#{namespace}\" with ID #{inspect(id)}"
end
end
@doc """
Starts the storage process.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns all values in namespace.
## Examples
Livebook.Storage.all(:filesystem)
#=> [%{id: "rand-id", type: "s3", bucket_url: "/...", secret: "abc", access_key: "xyz"}]
"""
@spec all(namespace()) :: [entity()]
def all(namespace) do
table_name()
|> :ets.match({{namespace, :"$1"}, :"$2", :"$3", :_})
|> Enum.group_by(
fn [entity_id, _attr, _val] -> entity_id end,
fn [_id, attr, val] -> {attr, val} end
)
|> Enum.map(fn {entity_id, attributes} ->
attributes
|> Map.new()
|> Map.put(:id, entity_id)
end)
end
@doc """
Returns a map identified by `entity_id` in `namespace`.
## Examples
Livebook.Storage.fetch(:filesystem, "rand-id")
#=> {:ok, %{id: "rand-id", type: "s3", bucket_url: "/...", secret: "abc", access_key: "xyz"}}
"""
@spec fetch(namespace(), entity_id()) :: {:ok, entity()} | :error
def fetch(namespace, entity_id) do
table_name()
|> :ets.lookup({namespace, entity_id})
|> case do
[] ->
:error
entries ->
entries
|> Enum.map(fn {_key, attr, val, _timestamp} -> {attr, val} end)
|> Map.new()
|> Map.put(:id, entity_id)
|> then(&{:ok, &1})
end
end
@doc """
Raising delegate for `c:fetch/2`.
"""
@spec fetch!(namespace(), entity_id()) :: entity()
def fetch!(namespace, id) do
case fetch(namespace, id) do
{:ok, entity} -> entity
:error -> raise NotFoundError, namespace: namespace, id: id
end
end
@doc """
Returns the value for a given `namespace`-`entity_id`-`attribute`.
## Examples
Livebook.Storage.fetch_key(:filesystem, "rand-id", :type)
#=> {:ok, "s3"}
"""
@spec fetch_key(namespace(), entity_id(), attribute()) :: {:ok, value()} | :error
def fetch_key(namespace, entity_id, key) do
table_name()
|> :ets.match({{namespace, entity_id}, key, :"$1", :_})
|> case do
[[value]] -> {:ok, value}
[] -> :error
end
end
@doc """
Inserts given list of attribute-value pairs to a entity belonging to
specified namespace.
"""
@spec insert(namespace(), entity_id(), [{attribute(), value()}]) :: :ok
def insert(namespace, entity_id, attributes) do
GenServer.call(__MODULE__, {:insert, namespace, entity_id, attributes})
end
@doc """
Deletes an entity of given id from given namespace.
"""
@spec delete(namespace(), entity_id()) :: :ok
def delete(namespace, entity_id) do
GenServer.call(__MODULE__, {:delete, namespace, entity_id})
end
@doc """
Deletes an attribute from given entity.
"""
@spec delete_key(namespace(), entity_id(), attribute()) :: :ok
def delete_key(namespace, entity_id, key) do
GenServer.call(__MODULE__, {:delete_key, namespace, entity_id, key})
end
@doc """
Returns file path where the data is persisted.
"""
@spec config_file_path() :: Path.t()
def config_file_path() do
Path.join([Livebook.Config.data_path(), "livebook_config.ets"])
end
@impl true
def init(_opts) do
# Make sure that this process does not terminate abruptly
# in case it is persisting to disk. terminate/2 is still a no-op.
Process.flag(:trap_exit, true)
table = load_or_create_table()
:persistent_term.put(__MODULE__, table)
{:ok, %{table: table}}
end
@impl true
def handle_call({:insert, namespace, entity_id, attributes}, _from, %{table: table} = state) do
keys_to_delete = Enum.map(attributes, fn {key, _val} -> key end)
delete_keys(table, namespace, entity_id, keys_to_delete)
timestamp = System.os_time(:millisecond)
attributes =
Enum.map(attributes, fn {attr, val} ->
{{namespace, entity_id}, attr, val, timestamp}
end)
:ets.insert(table, attributes)
{:reply, :ok, state, {:continue, :save_to_file}}
end
def handle_call({:delete, namespace, entity_id}, _from, %{table: table} = state) do
:ets.delete(table, {namespace, entity_id})
{:reply, :ok, state, {:continue, :save_to_file}}
end
def handle_call({:delete_key, namespace, entity_id, key}, _from, %{table: table} = state) do
delete_keys(table, namespace, entity_id, [key])
{:reply, :ok, state, {:continue, :save_to_file}}
end
@impl true
def handle_continue(:save_to_file, %{table: table} = state) do
file_path = String.to_charlist(config_file_path())
:ok = :ets.tab2file(table, file_path)
{:noreply, state}
end
defp table_name(), do: :persistent_term.get(__MODULE__)
defp load_or_create_table() do
path = config_file_path()
path
|> String.to_charlist()
|> :ets.file2tab()
|> case do
{:ok, tab} ->
Logger.info("Reading storage from #{path}")
tab
{:error, reason} ->
case reason do
{:read_error, {:file_error, _, :enoent}} -> :ok
_ -> Logger.warning("Could not open up #{config_file_path()}: #{inspect(reason)}")
end
:ets.new(__MODULE__, [:protected, :duplicate_bag])
end
end
defp delete_keys(table, namespace, entity_id, keys) do
match_head = {{namespace, entity_id}, :"$1", :_, :_}
guard =
keys
|> Enum.map(&{:==, :"$1", &1})
|> Enum.reduce(&{:orelse, &1, &2})
:ets.select_delete(table, [{match_head, [guard], [true]}])
end
end