Current section
Files
Jump to
Current section
Files
lib/bolt_sips/router.ex
defmodule Bolt.Sips.Router do
@moduledoc """
This "driver" works in tandem with Neo4j's [Causal Clustering](https://neo4j.com/docs/operations-manual/current/clustering/>) feature by directing read and write behaviour to appropriate cluster members
"""
use GenServer
require Logger
alias Bolt.Sips.Routing.RoutingTable
alias Bolt.Sips.{Protocol, ConnectionSupervisor, LoadBalancer, Response, Error}
defmodule State do
@moduledoc """
todo:
this is work in progress and will be used for defining the state of the Router (Gen)Server
"""
@type role :: atom
@type t :: %__MODULE__{
connections: %{
role => %{String.t() => non_neg_integer},
updated_at: non_neg_integer,
ttl: non_neg_integer
}
}
@enforce_keys [:connections]
defstruct @enforce_keys
end
@no_routing nil
@routing_table_keys [:read, :write, :route, :updated_at, :ttl, :error]
def configure(opts), do: GenServer.call(__MODULE__, {:configure, opts})
def get_connection(role, prefix \\ :direct)
def get_connection(role, prefix),
do: GenServer.call(__MODULE__, {:get_connection, role, prefix})
def terminate_connections(role, prefix \\ :default)
def terminate_connections(role, prefix),
do: GenServer.call(__MODULE__, {:terminate_connections, role, prefix})
def info(), do: GenServer.call(__MODULE__, :info)
def routing_table(prefix), do: GenServer.call(__MODULE__, {:routing_table_info, prefix})
@spec start_link(Keyword.t()) :: :ignore | {:error, Keyword.t()} | {:ok, pid()}
def start_link(init_args) do
GenServer.start_link(__MODULE__, init_args, name: __MODULE__)
end
@impl true
@spec init(Keyword.t()) :: {:ok, State.t(), {:continue, :post_init}}
def init(options) do
{:ok, options, {:continue, :post_init}}
end
@impl true
def handle_call({:configure, opts}, _from, state) do
prefix = Keyword.get(opts, :prefix, :default)
%{connections: current_connections} = Map.get(state, prefix, %{connections: %{}})
%{user_options: user_options, connections: connections} =
try do
opts
|> _configure()
|> Map.get(prefix)
rescue
e in Bolt.Sips.Exception ->
%{user_options: opts, connections: %{error: e.message}}
end
updated_connections = Map.merge(current_connections, connections)
new_state =
Map.put(state, prefix, %{user_options: user_options, connections: updated_connections})
{:reply, new_state, new_state}
end
# getting connections for role in [:route, :read, :write]
@impl true
def handle_call({:get_connection, role, prefix}, _from, state)
when role in [:route, :read, :write] do
with %{connections: connections} <- Map.get(state, prefix),
{:ok, conn, updated_connections} <- _get_connection(role, connections, prefix) do
{:reply, {:ok, conn}, put_in(state, [prefix, :connections], updated_connections)}
else
e ->
err_msg = error_no_connection_available_for_role(role, e, prefix)
{:reply, {:error, err_msg}, state}
end
end
# getting connections for any user defined roles, or: `:direct`
@impl true
def handle_call({:get_connection, role, prefix}, _from, state) do
with %{connections: connections} <- Map.get(state, prefix),
true <- Map.has_key?(connections, role),
[url | _none] <- connections |> Map.get(role) |> Map.keys(),
{:ok, pid} <- ConnectionSupervisor.find_connection(role, url, prefix) do
{:reply, {:ok, pid}, state}
else
e ->
err_msg = error_no_connection_available_for_role(role, e, prefix)
{:reply, {:error, err_msg}, state}
end
end
@impl true
def handle_call({:terminate_connections, role, prefix}, _from, state) do
%{connections: connections} = Map.get(state, prefix, %{})
with true <- Map.has_key?(connections, role),
:ok <-
connections
|> Map.get(role)
|> Map.keys()
|> Enum.each(&ConnectionSupervisor.terminate_connection(role, &1, prefix)) do
new_connections = Map.delete(connections, role)
new_state = put_in(state, [prefix, :connections], new_connections)
{:reply, :ok, new_state}
else
_e ->
{:reply, {:error, :not_found}, state}
end
end
@impl true
def handle_call(:info, _from, state), do: {:reply, state, state}
def handle_call({:routing_table_info, prefix}, _from, state) do
routing_table =
with connections when not is_nil(connections) <- get_in(state, [prefix, :connections]) do
Map.take(connections, @routing_table_keys)
end
{:reply, routing_table, state}
end
@impl true
@spec handle_continue(:post_init, Keyword.t()) :: {:noreply, map}
def handle_continue(:post_init, opts), do: {:noreply, _configure(opts)}
defp _configure(opts) do
options = Bolt.Sips.Utils.default_config(opts)
prefix = Keyword.get(options, :prefix, :default)
ssl_or_sock = if(Keyword.get(options, :ssl), do: :ssl, else: Keyword.get(options, :socket))
user_options = Keyword.put(options, :socket, ssl_or_sock)
with_routing? = Keyword.get(user_options, :schema, "bolt") =~ ~r/(^bolt\+routing$)/i
with {:ok, routing_table} <- get_routing_table(user_options, with_routing?),
{:ok, connections} <- start_connections(user_options, routing_table) do
connections = Map.put(connections, :routing_query, routing_table[:routing_query])
%{prefix => %{user_options: user_options, connections: connections}}
else
{:error, msg} ->
Logger.error("cannot load the routing table. Error: #{msg}")
%{prefix => %{user_options: user_options, connections: %{error: "Not a router"}}}
end
end
defp get_routing_table(
%{routing_query: %{params: props, query: query}} = connections,
_,
prefix
) do
with {:ok, conn, updated_connections} <- _get_connection(:route, connections, prefix),
{:ok, %Response{} = results} <- Bolt.Sips.query(conn, query, props) do
{:ok, Response.first(results), updated_connections}
else
{:error, %Error{code: code, message: message}} ->
err_msg = "#{code}; #{message}"
Logger.error(err_msg)
{:error, err_msg}
{:error, msg, _updated_connections} ->
Logger.error(msg)
{:error, :routing_table_not_available}
e ->
Logger.error("get_routing_table error: #{inspect(e)}")
{:error, :routing_table_not_available_at_all}
end
end
defp get_routing_table(_opts, false), do: {:ok, @no_routing}
defp get_routing_table(opts, _) do
prefix = Keyword.get(opts, :prefix, :default)
with {:ok, %Protocol.ConnData{configuration: configuration}} <- Protocol.connect(opts),
# DON'T> :ok <- Protocol.disconnect(:stop, conn),
{_long, short} <- parse_server_version(configuration[:server_version]) do
{query, params} =
if Version.match?(short, ">= 3.2.3") do
props = Keyword.get(opts, :routing_context, %{})
{"CALL dbms.cluster.routing.getRoutingTable($context)", %{context: props}}
else
{"CALL dbms.cluster.routing.getServers()", %{}}
end
with {:ok, pid} <- DBConnection.start_link(Protocol, Keyword.delete(opts, :name)),
{:ok, %Response{} = results} <- Bolt.Sips.query(pid, query, params),
true <- Process.exit(pid, :normal) do
table =
results
|> Response.first()
|> Map.put(:routing_query, %{query: query, params: params})
ttl = Map.get(table, :ttl, 300) * 1000
# may overwrite the ttl, when desired in exceptional situations: tests, for example.
ttl = Keyword.get(opts, :ttl, ttl)
Process.send_after(self(), {:refresh, prefix}, ttl)
{:ok, table}
else
{:error, %Error{message: message}} ->
Logger.error(message)
{:error, message}
_e ->
"Are you sure you're connected to a Neo4j cluster? The routing table, is not available."
|> Logger.error()
{:error, :routing_table_not_available}
end
end
end
@doc """
start a new (DB)Connection process, supervised registered under a name following this convention:
- "role@hostname:port", the `role`, `hostname` and the `port` are collected from the user's
configuration: `opts`. The `role` parameter is ignored when the `routing_table` parameter represents
a neo4j map containing the definition for a neo4j cluster! It defaults to: `:direct`, when not specified!
"""
def start_connections(opts, routing_table)
def start_connections(opts, routing_table) when is_nil(routing_table) do
url = "#{opts[:hostname]}:#{opts[:port]}"
role = Keyword.get(opts, :role, :direct)
with {:ok, _pid} <- ConnectionSupervisor.start_child(role, url, opts) do
{:ok, %{role => %{url => 0}}}
end
end
def start_connections(opts, routing_table) do
connections =
with %Bolt.Sips.Routing.RoutingTable{roles: roles} = rt <- RoutingTable.parse(routing_table) do
roles
|> Enum.reduce(%{}, fn {role, addresses}, acc ->
addresses
|> Enum.reduce(acc, fn {address, count}, acc ->
# interim hack; force the schema to be `bolt`, otherwise the parse is not happening
url = "bolt://" <> address
%URI{host: host, port: port} = URI.parse(url)
# Important!
# We remove the url from the routing-specific configs, because the port and the address where the
# socket will be opened, is using the host and the port returned by the routing table, and not by the
# initial url param. The Utils will overwrite them if the `url` is defined!
config =
opts
|> Keyword.put(:host, String.to_charlist(host))
|> Keyword.put(:port, port)
|> Keyword.put(:name, role)
|> Keyword.put(:hits, count)
|> Keyword.delete(:url)
with {:ok, _pid} <- ConnectionSupervisor.start_child(role, address, config) do
Map.update(acc, role, %{address => 0}, fn urls -> Map.put(urls, address, 0) end)
else
_ -> acc
end
end)
|> Map.merge(acc)
end)
|> Map.put(:ttl, rt.ttl)
|> Map.put(:updated_at, rt.updated_at)
end
{:ok, connections}
end
@with_routing true
@impl true
def handle_info({:refresh, prefix}, state) do
%{connections: connections, user_options: user_options} = Map.get(state, prefix)
%{ttl: ttl} = connections
# may overwrite the ttl, when desired in exceptional situations: tests, for example.
ttl = Keyword.get(user_options, :ttl, ttl)
state =
with {:ok, routing_table, _updated_connections} <-
get_routing_table(connections, @with_routing, prefix),
{:ok, new_connections} <- start_connections(user_options, routing_table) do
connections =
connections
|> Map.put(:updated_at, Bolt.Sips.Utils.now())
|> merge_connections_maps(new_connections, prefix)
ttl = Keyword.get(user_options, :ttl, ttl * 1000)
Process.send_after(self(), {:refresh, prefix}, ttl)
new_state = %{user_options: user_options, connections: connections}
Map.put(state, prefix, new_state)
else
e ->
Logger.error("Cannot create any connections. Error: #{inspect(e)}")
Map.put(state, prefix, %{user_options: user_options, connections: %{}})
end
{:noreply, state}
end
def handle_info(req, state) do
Logger.warning("An unusual request: #{inspect(req)}")
{:noreply, state}
end
@server_version_stringex ~r/Neo4j\/(?<M>\d+)\.(?<m>\d+)\.(?<p>\d+)/
@spec parse_server_version(map) :: {binary, <<_::16, _::_*8>>}
@doc """
parse the version string received from the server, while considering the lack of the
patch number in some situations
## Examples
iex> Bolt.Sips.Router.parse_server_version(%{"server" => "Neo4j/3.5.0"})
{"Neo4j/3.5.0", "3.5.0"}
iex> Bolt.Sips.Router.parse_server_version(%{"server" => "Neo4j/3.5"})
{"Neo4j/3.5", "3.5.0"}
iex> Bolt.Sips.Router.parse_server_version(%{"server" => "Neo4j/3.5.10"})
{"Neo4j/3.5.10", "3.5.10"}
iex> Bolt.Sips.Router.parse_server_version(%{"server" => "Neo4j/3.5.11.1"})
{"Neo4j/3.5.11.1", "3.5.11"}
"""
def parse_server_version(%{"server" => server_version_string}) do
%{"M" => major, "m" => minor, "p" => patch} =
@server_version_stringex
|> Regex.named_captures(server_version_string <> ".0")
{server_version_string, "#{major}.#{minor}.#{patch}"}
end
def parse_server_version(some_version),
do: raise(ArgumentError, "not a Neo4J version info: " <> inspect(some_version))
defp error_no_connection_available_for_role(role, _e, prefix \\ :default)
defp error_no_connection_available_for_role(role, _e, prefix) do
"no connection exists with this role: #{role} (prefix: #{prefix})"
end
@routing_roles ~w{read write route}a
@spec merge_connections_maps(any(), any(), any()) :: any()
def merge_connections_maps(current_connections, new_connections, prefix \\ :default)
def merge_connections_maps(current_connections, new_connections, prefix) do
@routing_roles
|> Enum.flat_map(fn role ->
new_urls = Map.keys(new_connections[role])
current_connections[role]
|> Map.keys()
|> Enum.flat_map(fn url -> remove_old_urls(role, url, new_urls) end)
end)
|> close_connections(prefix)
@routing_roles
|> Enum.reduce(current_connections, fn role, acc ->
Map.put(acc, role, new_connections[role])
end)
end
defp remove_old_urls(role, url, urls), do: if(url in urls, do: [], else: [{role, url}])
# [
# read: "localhost:7689",
# write: "localhost:7687",
# write: "localhost:7690",
# route: "localhost:7688",
# route: "localhost:7689"
# ]
defp close_connections(connections, prefix) do
connections
|> Enum.each(fn {role, url} ->
with {:ok, _pid} = r <- ConnectionSupervisor.terminate_connection(role, url, prefix) do
r
else
{:error, :not_found} ->
Logger.debug("#{role}: #{url}; not a valid connection/process. It can't be terminated")
end
end)
end
@spec _get_connection(role :: String.t() | atom, state :: map, prefix :: atom) ::
{:ok, pid, map} | {:error, any, map}
defp _get_connection(role, connections, prefix) do
with true <- Map.has_key?(connections, role),
{:ok, url} <-
LoadBalancer.least_reused_url(Map.get(connections, role)),
{:ok, pid} <- ConnectionSupervisor.find_connection(role, url, prefix) do
{_, updated_connections} =
connections
|> get_and_update_in([role, url], fn hits -> {hits, hits + 1} end)
{:ok, pid, updated_connections}
else
e ->
err_msg = error_no_connection_available_for_role(role, e)
{:error, err_msg, connections}
end
end
end