Current section
Files
Jump to
Current section
Files
lib/core/json_store.ex
defmodule Polyn.JSONStore do
# A process for loading and accessing key value data from the NATS server.
@moduledoc false
use GenServer
alias Jetstream.API.KV
@already_in_use_code 10_058
@default_retry_timeout 5_000
@default_retry_interval 1_000
@default_retries 5
@type option ::
{:connection_name, Gnat.t()}
| {:store_name, binary()}
| {:contents, map()}
| {:retry_interval, pos_integer()}
| GenServer.option()
@doc """
Start a new Store process
## Examples
iex>Polyn.Jetstream.KeyValueStore.start_link(connection_name: :gnat)
:ok
"""
@spec start_link(opts :: [option()]) :: GenServer.on_start()
def start_link(opts) do
{store_args, server_opts} =
Keyword.split(opts, [:contents, :store_name, :connection_name, :retry_interval])
# For applications and application testing there should only be one Store running.
# For testing the library there could be multiple
process_name = Keyword.fetch!(store_args, :store_name) |> process_name()
server_opts = Keyword.put_new(server_opts, :name, process_name)
GenServer.start_link(__MODULE__, store_args, server_opts)
end
# Get a process name for a given store name. We expect there to be one server per
# store so by making the names the same we don't have to pass a `pid` around as well
@doc false
def process_name(store_name) when is_binary(store_name), do: String.to_atom(store_name)
def process_name(store_name) when is_atom(store_name), do: store_name
@doc false
@spec get_contents(pid()) :: map()
def get_contents(pid) do
GenServer.call(pid, :get_contents)
end
# Persist a value
@doc false
@spec save(pid :: pid(), key :: binary(), value :: map()) :: :ok
def save(pid, key, value) when is_map(value) do
GenServer.call(pid, {:save, key, encode(value)})
end
defp encode(schema) do
case Jason.encode(schema) do
{:ok, encoded} -> encoded
{:error, reason} -> raise Polyn.JSONStoreException, inspect(reason)
end
end
# Remove a key
@doc false
@spec delete(pid :: pid(), key :: binary()) :: :ok
def delete(pid, key) do
GenServer.call(pid, {:delete, key})
end
# Get the value for a key
@doc false
@spec get(pid :: pid(), key :: binary()) :: nil | map()
def get(pid, key) do
case GenServer.call(pid, {:get, key}) do
nil ->
nil
value ->
Jason.decode!(value)
end
end
# Create the store if it doesn't exist already.
@doc false
@spec create_store(conn :: Gnat.t(), store_name :: binary()) :: :ok
def create_store(conn, store_name) do
result = KV.create_bucket(conn, store_name)
case result do
{:ok, _info} -> :ok
# If some other client created the store first, with a slightly different
# description or config we'll just use the existing one
{:error, %{"err_code" => @already_in_use_code}} -> :ok
{:error, reason} -> raise Polyn.JSONStoreException, inspect(reason)
end
end
# Delete the store. Useful for test
@doc false
@spec delete_store(conn :: Gnat.t(), store_name :: binary()) :: :ok
def delete_store(conn, store_name) do
KV.delete_bucket(conn, store_name)
end
@impl GenServer
def init(init_args) do
store_name = Keyword.fetch!(init_args, :store_name)
conn = Keyword.fetch!(init_args, :connection_name)
preloaded_contents = Keyword.get(init_args, :contents)
retry_interval = Keyword.get(init_args, :retry_interval, @default_retry_interval)
contents = preloaded_contents || start_load_contents(conn, store_name, retry_interval)
{:ok, %{conn: conn, store_name: store_name, contents: contents}}
end
# The `Gnat.ConnectionSupervisor` doesn't block for a connection so it's possible for
# the `KeyValueStore` process to `init` without the connection being established
defp start_load_contents(conn, store_name, retry_interval, retries_left \\ @default_retries) do
task =
Task.async(fn ->
load_contents(%{
conn: conn,
store_name: store_name,
retries_left: retries_left,
retry_interval: retry_interval
})
end)
case Task.yield(task, @default_retry_timeout) do
{:ok, contents} ->
contents
nil ->
contents_load_failed(%{
conn: conn,
store_name: store_name,
failed_reason: "Connection timeout after #{@default_retry_timeout}"
})
end
end
defp load_contents(%{retries_left: 0} = args) do
contents_load_failed(args)
end
defp load_contents(%{conn: conn} = args) do
with true <- connection_alive?(conn),
{:ok, contents} <- KV.contents(conn, args.store_name) do
contents
else
{:error, reason} ->
args =
Map.put(args, :failed_reason, reason)
|> Map.put(:retries_left, args.retries_left - 1)
:timer.sleep(args.retry_interval)
load_contents(args)
end
end
defp contents_load_failed(args) do
raise Polyn.JSONStoreException,
"Could not connect to Key Value Store #{args.store_name} with connection #{inspect(args.conn)}, #{inspect(args.failed_reason)}"
end
defp connection_alive?(conn) when is_pid(conn) do
Process.alive?(conn)
end
defp connection_alive?(conn) do
case Process.whereis(conn) do
nil -> {:error, "NATS server #{inspect(conn)} not alive"}
_pid -> true
end
end
@impl GenServer
def handle_call(:get_contents, _from, state) do
{:reply, state.contents, state}
end
def handle_call({:save, key, value}, _from, state) do
contents = Map.put(state.contents, key, value)
{:reply, :ok, %{state | contents: contents}}
end
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state.contents, key), state}
end
def handle_call({:delete, key}, _from, state) do
contents = Map.delete(state.contents, key)
{:reply, :ok, %{state | contents: contents}}
end
end