Merge pull request #572 from poanetwork/532
Use WebSocket subscription to newHeads for realtime indexerpull/574/merge
commit
050870f8b4
@ -0,0 +1,5 @@ |
|||||||
|
# Tests with everything using `Mox` |
||||||
|
|
||||||
|
use Mix.Config |
||||||
|
|
||||||
|
config :ethereum_jsonrpc, EthereumJSONRPC.Case, json_rpc_named_arguments: [transport: EthereumJSONRPC.Mox] |
@ -0,0 +1,59 @@ |
|||||||
|
defmodule EthereumJSONRPC.Subscription do |
||||||
|
@moduledoc """ |
||||||
|
A subscription to an event |
||||||
|
""" |
||||||
|
|
||||||
|
alias EthereumJSONRPC.Transport |
||||||
|
|
||||||
|
@enforce_keys ~w(id subscriber_pid transport transport_options)a |
||||||
|
defstruct ~w(id subscriber_pid transport transport_options)a |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
An event that can be suscribed to. |
||||||
|
|
||||||
|
* `"newHeads"` - when new blocks are added to chain including during reorgs. |
||||||
|
""" |
||||||
|
@type event :: String.t() |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
Subscription ID returned from `eth_subscribe` and used to canceled a subscription with `eth_unsubscribe`. |
||||||
|
""" |
||||||
|
@type id :: String.t() |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
Parameters for customizing subscription to `t:event/0`. |
||||||
|
""" |
||||||
|
@type params :: list() |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
* `id` - the `t:/id/0` of the subscription on the server |
||||||
|
* `subscriber_pid` - the `t:pid/0` of process where `transport_pid` should send messages |
||||||
|
* `transport` - the `t:EthereumJSONRPC.Transport.t/0` callback module |
||||||
|
* `transport_options` - options passed to `c:EthereumJSONRPC.Transport.json_rpc/2` |
||||||
|
""" |
||||||
|
@type t :: %__MODULE__{id: id, subscriber_pid: pid, transport: Transport.t(), transport_options: Transport.options()} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Publishes `messages` to all `subscriptions`s' `subscriber_pid`s. |
||||||
|
|
||||||
|
Sends `message` tagged with each `subscription`: `{subscription, message}`. |
||||||
|
""" |
||||||
|
@spec broadcast(Enumerable.t(), message :: term()) :: :ok |
||||||
|
def broadcast(subscriptions, message) do |
||||||
|
Enum.each(subscriptions, &publish(&1, message)) |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Publishes `message` to the `subscription`'s `subscriber_pid`. |
||||||
|
|
||||||
|
Sends `message` tagged with `subscription`: `{subscription, message}`. |
||||||
|
""" |
||||||
|
@spec publish(t(), message :: term()) :: :ok |
||||||
|
def publish(%__MODULE__{subscriber_pid: subscriber_pid} = subscription, message) do |
||||||
|
send(subscriber_pid, subscription_message(subscription, message)) |
||||||
|
end |
||||||
|
|
||||||
|
defp subscription_message(%__MODULE__{} = subscription, message) do |
||||||
|
{subscription, message} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,113 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket do |
||||||
|
@moduledoc """ |
||||||
|
JSONRPC over WebSocket. |
||||||
|
""" |
||||||
|
|
||||||
|
alias EthereumJSONRPC.{Subscription, Transport} |
||||||
|
|
||||||
|
@behaviour Transport |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
WebSocket name |
||||||
|
""" |
||||||
|
# same as `t:GenServer.name/0` |
||||||
|
@type name :: atom() | {:global, term()} | {:via, module(), term()} |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
WebSocket reference |
||||||
|
""" |
||||||
|
# same as `t:GenServer.server/0` |
||||||
|
@type web_socket :: pid() | name() | {atom(), node()} |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
Options passed to `EthereumJSONRPC.Transport` callbacks. |
||||||
|
|
||||||
|
**MUST** contain `t:web_socket/0` referring to `t:pid/0` returned by `c:start_link/2`. |
||||||
|
""" |
||||||
|
@type options :: [{:web_socket, web_socket()} | {:web_socket_options, term()}] |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Allow `c:start_link/1` to be called as part of a supervision tree. |
||||||
|
""" |
||||||
|
@callback child_spec([url :: String.t() | options :: term()]) :: Supervisor.child_spec() |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Starts web socket attached to `url` with `options`. |
||||||
|
|
||||||
|
|
||||||
|
""" |
||||||
|
# Return is same as `t:GenServer.on_start/0` |
||||||
|
@callback start_link([url :: String.t() | options :: term()]) :: |
||||||
|
{:ok, pid()} | :ignore | {:error, {:already_started, pid()} | reason :: term()} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Run a single Remote Procedure Call (RPC) `t:EthereumJSONRPC.Transport.request/0` through `t:web_socket/0`. |
||||||
|
|
||||||
|
## Returns |
||||||
|
|
||||||
|
* `{:ok, result}` - `result` is the `/result` from JSONRPC response object of format |
||||||
|
`%{"id" => ..., "result" => result}`. |
||||||
|
* `{:error, reason}` - `reason` is the the `/error` from JSONRPC response object of format |
||||||
|
`%{"id" => ..., "error" => reason}`. The transport can also give any `term()` for `reason` if a more specific |
||||||
|
reason is possible. |
||||||
|
|
||||||
|
""" |
||||||
|
@callback json_rpc(web_socket(), Transport.request()) :: {:ok, Transport.result()} | {:error, reason :: term()} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Subscribes to `t:EthereumJSONRPC.Subscription.event/0` with `t:EthereumJSONRPC.Subscription.params/0` through |
||||||
|
`t:web_socket/0`. |
||||||
|
|
||||||
|
Events are delivered in a tuple tagged with the `t:EthereumJSONRPC.Subscription.t/0` and containing the same output |
||||||
|
as `json_rpc/2`. |
||||||
|
|
||||||
|
| Message | Description | |
||||||
|
|-----------------------------------------------------------------------------------|----------------------------------------| |
||||||
|
| `{EthereumJSONRPC.Subscription.t(), {:ok, EthreumsJSONRPC.Transport.result.t()}}` | New result in subscription | |
||||||
|
| `{EthereumJSONRPC.Subscription.t(), {:error, reason :: term()}}` | There was an error in the subscription | |
||||||
|
|
||||||
|
Subscription can be canceled by calling `unsubscribe/1` with the returned `t:EthereumJSONRPC.Subscription.t/0`. |
||||||
|
""" |
||||||
|
@callback subscribe(web_socket(), event :: Subscription.event(), params :: Subscription.params()) :: |
||||||
|
{:ok, Subscription.t()} | {:error, reason :: term()} |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Unsubscribes to `t:EthereumJSONRPC.Subscription.t/0` created with `subscribe/2`. |
||||||
|
|
||||||
|
## Returns |
||||||
|
|
||||||
|
* `:ok` - subscription was canceled |
||||||
|
* `{:error, reason}` - subscription could not be canceled. |
||||||
|
|
||||||
|
""" |
||||||
|
@callback unsubscribe(web_socket(), Subscription.t()) :: :ok | {:error, reason :: term()} |
||||||
|
|
||||||
|
@impl Transport |
||||||
|
@spec json_rpc(Transport.request(), options) :: {:ok, Transport.result()} | {:error, reason :: term()} |
||||||
|
def json_rpc(request, options) do |
||||||
|
web_socket_module = Keyword.fetch!(options, :web_socket) |
||||||
|
%{web_socket: web_socket} = Keyword.fetch!(options, :web_socket_options) |
||||||
|
|
||||||
|
web_socket_module.json_rpc(web_socket, request) |
||||||
|
end |
||||||
|
|
||||||
|
@impl Transport |
||||||
|
@spec subscribe(event :: Subscription.event(), params :: Subscription.params(), options) :: |
||||||
|
{:ok, Subscription.t()} | {:error, reason :: term()} |
||||||
|
def subscribe(event, params, options) when is_binary(event) and is_list(params) do |
||||||
|
web_socket_module = Keyword.fetch!(options, :web_socket) |
||||||
|
%{web_socket: web_socket} = Keyword.fetch!(options, :web_socket_options) |
||||||
|
|
||||||
|
web_socket_module.subscribe(web_socket, event, params) |
||||||
|
end |
||||||
|
|
||||||
|
@impl Transport |
||||||
|
@spec unsubscribe(%Subscription{transport: __MODULE__, transport_options: options}) :: |
||||||
|
:ok | {:error, reason :: term()} |
||||||
|
def unsubscribe(%Subscription{transport: __MODULE__, transport_options: transport_options} = subscription) do |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
%{web_socket: web_socket} = Keyword.fetch!(transport_options, :web_socket_options) |
||||||
|
|
||||||
|
web_socket_module.unsubscribe(web_socket, subscription) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,24 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.Registration do |
||||||
|
@moduledoc """ |
||||||
|
When a caller registers for responses to asynchronous frame responses. |
||||||
|
""" |
||||||
|
|
||||||
|
alias EthereumJSONRPC.Subscription |
||||||
|
|
||||||
|
@enforce_keys ~w(from type)a |
||||||
|
defstruct ~w(from type subscription_id)a |
||||||
|
|
||||||
|
@typedoc """ |
||||||
|
What kind of request will be issued by the caller |
||||||
|
|
||||||
|
* `:json_rpc` - a generic JSONRPC request that just needs to be returned to the caller based on `id` matching. |
||||||
|
* `:subscribe` - an `eth_subscribe` request will be issued by the caller. Its response need to be returned to |
||||||
|
caller **AND** the client needs to `EthereumsJSONRPC.Subscription.publish/2` any `eth_subscription` messages to |
||||||
|
the caller until the `EthereumJSONRPC.WebSocket.Client.unsubscribe/1` is called. |
||||||
|
* `:unsubscribe` - an `eth_unsubscribe` request will be issued by the caller. Its response needs to be returned to |
||||||
|
caller **AND** the client needs to stop tracking the subscription. |
||||||
|
""" |
||||||
|
@type type :: :json_rpc | :subscribe | :unsubscribe |
||||||
|
|
||||||
|
@type t :: %__MODULE__{from: GenServer.from(), type: type, subscription_id: Subscription.id()} |
||||||
|
end |
@ -0,0 +1,322 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.WebSocketClient do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.WebSocket` that uses `websocket_client` |
||||||
|
""" |
||||||
|
|
||||||
|
require Logger |
||||||
|
|
||||||
|
import EthereumJSONRPC, only: [request: 1] |
||||||
|
|
||||||
|
alias EthereumJSONRPC.{Subscription, Transport, WebSocket} |
||||||
|
alias EthereumJSONRPC.WebSocket.Registration |
||||||
|
|
||||||
|
@behaviour :websocket_client |
||||||
|
@behaviour WebSocket |
||||||
|
|
||||||
|
@enforce_keys ~w(url)a |
||||||
|
defstruct request_id_to_registration: %{}, |
||||||
|
subscription_id_to_subscription: %{}, |
||||||
|
url: nil |
||||||
|
|
||||||
|
# Supervisor interface |
||||||
|
|
||||||
|
@impl WebSocket |
||||||
|
def child_spec(arg) do |
||||||
|
Supervisor.child_spec(%{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}}, []) |
||||||
|
end |
||||||
|
|
||||||
|
@impl WebSocket |
||||||
|
# only allow secure WSS |
||||||
|
def start_link(["wss://" <> _ = url, gen_fsm_options]) when is_list(gen_fsm_options) do |
||||||
|
fsm_name = |
||||||
|
case Keyword.fetch(gen_fsm_options, :name) do |
||||||
|
{:ok, name} when is_atom(name) -> {:local, name} |
||||||
|
:error -> :undefined |
||||||
|
end |
||||||
|
|
||||||
|
%URI{host: host} = URI.parse(url) |
||||||
|
host_charlist = String.to_charlist(host) |
||||||
|
|
||||||
|
# `:depth`, `:verify`, and `:verify_fun`, are based on `:hackney_connect.ssl_opts_1/2` as we use `:hackney` through |
||||||
|
# `:httpoison` and this keeps the SSL rules consistent between HTTP and WebSocket |
||||||
|
:websocket_client.start_link(fsm_name, url, __MODULE__, url, |
||||||
|
ssl_verify: :verify_peer, |
||||||
|
socket_opts: [ |
||||||
|
cacerts: :certifi.cacerts(), |
||||||
|
depth: 99, |
||||||
|
# SNI extension discloses host name in the clear, but allows for compatibility with Virtual Hosting for TLS |
||||||
|
server_name_indication: host_charlist, |
||||||
|
verify_fun: {&:ssl_verify_hostname.verify_fun/3, [check_hostname: host_charlist]} |
||||||
|
] |
||||||
|
) |
||||||
|
end |
||||||
|
|
||||||
|
# Client interface |
||||||
|
|
||||||
|
@impl WebSocket |
||||||
|
@spec json_rpc(WebSocket.web_socket(), Transport.request()) :: {:ok, Transport.result()} | {:error, reason :: term()} |
||||||
|
def json_rpc(web_socket, request) do |
||||||
|
GenServer.call(web_socket, {:json_rpc, request}) |
||||||
|
end |
||||||
|
|
||||||
|
@impl WebSocket |
||||||
|
@spec subscribe(WebSocket.web_socket(), Subscription.event(), Subscription.params()) :: |
||||||
|
{:ok, Subscription.t()} | {:error, reason :: term()} |
||||||
|
def subscribe(web_socket, event, params) when is_binary(event) and is_list(params) do |
||||||
|
GenServer.call(web_socket, {:subscribe, event, params}) |
||||||
|
end |
||||||
|
|
||||||
|
@impl WebSocket |
||||||
|
@spec unsubscribe(WebSocket.web_socket(), Subscription.t()) :: :ok | {:error, :not_found} |
||||||
|
def unsubscribe(web_socket, %Subscription{} = subscription) do |
||||||
|
GenServer.call(web_socket, {:unsubscribe, subscription}) |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def init(url) do |
||||||
|
{:reconnect, %__MODULE__{url: url}} |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def onconnect(_, %__MODULE__{} = state) do |
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def ondisconnect(reason, %__MODULE__{} = state) do |
||||||
|
{:close, reason, state} |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def websocket_handle({:text, text}, _request, %__MODULE__{} = state) do |
||||||
|
case Jason.decode(text) do |
||||||
|
{:ok, json} -> |
||||||
|
handle_response(json, state) |
||||||
|
|
||||||
|
{:error, _} = error -> |
||||||
|
broadcast(error, state) |
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def websocket_info({:"$gen_call", from, request}, _, %__MODULE__{} = state) do |
||||||
|
handle_call(request, from, state) |
||||||
|
end |
||||||
|
|
||||||
|
@impl :websocket_client |
||||||
|
def websocket_terminate(close, _request, %__MODULE__{} = state) do |
||||||
|
broadcast(close, state) |
||||||
|
end |
||||||
|
|
||||||
|
defp broadcast(message, %__MODULE__{subscription_id_to_subscription: id_to_subscription}) do |
||||||
|
id_to_subscription |
||||||
|
|> Map.values() |
||||||
|
|> Subscription.broadcast(message) |
||||||
|
end |
||||||
|
|
||||||
|
defp handle_call(message, from, %__MODULE__{} = state) do |
||||||
|
{updated_state, unique_request} = register(message, from, state) |
||||||
|
|
||||||
|
{:reply, {:text, Jason.encode!(unique_request)}, updated_state} |
||||||
|
end |
||||||
|
|
||||||
|
defp handle_response( |
||||||
|
%{"method" => "eth_subscription", "params" => %{"result" => result, "subscription" => subscription_id}}, |
||||||
|
%__MODULE__{subscription_id_to_subscription: subscription_id_to_subscription} = state |
||||||
|
) do |
||||||
|
case subscription_id_to_subscription do |
||||||
|
%{^subscription_id => subscription} -> |
||||||
|
Subscription.publish(subscription, {:ok, result}) |
||||||
|
|
||||||
|
_ -> |
||||||
|
Logger.error(fn -> |
||||||
|
[ |
||||||
|
"Unexpected `eth_subscription` subscription ID (", |
||||||
|
inspect(subscription_id), |
||||||
|
") result (", |
||||||
|
inspect(result), |
||||||
|
"). Subscription ID not in known subscription IDs (", |
||||||
|
subscription_id_to_subscription |
||||||
|
|> Map.values() |
||||||
|
|> Enum.map(&inspect/1), |
||||||
|
")." |
||||||
|
] |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
|
||||||
|
defp handle_response( |
||||||
|
%{"id" => id} = response, |
||||||
|
%__MODULE__{request_id_to_registration: request_id_to_registration} = state |
||||||
|
) do |
||||||
|
{registration, new_request_id_to_registration} = Map.pop(request_id_to_registration, id) |
||||||
|
respond_to_registration(registration, new_request_id_to_registration, response, state) |
||||||
|
end |
||||||
|
|
||||||
|
defp handle_response(response, %__MODULE__{} = state) do |
||||||
|
Logger.error(fn -> |
||||||
|
[ |
||||||
|
"Unexpected JSON response from web socket\n", |
||||||
|
"\n", |
||||||
|
" Response:\n", |
||||||
|
" ", |
||||||
|
inspect(response) |
||||||
|
] |
||||||
|
end) |
||||||
|
|
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
|
||||||
|
defp register( |
||||||
|
{:json_rpc, original_request}, |
||||||
|
from, |
||||||
|
%__MODULE__{request_id_to_registration: request_id_to_registration} = state |
||||||
|
) do |
||||||
|
unique_id = unique_request_id(state) |
||||||
|
|
||||||
|
{%__MODULE__{ |
||||||
|
state |
||||||
|
| request_id_to_registration: |
||||||
|
Map.put(request_id_to_registration, unique_id, %Registration{ |
||||||
|
from: from, |
||||||
|
type: :json_rpc |
||||||
|
}) |
||||||
|
}, %{original_request | id: unique_id}} |
||||||
|
end |
||||||
|
|
||||||
|
defp register( |
||||||
|
{:subscribe, event, params}, |
||||||
|
from, |
||||||
|
%__MODULE__{request_id_to_registration: request_id_to_registration} = state |
||||||
|
) |
||||||
|
when is_binary(event) and is_list(params) do |
||||||
|
unique_id = unique_request_id(state) |
||||||
|
|
||||||
|
{ |
||||||
|
%__MODULE__{ |
||||||
|
state |
||||||
|
| request_id_to_registration: |
||||||
|
Map.put(request_id_to_registration, unique_id, %Registration{from: from, type: :subscribe}) |
||||||
|
}, |
||||||
|
request(%{id: unique_id, method: "eth_subscribe", params: [event | params]}) |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
defp register( |
||||||
|
{:unsubscribe, %Subscription{id: subscription_id}}, |
||||||
|
from, |
||||||
|
%__MODULE__{request_id_to_registration: request_id_to_registration} = state |
||||||
|
) do |
||||||
|
unique_id = unique_request_id(state) |
||||||
|
|
||||||
|
{ |
||||||
|
%__MODULE__{ |
||||||
|
state |
||||||
|
| request_id_to_registration: |
||||||
|
Map.put(request_id_to_registration, unique_id, %Registration{ |
||||||
|
from: from, |
||||||
|
type: :unsubscribe, |
||||||
|
subscription_id: subscription_id |
||||||
|
}) |
||||||
|
}, |
||||||
|
request(%{id: unique_id, method: "eth_unsubscribe", params: [subscription_id]}) |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
defp respond_to_registration( |
||||||
|
%Registration{type: :json_rpc, from: from}, |
||||||
|
new_request_id_to_registration, |
||||||
|
response, |
||||||
|
%__MODULE__{} = state |
||||||
|
) do |
||||||
|
reply = |
||||||
|
case response do |
||||||
|
%{"result" => result} -> {:ok, result} |
||||||
|
%{"error" => error} -> {:error, error} |
||||||
|
end |
||||||
|
|
||||||
|
GenServer.reply(from, reply) |
||||||
|
|
||||||
|
{:ok, %__MODULE__{state | request_id_to_registration: new_request_id_to_registration}} |
||||||
|
end |
||||||
|
|
||||||
|
defp respond_to_registration( |
||||||
|
%Registration{type: :subscribe, from: {subscriber_pid, _} = from}, |
||||||
|
new_request_id_to_registration, |
||||||
|
%{"result" => subscription_id}, |
||||||
|
%__MODULE__{url: url} = state |
||||||
|
) do |
||||||
|
subscription = %Subscription{ |
||||||
|
id: subscription_id, |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: EthereumJSONRPC.WebSocket, |
||||||
|
transport_options: [web_socket: __MODULE__, web_socket_options: %{web_socket: self()}, url: url] |
||||||
|
} |
||||||
|
|
||||||
|
GenServer.reply(from, {:ok, subscription}) |
||||||
|
|
||||||
|
new_state = |
||||||
|
state |
||||||
|
|> put_in([Access.key!(:request_id_to_registration)], new_request_id_to_registration) |
||||||
|
|> put_in([Access.key!(:subscription_id_to_subscription), subscription_id], subscription) |
||||||
|
|
||||||
|
{:ok, new_state} |
||||||
|
end |
||||||
|
|
||||||
|
defp respond_to_registration( |
||||||
|
%Registration{type: :subscribe, from: from}, |
||||||
|
new_request_id_to_registration, |
||||||
|
%{"error" => error}, |
||||||
|
%__MODULE__{} = state |
||||||
|
) do |
||||||
|
GenServer.reply(from, {:error, error}) |
||||||
|
|
||||||
|
{:ok, %__MODULE__{state | request_id_to_registration: new_request_id_to_registration}} |
||||||
|
end |
||||||
|
|
||||||
|
defp respond_to_registration( |
||||||
|
%Registration{type: :unsubscribe, from: from, subscription_id: subscription_id}, |
||||||
|
new_request_id_to_registration, |
||||||
|
response, |
||||||
|
%__MODULE__{} = state |
||||||
|
) do |
||||||
|
reply = |
||||||
|
case response do |
||||||
|
%{"result" => true} -> :ok |
||||||
|
%{"result" => false} -> {:error, :not_found} |
||||||
|
%{"error" => %{"message" => "subscription not found"}} -> {:error, :not_found} |
||||||
|
%{"error" => error} -> {:error, error} |
||||||
|
end |
||||||
|
|
||||||
|
GenServer.reply(from, reply) |
||||||
|
|
||||||
|
new_state = |
||||||
|
state |
||||||
|
|> put_in([Access.key!(:request_id_to_registration)], new_request_id_to_registration) |
||||||
|
|> update_in([Access.key!(:subscription_id_to_subscription)], &Map.delete(&1, subscription_id)) |
||||||
|
|
||||||
|
{:ok, new_state} |
||||||
|
end |
||||||
|
|
||||||
|
defp respond_to_registration(nil, _, response, %__MODULE__{} = state) do |
||||||
|
Logger.error(fn -> ["Got response for unregistered request ID: ", inspect(response)] end) |
||||||
|
|
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
|
||||||
|
defp unique_request_id(%__MODULE__{request_id_to_registration: request_id_to_registration} = state) do |
||||||
|
unique_request_id = EthereumJSONRPC.unique_request_id() |
||||||
|
|
||||||
|
case request_id_to_registration do |
||||||
|
# collision |
||||||
|
%{^unique_request_id => _} -> |
||||||
|
unique_request_id(state) |
||||||
|
|
||||||
|
_ -> |
||||||
|
unique_request_id |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,219 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocketTest do |
||||||
|
use EthereumJSONRPC.WebSocket.Case, async: true |
||||||
|
|
||||||
|
import EthereumJSONRPC, only: [request: 1] |
||||||
|
import Mox |
||||||
|
|
||||||
|
alias EthereumJSONRPC.{Subscription, WebSocket} |
||||||
|
|
||||||
|
setup :verify_on_exit! |
||||||
|
|
||||||
|
describe "json_rpc/2" do |
||||||
|
test "can get result", %{subscribe_named_arguments: subscribe_named_arguments} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
|
||||||
|
if transport_options[:web_socket] == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
expect(EthereumJSONRPC.WebSocket.Mox, :json_rpc, fn _, _ -> |
||||||
|
{:ok, %{"number" => "0x0"}} |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, %{"number" => "0x0"}} = |
||||||
|
%{id: 1, method: "eth_getBlockByNumber", params: ["earliest", false]} |
||||||
|
|> request() |
||||||
|
|> WebSocket.json_rpc(transport_options) |
||||||
|
end |
||||||
|
|
||||||
|
test "can get error", %{subscribe_named_arguments: subscribe_named_arguments} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
|
||||||
|
if transport_options[:web_socket] == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
expect(EthereumJSONRPC.WebSocket.Mox, :json_rpc, fn _, _ -> |
||||||
|
{:error, |
||||||
|
%{ |
||||||
|
"code" => -32601, |
||||||
|
"message" => "Method not found" |
||||||
|
}} |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
# purposely misspell method to trigger error |
||||||
|
assert {:error, |
||||||
|
%{ |
||||||
|
"code" => -32601, |
||||||
|
# Message varies by variant, so don't match on it |
||||||
|
"message" => _ |
||||||
|
}} = |
||||||
|
%{id: 1, method: "eth_getBlockByNumbe", params: ["earliest", false]} |
||||||
|
|> request() |
||||||
|
|> WebSocket.json_rpc(transport_options) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "subscribe/2" do |
||||||
|
test "can subscribe to newHeads", %{subscribe_named_arguments: subscribe_named_arguments} do |
||||||
|
transport = Keyword.fetch!(subscribe_named_arguments, :transport) |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
subscriber_pid = self() |
||||||
|
|
||||||
|
if transport_options[:web_socket] == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
expect(EthereumJSONRPC.WebSocket.Mox, :subscribe, fn _, _, _ -> |
||||||
|
{:ok, |
||||||
|
%Subscription{ |
||||||
|
id: "0x1", |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: transport, |
||||||
|
transport_options: transport_options |
||||||
|
}} |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, |
||||||
|
%Subscription{ |
||||||
|
id: subscription_id, |
||||||
|
subscriber_pid: ^subscriber_pid, |
||||||
|
transport: ^transport, |
||||||
|
transport_options: ^transport_options |
||||||
|
}} = WebSocket.subscribe("newHeads", [], transport_options) |
||||||
|
|
||||||
|
assert is_binary(subscription_id) |
||||||
|
end |
||||||
|
|
||||||
|
test "delivers new heads to caller", %{ |
||||||
|
block_interval: block_interval, |
||||||
|
subscribe_named_arguments: subscribe_named_arguments |
||||||
|
} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
subscriber_pid = self() |
||||||
|
|
||||||
|
if web_socket_module == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
expect(web_socket_module, :subscribe, fn _, _, _ -> |
||||||
|
subscription = %Subscription{ |
||||||
|
id: "0x1", |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: Keyword.fetch!(subscribe_named_arguments, :transport), |
||||||
|
transport_options: transport_options |
||||||
|
} |
||||||
|
|
||||||
|
Process.send_after(subscriber_pid, {subscription, {:ok, %{"number" => "0x1"}}}, block_interval) |
||||||
|
|
||||||
|
{:ok, subscription} |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, subscription} = WebSocket.subscribe("newHeads", [], transport_options) |
||||||
|
|
||||||
|
assert_receive {^subscription, {:ok, %{"number" => _}}}, block_interval * 2 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
describe "unsubscribe/2" do |
||||||
|
test "can unsubscribe", %{subscribe_named_arguments: subscribe_named_arguments} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
subscriber_pid = self() |
||||||
|
|
||||||
|
if web_socket_module == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
subscription = %Subscription{ |
||||||
|
id: "0x1", |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: Keyword.fetch!(subscribe_named_arguments, :transport), |
||||||
|
transport_options: transport_options |
||||||
|
} |
||||||
|
|
||||||
|
web_socket_module |
||||||
|
|> expect(:subscribe, fn _, _, _ -> {:ok, subscription} end) |
||||||
|
|> expect(:unsubscribe, fn _, ^subscription -> :ok end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, subscription} = WebSocket.subscribe("newHeads", [], transport_options) |
||||||
|
|
||||||
|
assert :ok = WebSocket.unsubscribe(subscription) |
||||||
|
end |
||||||
|
|
||||||
|
test "stops messages being sent to subscriber", %{ |
||||||
|
block_interval: block_interval, |
||||||
|
subscribe_named_arguments: subscribe_named_arguments |
||||||
|
} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
subscriber_pid = self() |
||||||
|
|
||||||
|
if web_socket_module == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
subscription = %Subscription{ |
||||||
|
id: "0x1", |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: Keyword.fetch!(subscribe_named_arguments, :transport), |
||||||
|
transport_options: transport_options |
||||||
|
} |
||||||
|
|
||||||
|
web_socket_module |
||||||
|
|> expect(:subscribe, 2, fn pid, _, _ when is_pid(pid) -> |
||||||
|
send(pid, {:subscribe, subscription}) |
||||||
|
|
||||||
|
{:ok, subscription} |
||||||
|
end) |
||||||
|
|> expect(:unsubscribe, fn pid, ^subscription when is_pid(pid) -> |
||||||
|
send(pid, {:unsubscribe, subscription}) |
||||||
|
|
||||||
|
:ok |
||||||
|
end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, first_subscription} = |
||||||
|
WebSocket.subscribe("newHeads", [], subscribe_named_arguments[:transport_options]) |
||||||
|
|
||||||
|
assert {:ok, second_subscription} = |
||||||
|
WebSocket.subscribe("newHeads", [], subscribe_named_arguments[:transport_options]) |
||||||
|
|
||||||
|
wait = block_interval * 2 |
||||||
|
|
||||||
|
assert_receive {^first_subscription, {:ok, %{"number" => _}}}, wait |
||||||
|
assert_receive {^second_subscription, {:ok, %{"number" => _}}}, wait |
||||||
|
|
||||||
|
assert :ok = WebSocket.unsubscribe(first_subscription) |
||||||
|
|
||||||
|
clear_mailbox() |
||||||
|
|
||||||
|
# see the message on the second subscription, so that we don't have to wait for the refute_receive, which would |
||||||
|
# wait the full timeout |
||||||
|
assert_receive {^second_subscription, {:ok, %{"number" => _}}}, wait |
||||||
|
refute_receive {^first_subscription, _} |
||||||
|
end |
||||||
|
|
||||||
|
test "return error if already unsubscribed", %{subscribe_named_arguments: subscribe_named_arguments} do |
||||||
|
transport_options = subscribe_named_arguments[:transport_options] |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
subscriber_pid = self() |
||||||
|
|
||||||
|
if web_socket_module == EthereumJSONRPC.WebSocket.Mox do |
||||||
|
subscription = %Subscription{ |
||||||
|
id: "0x1", |
||||||
|
subscriber_pid: subscriber_pid, |
||||||
|
transport: Keyword.fetch!(subscribe_named_arguments, :transport), |
||||||
|
transport_options: transport_options |
||||||
|
} |
||||||
|
|
||||||
|
web_socket_module |
||||||
|
|> expect(:subscribe, fn _, _, _ -> {:ok, subscription} end) |
||||||
|
|> expect(:unsubscribe, fn _, ^subscription -> :ok end) |
||||||
|
|> expect(:unsubscribe, fn _, ^subscription -> {:error, :not_found} end) |
||||||
|
end |
||||||
|
|
||||||
|
assert {:ok, subscription} = WebSocket.subscribe("newHeads", [], transport_options) |
||||||
|
assert :ok = WebSocket.unsubscribe(subscription) |
||||||
|
|
||||||
|
assert {:error, :not_found} = WebSocket.unsubscribe(subscription) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp clear_mailbox do |
||||||
|
receive do |
||||||
|
_ -> clear_mailbox() |
||||||
|
after |
||||||
|
0 -> |
||||||
|
:ok |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,19 @@ |
|||||||
|
defmodule EthereumJSONRPC.Case.Geth.HTTPWebSocket do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.Case` for connecting to Geth using `EthereumJSONRPC.HTTP` for `json_rpc_named_arguments` |
||||||
|
`transport` and `EthereumJSONRPC.WebSocket` for `subscribe_named_arguments` `transport`. |
||||||
|
""" |
||||||
|
|
||||||
|
def setup do |
||||||
|
EthereumJSONRPC.WebSocket.Case.Geth.setup() |
||||||
|
|> Map.put(:json_rpc_named_arguments, |
||||||
|
transport: EthereumJSONRPC.HTTP, |
||||||
|
transport_options: [ |
||||||
|
http: EthereumJSONRPC.HTTP.HTTPoison, |
||||||
|
http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]], |
||||||
|
url: "https://mainnet.infura.io/8lTvJTKmHPCHazkneJsY" |
||||||
|
], |
||||||
|
variant: EthereumJSONRPC.Geth |
||||||
|
) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
defmodule EthereumJSONRPC.Case.Geth.Mox do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.Case` for mocking connecting to Geth using `Mox` |
||||||
|
""" |
||||||
|
|
||||||
|
def setup do |
||||||
|
%{ |
||||||
|
block_interval: 500, |
||||||
|
json_rpc_named_arguments: [transport: EthereumJSONRPC.Mox, transport_options: [], variant: EthereumJSONRPC.Geth], |
||||||
|
subscribe_named_arguments: [transport: EthereumJSONRPC.Mox, transport_options: []] |
||||||
|
} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,20 @@ |
|||||||
|
defmodule EthereumJSONRPC.Case.Parity.HTTPWebSocket do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.Case` for connecting to Parity using `EthereumJSONRPC.HTTP` for `json_rpc_named_arguments` |
||||||
|
`transport` and `EthereumJSONRPC.WebSocket` for `subscribe_named_arguments` `transport`. |
||||||
|
""" |
||||||
|
|
||||||
|
def setup do |
||||||
|
EthereumJSONRPC.WebSocket.Case.Parity.setup() |
||||||
|
|> Map.put( |
||||||
|
:json_rpc_named_arguments, |
||||||
|
transport: EthereumJSONRPC.HTTP, |
||||||
|
transport_options: [ |
||||||
|
http: EthereumJSONRPC.HTTP.HTTPoison, |
||||||
|
http_options: [recv_timeout: 60_000, timeout: 60_000, hackney: [pool: :ethereum_jsonrpc]], |
||||||
|
url: "https://sokol-trace.poa.network" |
||||||
|
], |
||||||
|
variant: EthereumJSONRPC.Parity |
||||||
|
) |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
defmodule EthereumJSONRPC.Case.Parity.Mox do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.Case` for mocking connecting to Parity using `Mox` |
||||||
|
""" |
||||||
|
|
||||||
|
def setup do |
||||||
|
%{ |
||||||
|
block_interval: 500, |
||||||
|
json_rpc_named_arguments: [transport: EthereumJSONRPC.Mox, transport_options: [], variant: EthereumJSONRPC.Parity], |
||||||
|
subscribe_named_arguments: [transport: EthereumJSONRPC.Mox, transport_options: []] |
||||||
|
} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,9 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.Case do |
||||||
|
use ExUnit.CaseTemplate |
||||||
|
|
||||||
|
import EthereumJSONRPC.Case, only: [module: 2] |
||||||
|
|
||||||
|
setup do |
||||||
|
module("ETHEREUM_JSONRPC_WEB_SOCKET_CASE", "EthereumJSONRPC.WebSocket.Case.Mox").setup() |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,25 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.Case.Geth do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.WebSocket.Case` connecting to Geth. |
||||||
|
""" |
||||||
|
|
||||||
|
import ExUnit.Callbacks, only: [start_supervised!: 1] |
||||||
|
|
||||||
|
def setup do |
||||||
|
url = "wss://mainnet.infura.io/ws/8lTvJTKmHPCHazkneJsY" |
||||||
|
web_socket_module = EthereumJSONRPC.WebSocket.WebSocketClient |
||||||
|
web_socket = start_supervised!({web_socket_module, [url, []]}) |
||||||
|
|
||||||
|
%{ |
||||||
|
block_interval: 25_000, |
||||||
|
subscribe_named_arguments: [ |
||||||
|
transport: EthereumJSONRPC.WebSocket, |
||||||
|
transport_options: [ |
||||||
|
web_socket: web_socket_module, |
||||||
|
web_socket_options: %{web_socket: web_socket}, |
||||||
|
url: url |
||||||
|
] |
||||||
|
] |
||||||
|
} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,76 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.Case.Mox do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.WebSocket.Case` using `Mox` |
||||||
|
""" |
||||||
|
|
||||||
|
import ExUnit.Callbacks, only: [start_supervised!: 1] |
||||||
|
import Mox |
||||||
|
|
||||||
|
alias EthereumJSONRPC.Subscription |
||||||
|
|
||||||
|
@block_interval 250 |
||||||
|
|
||||||
|
def setup do |
||||||
|
web_socket_module = EthereumJSONRPC.WebSocket.Mox |
||||||
|
|
||||||
|
web_socket_module |
||||||
|
|> allow(self(), supervisor()) |
||||||
|
|> stub(:child_spec, fn arguments -> |
||||||
|
Supervisor.child_spec( |
||||||
|
%{ |
||||||
|
id: web_socket_module, |
||||||
|
start: {web_socket_module, :start_link, arguments} |
||||||
|
}, |
||||||
|
[] |
||||||
|
) |
||||||
|
end) |
||||||
|
|> stub(:start_link, fn _ -> |
||||||
|
Task.start_link(__MODULE__, :loop, [%{}]) |
||||||
|
end) |
||||||
|
|
||||||
|
url = "wss://example.com/ws" |
||||||
|
web_socket = start_supervised!({web_socket_module, [url]}) |
||||||
|
|
||||||
|
%{ |
||||||
|
block_interval: @block_interval, |
||||||
|
subscribe_named_arguments: [ |
||||||
|
transport: EthereumJSONRPC.WebSocket, |
||||||
|
transport_options: [ |
||||||
|
web_socket: web_socket_module, |
||||||
|
web_socket_options: %{web_socket: web_socket}, |
||||||
|
url: url |
||||||
|
] |
||||||
|
] |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
def loop(%{subscription: subscription, timer_reference: timer_reference}) do |
||||||
|
receive do |
||||||
|
{:unsubscribe, ^subscription} -> |
||||||
|
{:ok, :cancel} = :timer.cancel(timer_reference) |
||||||
|
loop(%{}) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def loop(%{}) do |
||||||
|
receive do |
||||||
|
{:subscribe, %Subscription{subscriber_pid: subscriber_pid} = subscription} -> |
||||||
|
{:ok, timer_reference} = |
||||||
|
:timer.send_interval(@block_interval, subscriber_pid, {subscription, {:ok, %{"number" => "0x1"}}}) |
||||||
|
|
||||||
|
loop(%{subscription: subscription, timer_reference: timer_reference}) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
defp supervisor do |
||||||
|
case ExUnit.OnExitHandler.get_supervisor(self()) do |
||||||
|
{:ok, nil} -> |
||||||
|
{:ok, sup} = Supervisor.start_link([], strategy: :one_for_one, max_restarts: 1_000_000, max_seconds: 1) |
||||||
|
ExUnit.OnExitHandler.put_supervisor(self(), sup) |
||||||
|
sup |
||||||
|
|
||||||
|
{:ok, sup} -> |
||||||
|
sup |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,25 @@ |
|||||||
|
defmodule EthereumJSONRPC.WebSocket.Case.Parity do |
||||||
|
@moduledoc """ |
||||||
|
`EthereumJSONRPC.WebSocket.Case` connecting to Parity. |
||||||
|
""" |
||||||
|
|
||||||
|
import ExUnit.Callbacks, only: [start_supervised!: 1] |
||||||
|
|
||||||
|
def setup do |
||||||
|
url = "wss://sokol-ws.poa.network/ws" |
||||||
|
web_socket_module = EthereumJSONRPC.WebSocket.WebSocketClient |
||||||
|
web_socket = start_supervised!({web_socket_module, [url, []]}) |
||||||
|
|
||||||
|
%{ |
||||||
|
block_interval: 5_000, |
||||||
|
subscribe_named_arguments: [ |
||||||
|
transport: EthereumJSONRPC.WebSocket, |
||||||
|
transport_options: [ |
||||||
|
web_socket: web_socket_module, |
||||||
|
web_socket_options: %{web_socket: web_socket}, |
||||||
|
url: url |
||||||
|
] |
||||||
|
] |
||||||
|
} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,110 @@ |
|||||||
|
defmodule Indexer.BlockFetcher.Catchup.Supervisor do |
||||||
|
@moduledoc """ |
||||||
|
Supervises the `Indexer.BlockerFetcher.Catchup` with exponential backoff for restarts. |
||||||
|
""" |
||||||
|
|
||||||
|
# NOT a `Supervisor` because of the `Task` restart strategies are custom. |
||||||
|
use GenServer |
||||||
|
|
||||||
|
require Logger |
||||||
|
|
||||||
|
alias Indexer.{BlockFetcher, BoundInterval} |
||||||
|
alias Indexer.BlockFetcher.Catchup |
||||||
|
|
||||||
|
# milliseconds |
||||||
|
@block_interval 5_000 |
||||||
|
|
||||||
|
@enforce_keys ~w(bound_interval catchup)a |
||||||
|
defstruct bound_interval: nil, |
||||||
|
catchup: %Catchup{}, |
||||||
|
task: nil |
||||||
|
|
||||||
|
def child_spec(arg) do |
||||||
|
# The `child_spec` from `use Supervisor` because the one from `use GenServer` will set the `type` to `:worker` |
||||||
|
# instead of `:supervisor` and use the wrong shutdown timeout |
||||||
|
Supervisor.child_spec(%{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}, type: :supervisor}, []) |
||||||
|
end |
||||||
|
|
||||||
|
@doc """ |
||||||
|
Starts supervisor of `Indexer.BlockerFetcher.Catchup` and `Indexer.BlockFetcher.Realtime`. |
||||||
|
|
||||||
|
For `named_arguments` see `Indexer.BlockFetcher.new/1`. For `t:GenServer.options/0` see `GenServer.start_link/3`. |
||||||
|
""" |
||||||
|
@spec start_link([named_arguments :: list() | GenServer.options()]) :: {:ok, pid} |
||||||
|
def start_link([named_arguments, gen_server_options]) when is_map(named_arguments) and is_list(gen_server_options) do |
||||||
|
GenServer.start_link(__MODULE__, named_arguments, gen_server_options) |
||||||
|
end |
||||||
|
|
||||||
|
@impl GenServer |
||||||
|
def init(named_arguments) do |
||||||
|
state = new(named_arguments) |
||||||
|
|
||||||
|
send(self(), :catchup_index) |
||||||
|
|
||||||
|
{:ok, state} |
||||||
|
end |
||||||
|
|
||||||
|
defp new(%{block_fetcher: common_block_fetcher} = named_arguments) do |
||||||
|
block_fetcher = %BlockFetcher{common_block_fetcher | broadcast: false, callback_module: Catchup} |
||||||
|
|
||||||
|
block_interval = Map.get(named_arguments, :block_interval, @block_interval) |
||||||
|
minimum_interval = div(block_interval, 2) |
||||||
|
bound_interval = BoundInterval.within(minimum_interval..(minimum_interval * 10)) |
||||||
|
|
||||||
|
%__MODULE__{ |
||||||
|
catchup: %Catchup{block_fetcher: block_fetcher}, |
||||||
|
bound_interval: bound_interval |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
@impl GenServer |
||||||
|
def handle_info(:catchup_index, %__MODULE__{catchup: %Catchup{} = catchup} = state) do |
||||||
|
{:noreply, |
||||||
|
%__MODULE__{state | task: Task.Supervisor.async_nolink(Indexer.TaskSupervisor, Catchup, :task, [catchup])}} |
||||||
|
end |
||||||
|
|
||||||
|
def handle_info( |
||||||
|
{ref, %{first_block_number: first_block_number, missing_block_count: missing_block_count}}, |
||||||
|
%__MODULE__{ |
||||||
|
bound_interval: bound_interval, |
||||||
|
task: %Task{ref: ref} |
||||||
|
} = state |
||||||
|
) |
||||||
|
when is_integer(missing_block_count) do |
||||||
|
new_bound_interval = |
||||||
|
case missing_block_count do |
||||||
|
0 -> |
||||||
|
Logger.info("Index already caught up in #{first_block_number}-0") |
||||||
|
|
||||||
|
BoundInterval.increase(bound_interval) |
||||||
|
|
||||||
|
_ -> |
||||||
|
Logger.info("Index had to catch up #{missing_block_count} blocks in #{first_block_number}-0") |
||||||
|
|
||||||
|
BoundInterval.decrease(bound_interval) |
||||||
|
end |
||||||
|
|
||||||
|
Process.demonitor(ref, [:flush]) |
||||||
|
|
||||||
|
interval = new_bound_interval.current |
||||||
|
|
||||||
|
Logger.info(fn -> |
||||||
|
"Checking if index needs to catch up in #{interval}ms" |
||||||
|
end) |
||||||
|
|
||||||
|
Process.send_after(self(), :catchup_index, interval) |
||||||
|
|
||||||
|
{:noreply, %__MODULE__{state | bound_interval: new_bound_interval, task: nil}} |
||||||
|
end |
||||||
|
|
||||||
|
def handle_info( |
||||||
|
{:DOWN, ref, :process, pid, reason}, |
||||||
|
%__MODULE__{task: %Task{pid: pid, ref: ref}} = state |
||||||
|
) do |
||||||
|
Logger.error(fn -> "Catchup index stream exited with reason (#{inspect(reason)}). Restarting" end) |
||||||
|
|
||||||
|
send(self(), :catchup_index) |
||||||
|
|
||||||
|
{:noreply, %__MODULE__{state | task: nil}} |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,37 @@ |
|||||||
|
defmodule Indexer.BlockFetcher.Realtime.Supervisor do |
||||||
|
@moduledoc """ |
||||||
|
Supervises realtime block fetcher. |
||||||
|
""" |
||||||
|
|
||||||
|
use Supervisor |
||||||
|
|
||||||
|
def start_link([arguments, gen_server_options]) do |
||||||
|
Supervisor.start_link(__MODULE__, arguments, gen_server_options) |
||||||
|
end |
||||||
|
|
||||||
|
@impl Supervisor |
||||||
|
def init(%{block_fetcher: block_fetcher, subscribe_named_arguments: subscribe_named_arguments}) do |
||||||
|
children = |
||||||
|
case Keyword.fetch!(subscribe_named_arguments, :transport) do |
||||||
|
EthereumJSONRPC.WebSocket -> |
||||||
|
transport_options = Keyword.fetch!(subscribe_named_arguments, :transport_options) |
||||||
|
url = Keyword.fetch!(transport_options, :url) |
||||||
|
web_socket_module = Keyword.fetch!(transport_options, :web_socket) |
||||||
|
web_socket = Indexer.BlockFetcher.Realtime.WebSocket |
||||||
|
|
||||||
|
block_fetcher_subscribe_named_arguments = |
||||||
|
put_in(subscribe_named_arguments[:transport_options][:web_socket_options], %{web_socket: web_socket}) |
||||||
|
|
||||||
|
[ |
||||||
|
{web_socket_module, [url, [name: web_socket]]}, |
||||||
|
{Indexer.BlockFetcher.Realtime, |
||||||
|
[ |
||||||
|
%{block_fetcher: block_fetcher, subscribe_named_arguments: block_fetcher_subscribe_named_arguments}, |
||||||
|
[name: Indexer.BlockFetcher.Realtime] |
||||||
|
]} |
||||||
|
] |
||||||
|
end |
||||||
|
|
||||||
|
Supervisor.init(children, strategy: :rest_for_one) |
||||||
|
end |
||||||
|
end |
@ -1,84 +1,35 @@ |
|||||||
defmodule Indexer.BlockFetcher.Supervisor do |
defmodule Indexer.BlockFetcher.Supervisor do |
||||||
@moduledoc """ |
@moduledoc """ |
||||||
Supervises the `Indexer.BlockerFetcher.Catchup` and `Indexer.BlockFetcher.Realtime`. |
Supervises catchup and realtime block fetchers |
||||||
""" |
""" |
||||||
|
|
||||||
# NOT a `Supervisor` because of the `Task` restart strategies are custom. |
|
||||||
use GenServer |
|
||||||
|
|
||||||
require Logger |
|
||||||
|
|
||||||
alias Indexer.BlockFetcher |
alias Indexer.BlockFetcher |
||||||
alias Indexer.BlockFetcher.{Catchup, Realtime} |
alias Indexer.BlockFetcher.{Catchup, Realtime} |
||||||
|
|
||||||
# milliseconds |
use Supervisor |
||||||
@block_interval 5_000 |
|
||||||
|
def start_link([arguments, gen_server_options]) do |
||||||
@enforce_keys ~w(catchup realtime)a |
Supervisor.start_link(__MODULE__, arguments, gen_server_options) |
||||||
defstruct ~w(catchup realtime)a |
end |
||||||
|
|
||||||
def child_spec(arg) do |
@impl Supervisor |
||||||
# The `child_spec` from `use Supervisor` because the one from `use GenServer` will set the `type` to `:worker` |
def init(%{block_interval: block_interval, subscribe_named_arguments: subscribe_named_arguments} = named_arguments) do |
||||||
# instead of `:supervisor` and use the wrong shutdown timeout |
block_fetcher = |
||||||
Supervisor.child_spec(%{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}, type: :supervisor}, []) |
named_arguments |
||||||
end |
|> Map.drop(~w(block_interval subscribe_named_arguments)a) |
||||||
|
|> BlockFetcher.new() |
||||||
@doc """ |
|
||||||
Starts supervisor of `Indexer.BlockerFetcher.Catchup` and `Indexer.BlockFetcher.Realtime`. |
Supervisor.init( |
||||||
|
[ |
||||||
For `named_arguments` see `Indexer.BlockFetcher.new/1`. For `t:GenServer.options/0` see `GenServer.start_link/3`. |
{Catchup.Supervisor, |
||||||
""" |
[%{block_fetcher: block_fetcher, block_interval: block_interval}, [name: Catchup.Supervisor]]}, |
||||||
@spec start_link([named_arguments :: list() | GenServer.options()]) :: {:ok, pid} |
{Realtime.Supervisor, |
||||||
def start_link([named_arguments, gen_server_options]) when is_list(named_arguments) and is_list(gen_server_options) do |
[ |
||||||
GenServer.start_link(__MODULE__, named_arguments, gen_server_options) |
%{block_fetcher: block_fetcher, subscribe_named_arguments: subscribe_named_arguments}, |
||||||
end |
[name: Realtime.Supervisor] |
||||||
|
]} |
||||||
@impl GenServer |
], |
||||||
def init(named_arguments) do |
strategy: :one_for_one |
||||||
state = new(named_arguments) |
) |
||||||
|
|
||||||
send(self(), :catchup_index) |
|
||||||
{:ok, _} = :timer.send_interval(state.realtime.interval, :realtime_index) |
|
||||||
|
|
||||||
{:ok, state} |
|
||||||
end |
|
||||||
|
|
||||||
defp new(named_arguments) do |
|
||||||
{given_block_interval, block_fetcher_named_arguments} = Keyword.pop(named_arguments, :block_interval) |
|
||||||
block_fetcher = struct!(BlockFetcher, block_fetcher_named_arguments) |
|
||||||
block_interval = given_block_interval || @block_interval |
|
||||||
|
|
||||||
%__MODULE__{ |
|
||||||
catchup: Catchup.new(%{block_fetcher: block_fetcher, block_interval: block_interval}), |
|
||||||
realtime: Realtime.new(%{block_fetcher: block_fetcher, block_interval: block_interval}) |
|
||||||
} |
|
||||||
end |
|
||||||
|
|
||||||
@impl GenServer |
|
||||||
def handle_info(:catchup_index, %__MODULE__{} = state) do |
|
||||||
{:noreply, Catchup.put(state)} |
|
||||||
end |
|
||||||
|
|
||||||
def handle_info({ref, _} = message, %__MODULE__{catchup: %Catchup{task: %Task{ref: ref}}} = state) do |
|
||||||
{:noreply, Catchup.handle_success(message, state)} |
|
||||||
end |
|
||||||
|
|
||||||
def handle_info( |
|
||||||
{:DOWN, ref, :process, pid, _} = message, |
|
||||||
%__MODULE__{catchup: %Catchup{task: %Task{pid: pid, ref: ref}}} = state |
|
||||||
) do |
|
||||||
{:noreply, Catchup.handle_failure(message, state)} |
|
||||||
end |
|
||||||
|
|
||||||
def handle_info(:realtime_index, %__MODULE__{} = state) do |
|
||||||
{:noreply, Realtime.put(state)} |
|
||||||
end |
|
||||||
|
|
||||||
def handle_info({ref, :ok} = message, %__MODULE__{} = state) when is_reference(ref) do |
|
||||||
{:noreply, Realtime.handle_success(message, state)} |
|
||||||
end |
|
||||||
|
|
||||||
def handle_info({:DOWN, _, :process, _, _} = message, %__MODULE__{} = state) do |
|
||||||
{:noreply, Realtime.handle_failure(message, state)} |
|
||||||
end |
end |
||||||
end |
end |
||||||
|
Loading…
Reference in new issue