diff --git a/src/dev_arweave.erl b/src/dev_arweave.erl index 87c87ac35..39a74dd72 100644 --- a/src/dev_arweave.erl +++ b/src/dev_arweave.erl @@ -6,7 +6,9 @@ -module(dev_arweave). -export([tx/3, raw/3, chunk/3, block/3, current/3, status/3, price/3, tx_anchor/3]). -export([post_tx/3, post_tx/4, post_binary_ans104/2, post_json_chunk/2]). +-export([is_tx_admissible/3]). -include("include/hb.hrl"). +-include("include/hb_arweave_nodes.hrl"). -include_lib("eunit/include/eunit.hrl"). -define(IS_BLOCK_ID(X), (is_binary(X) andalso byte_size(X) == 64)). @@ -133,6 +135,14 @@ get_tx(Base, Request, Opts) -> request( <<"GET">>, <<"/tx/", TXID/binary>>, + #{ + <<"multirequest-admissible">> => + #{ + <<"device">> => <<"arweave@2.9">>, + <<"path">> => <<"is-tx-admissible">>, + <<"tx">> => TXID + } + }, Opts#{ exclude_data => hb_util:bool( @@ -147,6 +157,20 @@ get_tx(Base, Request, Opts) -> ) end. +%% @doc Check whether a response to a `GET /tx/ID' request is valid. +%% Verifies that the requested TXID exists as a commitment ID in the +%% response message, and that all commitments are cryptographically valid. +is_tx_admissible(Base, Request, Opts) -> + maybe + {ok, TXID} ?= hb_maps:find(<<"tx">>, Base, Opts), + CommIDs = maps:keys(maps:get(<<"commitments">>, Request, #{})), + true ?= + lists:member(TXID, CommIDs) andalso + hb_message:verify(Request, all, Opts) + else + _ -> false + end. + %% @doc A router for range requests by method. Both `HEAD` and `GET` requests %% are supported. raw(Base, Request, Opts) -> @@ -741,7 +765,29 @@ request(Method, Path, Extra, LogExtra, Opts) -> cache_control => [<<"no-cache">>, <<"no-store">>] } ), - to_message(Path, Method, best_response(Res), LogExtra, Opts). + case to_message(Path, Method, best_response(Res), LogExtra, Opts) of + {ok, Msg} -> + case hb_maps:get( + <<"multirequest-admissible">>, Extra, undefined, Opts + ) of + undefined -> {ok, Msg}; + Admissible -> + case is_tx_admissible(Admissible, Msg, Opts) of + true -> + case dev_hook:on( + <<"tx-admissible">>, + #{ <<"body">> => Msg }, + Opts + ) of + {ok, #{ <<"body">> := ResultMsg }} -> + {ok, ResultMsg}; + {error, Reason} -> {error, Reason} + end; + false -> {error, not_admissible} + end + end; + Error -> Error + end. %% @doc Select the best response from a list of responses by sorting them %% ascending by HTTP status code. Returns the first (best) response tuple. @@ -814,6 +860,10 @@ to_message(Path = <<"/tx/", TXID/binary>>, <<"GET">>, {ok, #{ <<"body">> := Body Error end end; +to_message(Path = <<"/tx/", _/binary>>, <<"GET">>, {ok, Msg}, LogExtra, _Opts) -> + %% Response from a routed HyperBEAM node: already a decoded message. + event_request(Path, <<"GET">>, 200, LogExtra), + {ok, Msg}; to_message(Path = <<"/raw/", _/binary>>, <<"GET">>, {ok, #{ <<"body">> := Body }}, LogExtra, _Opts) -> event_request(Path, <<"GET">>, 200, LogExtra), {ok, Body}; @@ -2013,3 +2063,192 @@ assert_chunk_range(Type, ID, StartOffset, ExpectedLength, ExpectedHash, Opts) -> ?event(debug_test, {data, {explicit, hb_util:encode(crypto:hash(sha256, Data))}}), ?assertEqual(ExpectedHash, hb_util:encode(crypto:hash(sha256, Data))), ok. + +is_admissible_routed_test() -> + AliceWallet = ar_wallet:new(), + BobWallet = ar_wallet:new(), + AliceNode = hb_http_server:start_node(#{ priv_wallet => AliceWallet }), + BobNode = hb_http_server:start_node(#{ priv_wallet => BobWallet }), + AliceNodeOpts = hb_http_server:get_opts(#{ + http_server => hb_util:human_id(ar_wallet:to_address(AliceWallet)) + }), + BobNodeOpts = hb_http_server:get_opts(#{ + http_server => hb_util:human_id(ar_wallet:to_address(BobWallet)) + }), + AliceMsg = + hb_message:commit(#{ + <<"a">> => 1 }, + AliceNodeOpts, + <<"ans104@1.0">> + ), + {ok, AliceMsgRawID} = hb_cache:write(AliceMsg, AliceNodeOpts), + AliceMsgID = hb_util:human_id(AliceMsgRawID), + BobMsg = + hb_message:commit(#{ + <<"b">> => 1 }, + BobNodeOpts, + <<"ans104@1.0">> + ), + {ok, BobMsgRawID} = hb_cache:write(BobMsg, BobNodeOpts), + BobMsgID = hb_util:human_id(BobMsgRawID), + %% Start RoutingNode with routes to both AliceNode and BobNode. + RoutingNode = hb_http_server:start_node(#{ + priv_wallet => ar_wallet:new(), + routes => [ + #{ + <<"template">> => <<"^/arweave/tx">>, + <<"strategy">> => <<"Random">>, + <<"choose">> => 2, + <<"parallel">> => true, + <<"nodes">> => + [ + #{ + <<"match">> => <<"/arweave/tx/">>, + <<"with">> => AliceNode + }, + #{ + <<"match">> => <<"/arweave/tx/">>, + <<"with">> => BobNode + } + ] + } + ] + }), + %% Fetch Alice's and Bob's messages via RoutingNode with admissibility check. + {ok, AliceRes} = + hb_http:get(RoutingNode, <<"~arweave@2.9/tx=", AliceMsgID/binary>>, #{}), + ?assertMatch(#{ <<"a">> := 1 }, AliceRes), + {ok, BobRes} = + hb_http:get(RoutingNode, <<"~arweave@2.9/tx=", BobMsgID/binary>>, #{}), + ?assertMatch(#{ <<"b">> := 1 }, BobRes), + ok. + +is_admissible_hook_routed_test_() -> + {timeout, 60, fun() -> + application:ensure_all_started(hb), + TXID = <<"ptBC0UwDmrUTBQX3MqZ1lB57ex20ygwzkjjCrQjIx3o">>, + PerfProcess = <<"/perf-router~node-process@1.0">>, + SchedulePath = <>, + RoutesPath = <>, + NodeWallet = ar_wallet:new(), + NodeAddr = hb_util:human_id(NodeWallet), + Opts = #{ + store => hb_test_utils:test_store(), + priv_wallet => NodeWallet, + http_monitor => #{ + <<"method">> => <<"POST">>, + <<"path">> => SchedulePath + }, + router_opts => #{ <<"provider">> => #{ <<"path">> => RoutesPath } }, + node_processes => #{ + <<"perf-router">> => #{ + <<"device">> => <<"process@1.0">>, + <<"execution-device">> => <<"router-perf@1.0">>, + <<"scheduler-device">> => <<"scheduler@1.0">>, + <<"performance-period">> => 2, + <<"initial-performance">> => 1000 + } + }, + routes => [ + #{ + <<"template">> => <<"^/arweave">>, + <<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES, + <<"parallel">> => true, + <<"admissible-status">> => 200 + } + ] + }, + Node = hb_http_server:start_node(Opts), + %% Register gateways with the perf-router process. + RouteConfig = #{ + <<"template">> => <<"^/arweave">>, + <<"parallel">> => true, + <<"strategy">> => <<"Random">>, + <<"choose">> => 10, + <<"admissible-status">> => 200 + }, + lists:foreach( + fun(GatewayNode) -> + Body = + hb_message:commit( + #{ + <<"action">> => <<"register">>, + <<"route">> => maps:merge(GatewayNode, RouteConfig) + }, + Opts + ), + {ok, _} = + hb_http:post( + Node, + #{ + <<"path">> => SchedulePath, + <<"method">> => <<"POST">>, + <<"body">> => Body + }, + Opts + ) + end, + ?ARWEAVE_BOOTSTRAP_DATA_NODES + ), + %% Trigger compute to process register messages. + {ok, _} = hb_http:get(Node, RoutesPath, Opts), + %% Verify initial performance. + PerfPath = <>, + {ok, InitPerf} = hb_http:get(Node, PerfPath, Opts), + ?assertEqual(1000.0, dev_router_perf:to_float(InitPerf)), + %% Fetch TX through the full stack. + {ok, Res} = + hb_http:get( + Node, + <<"~arweave@2.9/tx=", TXID/binary, "&exclude-data=true">>, + Opts + ), + ?assertMatch(#{ <<"reward">> := <<"482143296">> }, Res), + ?assert(hb_message:verify(Res, all, #{})), + ?assertNot(lists:member(NodeAddr, hb_message:signers(Res, #{}))), + %% Wait for async monitor duration posts, then recompute. + timer:sleep(1000), + {ok, _} = hb_http:get(Node, RoutesPath, Opts), + %% Verify performance score changed from initial value. + {ok, UpdatedPerf} = hb_http:get(Node, PerfPath, Opts), + ?assertNotEqual(1000.0, dev_router_perf:to_float(UpdatedPerf)), + ok + end}. + +is_admissible_real_gateway_test_() -> + {timeout, 30, fun() -> + application:ensure_all_started(hb), + TXID = <<"ptBC0UwDmrUTBQX3MqZ1lB57ex20ygwzkjjCrQjIx3o">>, + Node = hb_http_server:start_node(#{ + priv_wallet => ar_wallet:new(), + routes => [ + #{ + <<"template">> => <<"^/arweave/tx">>, + <<"strategy">> => <<"Random">>, + <<"choose">> => 10, + <<"parallel">> => true, + <<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES + }, + #{ + <<"template">> => <<"^/arweave">>, + <<"nodes">> => ?ARWEAVE_BOOTSTRAP_CHAIN_NODES, + <<"parallel">> => true, + <<"admissible-status">> => 200 + } + ] + }), + {ok, Res} = hb_http:get( + Node, + <<"~arweave@2.9/tx=", TXID/binary, "&exclude-data=true">>, + #{} + ), + ?assertMatch(#{ <<"reward">> := <<"482143296">> }, Res), + ?assertMatch( + #{ <<"anchor">> := + <<"XTzaU2_m_hRYDLiXkcleOC4zf5MVTXIeFWBOsJSRrtEZ8kM6Oz7EKLhZY7fTAvKq">> + }, + Res + ), + ?assertMatch(#{ <<"content-type">> := <<"application/json">> }, Res), + ok + end}. \ No newline at end of file diff --git a/src/dev_router_perf.erl b/src/dev_router_perf.erl new file mode 100644 index 000000000..ab1d680ab --- /dev/null +++ b/src/dev_router_perf.erl @@ -0,0 +1,491 @@ +%%% @doc An Erlang execution device for `process@1.0' that provides +%%% performance-based routing. Replaces the Lua `dynamic-router.lua' for +%%% Arweave gateway routing, fixing two gaps: +%%% 1. Supports `match'/`with' route format (not just `prefix'/`price'/`topup'). +%%% 2. Handles monitor duration POSTs that lack an `action' field. +%%% +%%% Device state is stored in the Base message (standard HyperBEAM architecture). +%%% Configuration keys read from Base: +%%% - `performance-period': EMA smoothing period (default 1000). +%%% - `initial-performance': Starting perf score for new nodes (default 30000). +%%% - `sampling-rate': Fraction of random sampling (default 0.1). +%%% - `performance-weight': Weight factor for perf scoring (default 1). +%%% - `pricing-weight': Weight factor for price scoring (default 1). +%%% - `score-preference': Decay exponent for scoring (default 1). +-module(dev_router_perf). +-export([init/3, compute/3, snapshot/3, normalize/3]). +-export([duration/3, register/3, recalculate/3]). +%%% Helper API +-export([to_float/1]). +-include("include/hb.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +%% @doc Compute dispatcher for process@1.0 compatibility. +compute(Base, Req, Opts) -> + Body = hb_ao:get(<<"body">>, Req, Req, Opts), + %% TODO: THIS IS WIERD IS THERE A BETTER WAY??? + %% Falls back to `path' because hb_http_client:maybe_invoke_monitor + %% sends duration data with `path => <<"duration">>' (not `action'). + Action = + case hb_ao:get(<<"action">>, Body, not_found, Opts) of + not_found -> hb_ao:get(<<"path">>, Body, not_found, Opts); + A -> A + end, + case Action of + <<"register">> -> register(Base, Body, Opts); + <<"recalculate">> -> recalculate(Base, Body, Opts); + <<"duration">> -> duration(Base, Body, Opts); + _ -> + ?event({erroe_report, <<"Action not supported.">>}), + {ok, Base} + end. + +%% @doc Initialize the device state with defaults if not already set. +init(Base, _Req, Opts) -> + {ok, ensure_defaults(Base, Opts)}. + +%% @doc Return a snapshot of the execution device state for caching. +snapshot(Base, _Req, _Opts) -> + {ok, Base}. + +%% @doc Restore execution device state from a snapshot. +normalize(Base, _Req, _Opts) -> + {ok, Base}. + +%% @doc Update the performance score for a node identified by `reference'. +%% Uses exponential weighted average: +%% new_perf = current * (1 - 1/period) + duration * (1/period) +duration(RawBase, Req, Opts) -> + Base = ensure_defaults(RawBase, Opts), + Body = hb_ao:get(<<"body">>, Req, Req, Opts), + case hb_ao:get(<<"reference">>, Body, not_found, Opts) of + not_found -> {ok, Base}; + Reference -> + Duration = to_float(hb_ao:get(<<"duration">>, Body, Opts)), + Period = to_float( + hb_ao:get(<<"performance-period">>, Base, 1000, Opts) + ), + ChangeFactor = 1.0 / Period, + Routes = hb_ao:get(<<"routes">>, Base, [], Opts), + UpdatedRoutes = update_node_performance( + Routes, + Reference, + Duration, + ChangeFactor, + Opts + ), + {ok, hb_ao:set(Base, #{ <<"routes">> => UpdatedRoutes }, Opts)} + end. + +%% @doc Register a new node on a route. Supports both: +%% - `match'/`with' format +%% - `prefix'/`price'/`topup' format +%% Generates `http_reference' from the node's URL (with or prefix). +register(RawBase, Req, Opts) -> + Base = ensure_defaults(RawBase, Opts), + Body = hb_ao:get(<<"body">>, Req, Req, Opts), + Route = hb_ao:get(<<"route">>, Body, Opts), + Template = hb_ao:get(<<"template">>, Route, Opts), + Routes = hb_ao:get(<<"routes">>, Base, [], Opts), + InitialPerf = + to_float(hb_ao:get(<<"initial-performance">>, Base, 30000, Opts)), + HttpRef = node_url(Route, Opts), + Node = build_node(Route, HttpRef, InitialPerf, Opts), + RouteConfig = extract_route_config(Route, Opts), + {UpdatedRoutes, _} = + add_node_to_route(Routes, Template, Node, RouteConfig, Opts), + NewBase = hb_ao:set(Base, #{ <<"routes">> => UpdatedRoutes }, Opts), + recalculate(NewBase, Req, Opts). + +%% @doc Recompute weights for all nodes in all routes. +%% Weight = inverse performance -- lower ms = higher weight, normalized. +recalculate(RawBase, _Req, Opts) -> + Base = ensure_defaults(RawBase, Opts), + Routes = hb_ao:get(<<"routes">>, Base, [], Opts), + SamplingRate = to_float(hb_ao:get(<<"sampling-rate">>, Base, 0.1, Opts)), + PerfWeight = to_float(hb_ao:get(<<"performance-weight">>, Base, 1, Opts)), + PricingWeight = to_float(hb_ao:get(<<"pricing-weight">>, Base, 1, Opts)), + ScorePref = to_float(hb_ao:get(<<"score-preference">>, Base, 1, Opts)), + ScoringParams = #{ + sampling_rate => SamplingRate, + perf_weight => PerfWeight, + pricing_weight => PricingWeight, + score_pref => ScorePref + }, + UpdatedRoutes = lists:map( + fun(R) -> + Nodes = hb_ao:get(<<"nodes">>, R, [], Opts), + recalculate_route(R, ScoringParams, Opts, Nodes) + end, + Routes + ), + {ok, hb_ao:set(Base, #{ <<"routes">> => UpdatedRoutes }, Opts)}. + +%%% Internal functions + +-define( + ROUTE_CONFIG_KEYS, + [ + <<"strategy">>, + <<"parallel">>, + <<"choose">>, + <<"admissible-status">> + ] +). + +%% @doc Extract route-level configuration keys from a registration message. +extract_route_config(Route, Opts) -> + lists:foldl( + fun(Key, Acc) -> + case hb_ao:get(Key, Route, not_found, Opts) of + not_found -> Acc; + Value -> Acc#{ Key => Value } + end + end, + #{}, + ?ROUTE_CONFIG_KEYS + ). + +ensure_defaults(Base, Opts) -> + Defaults = [ + {<<"routes">>, []}, + {<<"sampling-rate">>, 0.1}, + {<<"pricing-weight">>, 1}, + {<<"performance-weight">>, 1}, + {<<"score-preference">>, 1}, + {<<"performance-period">>, 1000}, + {<<"initial-performance">>, 30000} + ], + lists:foldl( + fun({Key, Default}, Acc) -> + case hb_ao:get(Key, Acc, not_found, Opts) of + not_found -> hb_ao:set(Acc, #{Key => Default}, Opts); + _ -> Acc + end + end, + Base, + Defaults + ). + + +node_url(Node, Opts) -> + case hb_ao:get(<<"with">>, Node, not_found, Opts) of + not_found -> hb_ao:get(<<"prefix">>, Node, <<"unknown">>, Opts); + With -> With + end. + +%% @doc Build a node map from a registration route. +%% `http_reference' is stored at the top level AND in opts. +build_node(Route, HttpRef, InitialPerf, Opts) -> + ExistingNodeOpts = + case hb_ao:get(<<"opts">>, Route, not_found, Opts) of + NodeOpts when is_map(NodeOpts) -> + %% TODO: PLEASE CROSS CHECK ONCE I THINK IT's OKAY BUT VERIFY + %% Strip old commitments so http_reference gets included + %% when the process pipeline commits the full state. + hb_maps:without([<<"commitments">>], NodeOpts, Opts); + _ -> #{} + end, + Node = #{ + <<"performance">> => InitialPerf, + <<"price">> => to_float(hb_ao:get(<<"price">>, Route, 0, Opts)), + <<"weight">> => 1.0, + <<"http_reference">> => HttpRef, + <<"opts">> => ExistingNodeOpts#{ <<"http_reference">> => HttpRef } + }, + WithMatch = + case hb_ao:get(<<"with">>, Route, not_found, Opts) of + not_found -> #{}; + With -> + #{ + <<"with">> => With, + <<"match">> => hb_ao:get(<<"match">>, Route, <<"">>, Opts) + } + end, + WithPrefix = + case hb_ao:get(<<"prefix">>, Route, not_found, Opts) of + not_found -> #{}; + Prefix -> #{ <<"prefix">> => Prefix } + end, + hb_maps:merge(hb_maps:merge(Node, WithMatch, Opts), WithPrefix, Opts). + +%% @doc Append a node to an existing route or create a new one. +add_node_to_route(Routes, Template, Node, RouteConfig, Opts) -> + case find_route_index(Routes, Template, Opts) of + not_found -> + BaseRoute = #{ + <<"template">> => Template, + <<"strategy">> => <<"By-Weight">>, + <<"nodes">> => [Node] + }, + NewRoute = hb_maps:merge(BaseRoute, RouteConfig, Opts), + {Routes ++ [NewRoute], length(Routes) + 1}; + Idx -> + Route = lists:nth(Idx, Routes), + ExistingNodes = hb_ao:get(<<"nodes">>, Route, [], Opts), + UpdatedRoute = + hb_ao:set( + Route, + #{ <<"nodes">> => ExistingNodes ++ [Node] }, + Opts + ), + MergedRoute = hb_maps:merge(UpdatedRoute, RouteConfig, Opts), + {list_replace(Routes, Idx, MergedRoute), Idx} + end. + +%% @doc Find the 1-based index of the route matching Template, or not_found. +find_route_index(Routes, Template, Opts) -> + find_route_index(Routes, Template, 1, Opts). +find_route_index([], _Template, _Idx, _Opts) -> not_found; +find_route_index([Route | Rest], Template, Idx, Opts) -> + case hb_ao:get(<<"template">>, Route, undefined, Opts) of + Template -> Idx; + _ -> find_route_index(Rest, Template, Idx + 1, Opts) + end. + +%% @doc Replace the element at 1-based Idx in List with Value. +list_replace(List, Idx, Value) -> + {Before, [_ | After]} = lists:split(Idx - 1, List), + Before ++ [Value | After]. + +%% @doc Apply a duration update to the node matching Reference across all routes. +update_node_performance(Routes, Reference, Duration, ChangeFactor, Opts) -> + lists:map( + fun(Route) -> + Nodes = hb_ao:get(<<"nodes">>, Route, [], Opts), + UpdatedNodes = + [ + apply_node_perf(Node, Reference, Duration, ChangeFactor, Opts) + || + Node <- Nodes + ], + hb_ao:set(Route, #{ <<"nodes">> => UpdatedNodes }, Opts) + end, + Routes + ). + +%% @doc Update a single node's performance if its http_reference matches. +apply_node_perf(Node, Reference, Duration, ChangeFactor, Opts) -> + case hb_ao:get(<<"http_reference">>, Node, not_found, Opts) of + Reference -> + OldPerf = to_float(hb_ao:get(<<"performance">>, Node, 30000, Opts)), + NewPerf = (OldPerf * (1.0 - ChangeFactor)) + (Duration * ChangeFactor), + hb_ao:set(Node, #{ <<"performance">> => NewPerf }, Opts); + _ -> + Node + end. + +%% @doc Recompute weights for all nodes in a single route. +recalculate_route(Route, ScoringParams, Opts, Nodes) when is_list(Nodes) -> + #{ + perf_weight := PerfW, + pricing_weight := PriceW, + sampling_rate := SamplingRate, + score_pref := ScorePref + } = ScoringParams, + TotalWeight = PerfW + PriceW, + PerfFactor = PerfW / TotalWeight, + PriceFactor = PriceW / TotalWeight, + PerfValues = + [ + to_float(hb_ao:get(<<"performance">>, N, 30000, Opts)) + || + N <- Nodes + ], + PriceValues = + [ + to_float(hb_ao:get(<<"price">>, N, 0, Opts)) + || + N <- Nodes + ], + SortedPerf = lists:sort(PerfValues), + SortedPrice = lists:sort(PriceValues), + ScoredNodes = + lists:map( + fun(Node) -> + Perf = + to_float(hb_ao:get(<<"performance">>, Node, 30000, Opts)), + Price = + to_float(hb_ao:get(<<"price">>, Node, 0, Opts)), + PerfPercentile = percentile(Perf, SortedPerf), + PricePercentile = percentile(Price, SortedPrice), + PerfScore = + (decay(ScorePref, PerfPercentile) * (1.0 - SamplingRate)) + + SamplingRate, + PriceScore = decay(ScorePref, PricePercentile), + Weight = (PerfScore * PerfFactor) + (PriceScore * PriceFactor), + hb_ao:set(Node, #{ <<"weight">> => Weight }, Opts) + end, + Nodes + ), + hb_ao:set(Route, #{ <<"nodes">> => ScoredNodes }, Opts); +recalculate_route(Route, _ScoringParams, _Opts, _Nodes) -> + Route. + +%% @doc Exponential decay function for score weighting. +decay(Preference, Score) -> + math:exp(-Preference * Score). + +%% @doc Compute the percentile rank of Value in a sorted list. +percentile(_Value, []) -> 0.0; +percentile(_Value, [_]) -> 0.0; +percentile(Value, Sorted) -> + Pos = count_less_or_equal(Value, Sorted, 0), + (Pos - 1) / length(Sorted). + +count_less_or_equal(_Value, [], Count) -> Count; +count_less_or_equal(Value, [H | T], Count) when H =< Value -> + count_less_or_equal(Value, T, Count + 1); +count_less_or_equal(_Value, _Rest, Count) -> Count. + +to_float(V) when is_float(V) -> V; +to_float(V) when is_integer(V) -> float(V); +to_float(V) when is_binary(V) -> + try binary_to_float(V) + catch _:_ -> + try float(binary_to_integer(V)) + catch _:_ -> 0.0 + end + end; +to_float(V) when is_list(V) -> + try list_to_float(V) + catch _:_ -> + try float(list_to_integer(V)) + catch _:_ -> 0.0 + end + end; +to_float(_) -> 0.0. + +%%% ============================================================ +%%% Tests +%%% ============================================================ + +register_test() -> + Base = #{}, + Req = #{ + <<"route">> => #{ + <<"template">> => <<"/test-key">>, + <<"prefix">> => <<"host1">>, + <<"price">> => 5 + } + }, + {ok, Base2} = register(Base, Req, #{}), + Routes = hb_maps:get(<<"routes">>, Base2, [], #{}), + ?assertEqual(1, length(Routes)), + [Route] = Routes, + Nodes = hb_maps:get(<<"nodes">>, Route, [], #{}), + ?assertEqual(1, length(Nodes)), + [Node] = Nodes, + ?assertEqual(<<"host1">>, hb_maps:get(<<"prefix">>, Node, undefined, #{})), + ?assertEqual(5.0, hb_maps:get(<<"price">>, Node, undefined, #{})). + +register_match_with_test() -> + Base = #{}, + Req = #{ + <<"route">> => #{ + <<"template">> => <<"^/arweave">>, + <<"match">> => <<"^/arweave">>, + <<"with">> => <<"http://chain-1.arweave.xyz:1984">> + } + }, + {ok, Base2} = register(Base, Req, #{}), + Routes = hb_maps:get(<<"routes">>, Base2, [], #{}), + ?assertEqual(1, length(Routes)), + [Route] = Routes, + Nodes = hb_maps:get(<<"nodes">>, Route, [], #{}), + ?assertEqual(1, length(Nodes)), + [Node] = Nodes, + ?assertEqual(<<"http://chain-1.arweave.xyz:1984">>, + hb_maps:get(<<"with">>, Node, undefined, #{})), + ?assertEqual(<<"^/arweave">>, + hb_maps:get(<<"match">>, Node, undefined, #{})), + ?assertEqual(<<"http://chain-1.arweave.xyz:1984">>, + hb_maps:get(<<"http_reference">>, Node, undefined, #{})), + NodeOpts = hb_maps:get(<<"opts">>, Node, #{}, #{}), + ?assertEqual(<<"http://chain-1.arweave.xyz:1984">>, + hb_maps:get(<<"http_reference">>, NodeOpts, undefined, #{})). + +duration_test() -> + Base = #{ + <<"performance-period">> => 2, + <<"initial-performance">> => 1000, + <<"routes">> => [ + #{ + <<"template">> => <<"^/arweave">>, + <<"strategy">> => <<"By-Weight">>, + <<"nodes">> => [ + #{ + <<"with">> => <<"http://chain-1.arweave.xyz:1984">>, + <<"match">> => <<"^/arweave">>, + <<"performance">> => 1000.0, + <<"price">> => 0, + <<"weight">> => 1.0, + <<"http_reference">> => + <<"http://chain-1.arweave.xyz:1984">>, + <<"opts">> => #{ + <<"http_reference">> => + <<"http://chain-1.arweave.xyz:1984">> + } + } + ] + } + ] + }, + Req = #{ + <<"duration">> => 200, + <<"reference">> => <<"http://chain-1.arweave.xyz:1984">> + }, + {ok, Base2} = duration(Base, Req, #{}), + Routes = hb_maps:get(<<"routes">>, Base2, [], #{}), + [Route] = Routes, + [Node] = hb_maps:get(<<"nodes">>, Route, [], #{}), + %% With period=2, change_factor=0.5. + %% new_perf = 1000 * 0.5 + 200 * 0.5 = 600. + ?assertEqual(600.0, hb_maps:get(<<"performance">>, Node, undefined, #{})). + +duration_no_reference_test() -> + Base = #{ <<"routes">> => [] }, + Req = #{ + <<"duration">> => 200 + }, + {ok, Base2} = duration(Base, Req, #{}), + ?assertEqual(Base2, ensure_defaults(Base, #{})). + +recalculate_test() -> + Base = #{ + <<"performance-period">> => 6, + <<"initial-performance">> => 30000, + <<"performance-weight">> => 1, + <<"pricing-weight">> => 1, + <<"sampling-rate">> => 0.1, + <<"score-preference">> => 1, + <<"routes">> => [ + #{ + <<"template">> => <<"/test">>, + <<"strategy">> => <<"By-Weight">>, + <<"nodes">> => [ + #{ + <<"prefix">> => <<"host1">>, + <<"performance">> => 200.0, + <<"price">> => 5, + <<"weight">> => 1.0, + <<"http_reference">> => <<"host1">> + }, + #{ + <<"prefix">> => <<"host2">>, + <<"performance">> => 55500.0, + <<"price">> => 5, + <<"weight">> => 1.0, + <<"http_reference">> => <<"host2">> + } + ] + } + ] + }, + {ok, Base2} = recalculate(Base, #{}, #{}), + Routes = hb_maps:get(<<"routes">>, Base2, [], #{}), + [Route] = Routes, + [N1, N2] = hb_maps:get(<<"nodes">>, Route, [], #{}), + W1 = hb_maps:get(<<"weight">>, N1, undefined, #{}), + W2 = hb_maps:get(<<"weight">>, N2, undefined, #{}), + ?assert(W1 > W2). diff --git a/src/hb_opts.erl b/src/hb_opts.erl index 20e7168ea..14bcae39f 100644 --- a/src/hb_opts.erl +++ b/src/hb_opts.erl @@ -203,6 +203,7 @@ default_message() -> #{<<"name">> => <<"query@1.0">>, <<"module">> => dev_query}, #{<<"name">> => <<"relay@1.0">>, <<"module">> => dev_relay}, #{<<"name">> => <<"router@1.0">>, <<"module">> => dev_router}, + #{<<"name">> => <<"router-perf@1.0">>, <<"module">> => dev_router_perf}, #{<<"name">> => <<"scheduler@1.0">>, <<"module">> => dev_scheduler}, #{<<"name">> => <<"simple-pay@1.0">>, <<"module">> => dev_simple_pay}, #{<<"name">> => <<"snp@1.0">>, <<"module">> => dev_snp},