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\/(?\d+)\.(?\d+)\.(?

\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