Skip to content

Commit 21c2fc2

Browse files
Merge pull request #226 from permaweb/feat/route-providers
feat: dynamic route choice and weighted node selection in `~router@1.0`
2 parents c61f8c7 + 4d761a0 commit 21c2fc2

File tree

3 files changed

+115
-6
lines changed

3 files changed

+115
-6
lines changed

src/dev_router.erl

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
%% @doc Device function that returns all known routes.
3535
routes(M1, M2, Opts) ->
3636
?event({routes_msg, M1, M2}),
37-
Routes = hb_opts:get(routes, [], Opts),
37+
Routes = load_routes(Opts),
3838
?event({routes, Routes}),
3939
case hb_ao:get(<<"method">>, M2, Opts) of
4040
<<"POST">> ->
@@ -75,9 +75,10 @@ routes(M1, M2, Opts) ->
7575
%% If we have a route that has multiple resolving nodes, check
7676
%% the load distribution strategy and choose a node. Supported strategies:
7777
%% <pre>
78-
%% All: Return all nodes (default).
79-
%% Random: Distribute load evenly across all nodes, non-deterministically.
78+
%% All: Return all nodes (default).
79+
%% Random: Distribute load evenly across all nodes, non-deterministically.
8080
%% By-Base: According to the base message's hashpath.
81+
%% By-Weight: According to the node's `weight' key.
8182
%% Nearest: According to the distance of the node's wallet address to the
8283
%% base message's hashpath.
8384
%% </pre>
@@ -90,7 +91,7 @@ routes(M1, M2, Opts) ->
9091
%% function, taking only the request message and the `Opts' map.
9192
route(Msg, Opts) -> route(undefined, Msg, Opts).
9293
route(_, Msg, Opts) ->
93-
Routes = hb_opts:get(routes, [], Opts),
94+
Routes = load_routes(Opts),
9495
R = match_routes(Msg, Routes, Opts),
9596
?event({find_route, {msg, Msg}, {routes, Routes}, {res, R}}),
9697
case (R =/= no_matches) andalso hb_ao:get(<<"node">>, R, Opts) of
@@ -137,6 +138,21 @@ route(_, Msg, Opts) ->
137138
end
138139
end.
139140

141+
%% @doc Load the current routes for the node. Allows either explicit routes from
142+
%% the node message's `routes' key, or dynamic routes generated by resolving the
143+
%% `routes_provider' message.
144+
load_routes(Opts) ->
145+
case hb_opts:get(routes_provider, not_found, Opts) of
146+
not_found -> hb_opts:get(routes, [], Opts);
147+
RoutesProvider ->
148+
ProviderMsgs = hb_singleton:from(RoutesProvider),
149+
?event({routes_provider, ProviderMsgs}),
150+
case hb_ao:resolve_many(ProviderMsgs, Opts) of
151+
{ok, Routes} -> Routes;
152+
{error, Error} -> throw({routes, routes_provider_failed, Error})
153+
end
154+
end.
155+
140156
%% @doc Extract the base message ID from a request message. Produces a single
141157
%% binary ID that can be used for routing decisions.
142158
extract_base(#{ <<"path">> := Path }, Opts) ->
@@ -231,6 +247,19 @@ choose(0, _, _, _, _) -> [];
231247
choose(N, <<"Random">>, _, Nodes, _Opts) ->
232248
Node = lists:nth(rand:uniform(length(Nodes)), Nodes),
233249
[Node | choose(N - 1, <<"Random">>, nop, lists:delete(Node, Nodes), _Opts)];
250+
choose(N, <<"By-Weight">>, _, Nodes, Opts) ->
251+
NodesWithWeight =
252+
[
253+
{ Node, hb_util:int(hb_ao:get(<<"weight">>, Node, Opts)) }
254+
||
255+
Node <- Nodes
256+
],
257+
Node = hb_util:weighted_random(NodesWithWeight),
258+
[
259+
Node
260+
|
261+
choose(N - 1, <<"By-Weight">>, nop, lists:delete(Node, Nodes), Opts)
262+
];
234263
choose(N, <<"By-Base">>, Hashpath, Nodes, Opts) when is_binary(Hashpath) ->
235264
choose(N, <<"By-Base">>, binary_to_bignum(Hashpath), Nodes, Opts);
236265
choose(N, <<"By-Base">>, HashInt, Nodes, Opts) ->
@@ -303,6 +332,51 @@ binary_to_bignum(Bin) when ?IS_ID(Bin) ->
303332

304333
%%% Tests
305334

335+
routes_provider_test() ->
336+
Node = hb_http_server:start_node(#{
337+
routes_provider => #{
338+
<<"path">> => <<"/test-key/routes">>,
339+
<<"test-key">> => #{
340+
<<"routes">> => [
341+
#{
342+
<<"template">> => <<"*">>,
343+
<<"node">> => <<"testnode">>
344+
}
345+
]
346+
}
347+
}
348+
}),
349+
?assertEqual(
350+
{ok, <<"testnode">>},
351+
hb_http:get(Node, <<"/~router@1.0/routes/1/node">>, #{})
352+
).
353+
354+
dynamic_routes_provider_test() ->
355+
{ok, Script} = file:read_file("test/test.lua"),
356+
Node = hb_http_server:start_node(#{
357+
routes_provider => #{
358+
<<"device">> => <<"lua@5.3a">>,
359+
<<"path">> => <<"routes">>,
360+
<<"script">> => Script,
361+
<<"node">> => <<"test-dynamic-node">>
362+
}
363+
}),
364+
?assertEqual(
365+
{ok, <<"test-dynamic-node">>},
366+
hb_http:get(Node, <<"/~router@1.0/routes/1/node">>, #{})
367+
).
368+
369+
weighted_random_strategy_test() ->
370+
Nodes =
371+
[
372+
#{ <<"host">> => <<"1">>, <<"weight">> => 1 },
373+
#{ <<"host">> => <<"2">>, <<"weight">> => 99 }
374+
],
375+
SimRes = simulate(1000, 1, Nodes, <<"By-Weight">>),
376+
[One, _] = simulation_distribution(SimRes, Nodes),
377+
?assert(One < 20),
378+
?assert(One > 5).
379+
306380
strategy_suite_test_() ->
307381
lists:map(
308382
fun(Strategy) ->

src/hb_util.erl

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
-export([print_trace/4, trace_macro_helper/5, print_trace_short/4]).
1818
-export([ok/1, ok/2, until/1, until/2, until/3]).
1919
-export([format_trace_short/1, is_hb_module/1, is_hb_module/2, all_hb_modules/0]).
20-
-export([count/2, mean/1, stddev/1, variance/1]).
20+
-export([count/2, mean/1, stddev/1, variance/1, weighted_random/1]).
2121
-include("include/hb.hrl").
2222

2323
%%% Simple type coercion functions, useful for quickly turning inputs from the
@@ -709,4 +709,22 @@ stddev(List) ->
709709

710710
variance(List) ->
711711
Mean = mean(List),
712-
lists:sum([ math:pow(X - Mean, 2) || X <- List ]) / length(List).
712+
lists:sum([ math:pow(X - Mean, 2) || X <- List ]) / length(List).
713+
714+
%% @doc Shuffle a list.
715+
shuffle(List) ->
716+
[ Y || {_, Y} <- lists:sort([ {rand:uniform(), X} || X <- List]) ].
717+
718+
%% @doc Return a random element from a list, weighted by the values in the list.
719+
weighted_random(List) ->
720+
TotalWeight = lists:sum([ Weight || {_, Weight} <- List ]),
721+
Normalized = [ {Item, Weight / TotalWeight} || {Item, Weight} <- List ],
722+
Shuffled = shuffle(Normalized),
723+
pick_weighted(Shuffled, rand:uniform()).
724+
725+
pick_weighted([], _) ->
726+
error(empty_list);
727+
pick_weighted([{Item, Weight}|_Rest], Remaining) when Remaining < Weight ->
728+
Item;
729+
pick_weighted([{_Item, Weight}|Rest], Remaining) ->
730+
pick_weighted(Rest, Remaining - Weight).

test/test.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,24 @@ function preprocess(base, req, opts)
6868
return { { body = "i like turtles" } }
6969
end
7070

71+
--- @function sandboxed_fail
72+
--- @tparam table base
73+
--- @tparam table request
74+
--- @error fails when inside the sandbox
7175
function sandboxed_fail()
7276
-- Do something that is not dangerous, but is sandboxed nonetheless.
7377
return os.getenv("PWD")
7478
end
79+
80+
--- @function routes
81+
--- @tparam table base
82+
--- @tparam table request
83+
--- @return table a table with the `node` field set to the value of the `node`
84+
--- field in the base message.
85+
function routes(base, req, opts)
86+
return {
87+
{
88+
node = base.node
89+
}
90+
}
91+
end

0 commit comments

Comments
 (0)