diff --git a/src/hz.erl b/src/hz.erl index b240c2b..b081f5d 100644 --- a/src/hz.erl +++ b/src/hz.erl @@ -9,7 +9,7 @@ %%% %%% The get/set admin functions are for setting or checking things like the Gajumaru %%% "network ID" and list of addresses of nodes you want to use for answering -%%% queries to the blockchain. +%%% queries to the blockchain. Get functions are arity 0, and set functions are arity 1. %%% %%% The JSON query interface functions are the blockchain query functions themselves %%% which are translated to network queries and return Erlang messages as responses. @@ -18,8 +18,8 @@ %%% a desired call to a smart contract on the chain to call data serialized in a form %%% that a Gajumaru compatible wallet or library can sign and submit to a Gajumaru node. %%% -%%% This module does not implement the OTP application behavior. -%%% helper functions. +%%% NOTE: +%%% This module does not implement the OTP application behavior. Refer to hakuzaru.erl. %%% @end -module(hz). @@ -225,7 +225,7 @@ NetworkID :: string(), Reason :: term(). %% @doc -%% Returns the network ID or the atom `none' if it is unset. +%% Returns the network ID or the atom `none' if unavailable. %% Checking this is not normally necessary, but if network ID assignment is dynamic %% in your system it may be necessary to call this before attempting to form %% call data or perform other actions on chain that require a signature. @@ -241,7 +241,9 @@ network_id() -> %% @doc %% Returns the list of currently assigned nodes. %% The normal reason to call this is in preparation for altering the nodes list or -%% checking the current list in debugging. +%% checking the current list in debugging. Note that the first node in the list is +%% the "sticky" node: the one that will be used for submitting transactions and +%% querying `next_nonce'. chain_nodes() -> hz_man:chain_nodes(). @@ -251,19 +253,26 @@ chain_nodes() -> when List :: [chain_node()], Reason :: {invalid, [term()]}. %% @doc -%% Sets the nodes that are intended to be used as your interface to the peer -%% network. The common situation is that your project runs a non-mining node as -%% part of your backend infrastructure. Typically one or two nodes is plenty, but -%% this may need to expand depending on how much query load your application generates. -%% The Hakuzaru manager will load balance by round-robin distribution. +%% Sets the chain nodes that will be queried whenever you communicate with the chain. %% -%% NOTE: When load balancing in this way be aware that there can be race conditions -%% among the backend nodes with regard to a single account's current nonce when performing -%% contract calls in quick succession. Round robin distribution is extremely useful when -%% performing rapid lookups to the chain, but does not work well when submitting many -%% transactions to the chain from a single user in a short period of time. A future version -%% of this library will allow the caller to designate a single node as "sticky" to be used -%% exclusively in the case of nonce reads and TX submissions. +%% The common situation is that a project runs a non-mining node as part of the backend +%% infrastructure. Typically one or two nodes is plenty, but this may need to expand +%% depending on how much query load your application generates. +%% +%% There are two situations: one node, or multiple nodes. +%% +%% Single node: +%% In the case of a single node, everything passes through that one node. Duh. +%% +%% Multiple nodes: +%% In the case of multiple nodes a distinction is made between the node to which +%% transactions that update the chain state are made and to which `next_nonce' queries +%% are made, and nodes that are used for read-only queries. The node to which stateful +%% transactions are submitted is called the "sticky node". This is the first node +%% (head position) in the list of nodes submitted to the chain when `chain_nodes/1' +%% is called. If using multiple nodes but the sticky node should also be used for +%% read-only queries, submit the sticky node at the head of the list and again in +%% the tail. chain_nodes(List) when is_list(List) -> hz_man:chain_nodes(List). @@ -271,7 +280,16 @@ chain_nodes(List) when is_list(List) -> -spec tls() -> boolean(). %% @doc -%% Check whether TLS is in use. +%% Check whether TLS is in use. The typical situation is to not use TLS as nodes that +%% serve as part of the backend of an application are typically run in the same +%% backend network as the application service. When accessing chain nodes over the WAN +%% however, TLS is strongly recommended to avoid a MITM attack. +%% +%% In this version of Hakuzaru TLS is either on or off for all nodes, making a mixed +%% infrastructure complicated to support without two Hakuzaru instances. This will +%% likely become a per-node setting in the future. +%% +%% TLS defaults to `false'. tls() -> hz_man:tls(). @@ -281,6 +299,8 @@ tls() -> %% @doc %% Set TLS true or false. That's what a boolean is, by the way, `true' or `false'. %% This is a condescending comment. That means I am talking down to you. +%% +%% TLS defaults to `false'. tls(Boolean) -> hz_man:tls(Boolean). @@ -291,6 +311,8 @@ tls(Boolean) -> when Timeout :: pos_integer() | infinity. %% @doc %% Returns the current request timeout setting in milliseconds. +%% The default timeout is 5,000ms. +%% The max timeout is 120,000ms. timeout() -> hz_man:timeout(). @@ -300,6 +322,8 @@ timeout() -> when MS :: pos_integer() | infinity. %% @doc %% Sets the request timeout in milliseconds. +%% The default timeout is 5,000ms. +%% The max timeout is 120,000ms. timeout(MS) -> hz_man:timeout(MS). @@ -576,18 +600,18 @@ acc_pending_txs(AccountID) -> %% Retrieve the next nonce for the given account next_nonce(AccountID) -> -% case request(["/v3/accounts/", AccountID, "/next-nonce"]) of -% {ok, #{"next_nonce" := Nonce}} -> {ok, Nonce}; -% {ok, #{"reason" := "Account not found"}} -> {ok, 1}; -% {ok, #{"reason" := Reason}} -> {error, Reason}; -% Error -> Error -% end. - case request(["/v3/accounts/", AccountID]) of - {ok, #{"nonce" := Nonce}} -> {ok, Nonce + 1}; + case request_sticky(["/v3/accounts/", AccountID, "/next-nonce"]) of + {ok, #{"next_nonce" := Nonce}} -> {ok, Nonce}; {ok, #{"reason" := "Account not found"}} -> {ok, 1}; {ok, #{"reason" := Reason}} -> {error, Reason}; Error -> Error end. +% case request_sticky(["/v3/accounts/", AccountID]) of +% {ok, #{"nonce" := Nonce}} -> {ok, Nonce + 1}; +% {ok, #{"reason" := "Account not found"}} -> {ok, 1}; +% {ok, #{"reason" := Reason}} -> {error, Reason}; +% Error -> Error +% end. -spec dry_run(TX) -> {ok, Result} | {error, Reason} @@ -729,7 +753,7 @@ tx_info(ID) -> post_tx(Data) when is_binary(Data) -> JSON = zj:binary_encode(#{tx => Data}), - request("/v3/transactions", JSON); + request_sticky("/v3/transactions", JSON); post_tx(Data) when is_list(Data) -> post_tx(list_to_binary(Data)). @@ -841,6 +865,14 @@ status_chainends() -> request("/v3/status/chain-ends"). +request_sticky(Path) -> + hz_man:request_sticky(unicode:characters_to_list(Path)). + + +request_sticky(Path, Payload) -> + hz_man:request_sticky(unicode:characters_to_list(Path), Payload). + + request(Path) -> hz_man:request(unicode:characters_to_list(Path)). diff --git a/src/hz_fetcher.erl b/src/hz_fetcher.erl index 3e15d19..7e713a7 100644 --- a/src/hz_fetcher.erl +++ b/src/hz_fetcher.erl @@ -4,7 +4,7 @@ -copyright("Craig Everett "). -license("MIT"). --export([connect/4, slowly_connect/4]). +-export([connect/4, connect_slowly/4]). connect(Node = {Host, Port}, Request, From, Timeout) -> @@ -206,7 +206,7 @@ read_hval(_, Received, _, _, _) -> {error, headers}. -slowly_connect(Node, {get, Path}, From, Timeout) -> +connect_slowly(Node, {get, Path}, From, Timeout) -> HttpOptions = [{connect_timeout, 3000}, {timeout, Timeout}], URL = lists:flatten(url(Node, Path)), Request = {URL, []}, @@ -217,7 +217,7 @@ slowly_connect(Node, {get, Path}, From, Timeout) -> BAD -> {error, BAD} end, gen_server:reply(From, Result); -slowly_connect(Node, {post, Path, Payload}, From, Timeout) -> +connect_slowly(Node, {post, Path, Payload}, From, Timeout) -> HttpOptions = [{connect_timeout, 3000}, {timeout, Timeout}], URL = lists:flatten(url(Node, Path)), Request = {URL, [], "application/json", Payload}, diff --git a/src/hz_man.erl b/src/hz_man.erl index 8f7ae58..18248ff 100644 --- a/src/hz_man.erl +++ b/src/hz_man.erl @@ -21,7 +21,7 @@ timeout/0, timeout/1]). %% The whole point of this module: --export([request/1, request/2]). +-export([request_sticky/1, request_sticky/2, request/1, request/2]). %% gen_server goo -export([start_link/0]). @@ -40,11 +40,11 @@ req = none :: none | binary()}). -record(s, - {tls = false :: boolean(), - chain_nodes = {[], []} :: {[hz:chain_node()], [hz:chain_node()]}, - sticky = none :: none | hz:chain_node(), - fetchers = [] :: [#fetcher{}], - timeout = 5000 :: pos_integer()}). + {tls = false :: boolean(), + chain_nodes = {[], []} :: {[hz:chain_node()], [hz:chain_node()]}, + sticky = none :: none | hz:chain_node(), + fetchers = [] :: [#fetcher{}], + timeout = 5000 :: pos_integer()}). -type state() :: #s{}. @@ -94,6 +94,25 @@ timeout(Value) when 0 < Value, Value =< 120000 -> gen_server:cast(?MODULE, {timeout, Value}). +-spec request_sticky(Path) -> {ok, Value} | {error, Reason} + when Path :: unicode:charlist(), + Value :: map(), + Reason :: hz:chain_error(). + +request_sticky(Path) -> + gen_server:call(?MODULE, {request_sticky, {get, Path}}, infinity). + + +-spec request_sticky(Path, Data) -> {ok, Value} | {error, Reason} + when Path :: unicode:charlist(), + Data :: unicode:charlist(), + Value :: map(), + Reason :: hz:chain_error(). + +request_sticky(Path, Data) -> + gen_server:call(?MODULE, {request_sticky, {post, Path, Data}}, infinity). + + -spec request(Path) -> {ok, Value} | {error, Reason} when Path :: unicode:charlist(), Value :: map(), @@ -145,10 +164,13 @@ init(none) -> handle_call({request, Request}, From, State) -> NewState = do_request(Request, From, State), {noreply, NewState}; +handle_call({request_sticky, Request}, From, State) -> + NewState = do_request_sticky(Request, From, State), + {noreply, NewState}; handle_call(tls, _, State = #s{tls = TLS}) -> {reply, TLS, State}; -handle_call(chain_nodes, _, State = #s{chain_nodes = {Wait, Used}}) -> - Nodes = lists:append(Wait, Used), +handle_call(chain_nodes, _, State) -> + Nodes = do_chain_nodes(State), {reply, Nodes, State}; handle_call(timeout, _, State = #s{timeout = Value}) -> {reply, Value, State}; @@ -160,10 +182,9 @@ handle_call(Unexpected, From, State) -> handle_cast({tls, Boolean}, State) -> NewState = do_tls(Boolean, State), {noreply, NewState}; -handle_cast({chain_nodes, []}, State) -> - {noreply, State#s{chain_nodes = {[], []}}}; -handle_cast({chain_nodes, ToUse}, State) -> - {noreply, State#s{chain_nodes = {ToUse, []}}}; +handle_cast({chain_nodes, List}, State) -> + NewState = do_chain_nodes(List, State), + {noreply, NewState}; handle_cast({timeout, Value}, State) -> {noreply, State#s{timeout = Value}}; handle_cast(Unexpected, State) -> @@ -218,6 +239,23 @@ terminate(_, _) -> %%% Doer Functions +do_chain_nodes(#s{sticky = none, chain_nodes = {Wait, Used}}) -> + lists:append(Wait, Used); +do_chain_nodes(#s{sticky = Sticky, chain_nodes = {Wait, Used}}) -> + case lists:append(Wait, Used) of + [Sticky] -> [Sticky]; + Nodes -> [Sticky | Nodes] + end. + + +do_chain_nodes([], State) -> + State#s{sticky = none, chain_nodes = {[], []}}; +do_chain_nodes(List = [Sticky], State) -> + State#s{sticky = Sticky, chain_nodes = {List, []}}; +do_chain_nodes([Sticky | List], State) -> + State#s{sticky = Sticky, chain_nodes = {List, []}}. + + do_tls(true, State) -> ok = ssl:start(), State#s{tls = true}; @@ -227,17 +265,21 @@ do_tls(_, State) -> State. -do_request(_, From, State = #s{chain_nodes = {[], []}}) -> +do_request_sticky(_, From, State = #s{sticky = none}) -> ok = gen_server:reply(From, {error, no_nodes}), State; -do_request(Request, - From, - State = #s{tls = false, - fetchers = Fetchers, - chain_nodes = {[Node | Rest], Used}, - timeout = Timeout}) -> +do_request_sticky(Request, + From, + State = #s{tls = TLS, + fetchers = Fetchers, + sticky = Node, + timeout = Timeout}) -> Now = erlang:system_time(nanosecond), - Fetcher = fun() -> hz_fetcher:connect(Node, Request, From, Timeout) end, + Fetcher = + case TLS of + true -> fun() -> hz_fetcher:connect_slowly(Node, Request, From, Timeout) end; + false -> fun() -> hz_fetcher:connect(Node, Request, From, Timeout) end + end, {PID, Mon} = spawn_monitor(Fetcher), New = #fetcher{pid = PID, mon = Mon, @@ -245,15 +287,24 @@ do_request(Request, node = Node, from = From, req = Request}, - State#s{fetchers = [New | Fetchers], chain_nodes = {Rest, [Node | Used]}}; + State#s{fetchers = [New | Fetchers]}. + + +do_request(_, From, State = #s{chain_nodes = {[], []}}) -> + ok = gen_server:reply(From, {error, no_nodes}), + State; do_request(Request, From, - State = #s{tls = true, + State = #s{tls = TLS, fetchers = Fetchers, chain_nodes = {[Node | Rest], Used}, timeout = Timeout}) -> Now = erlang:system_time(nanosecond), - Fetcher = fun() -> hz_fetcher:slowly_connect(Node, Request, From, Timeout) end, + Fetcher = + case TLS of + true -> fun() -> hz_fetcher:connect_slowly(Node, Request, From, Timeout) end; + false -> fun() -> hz_fetcher:connect(Node, Request, From, Timeout) end + end, {PID, Mon} = spawn_monitor(Fetcher), New = #fetcher{pid = PID, mon = Mon,