Skip to content

Commit 1a183d4

Browse files
Metrics for :random strategy (#11)
* add metrics for random * fix dialyze warning * fix test * add missing listener stop * remove tet changes * bump gun version * remove unused helper * report size * add try catch
1 parent e14570d commit 1a183d4

5 files changed

Lines changed: 191 additions & 12 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
PROJECT = whatnot_gun
44
PROJECT_DESCRIPTION = HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP.
5-
PROJECT_VERSION = 2.4.3
5+
PROJECT_VERSION = 2.4.4
66

77
# The OTP application is renamed to whatnot_gun for the Whatnot fork, but the
88
# modules, application callback, and supervisor keep their upstream `gun_*`

ebin/whatnot_gun.app

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{application, 'whatnot_gun', [
22
{description, "HTTP/1.1, HTTP/2 and Websocket client for Erlang/OTP."},
3-
{vsn, "2.4.3"},
3+
{vsn, "2.4.4"},
44
{modules, ['gun','gun_app','gun_conns_sup','gun_content_handler','gun_cookies','gun_cookies_list','gun_data_h','gun_default_event_h','gun_event','gun_http','gun_http2','gun_http3','gun_pool','gun_pool_events_h','gun_pools_sup','gun_protocols','gun_public_suffix','gun_quicer','gun_raw','gun_socks','gun_sse_h','gun_sup','gun_tcp','gun_tcp_proxy','gun_tls','gun_tls_proxy','gun_tls_proxy_cb','gun_tls_proxy_http2_connect','gun_tunnel','gun_ws','gun_ws_h','gun_ws_protocol']},
55
{registered, [gun_sup]},
66
{applications, [kernel,stdlib,public_key,ssl,cowlib]},

src/gun_app.erl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424

2525
start(_Type, _Args) ->
2626
gun_pools = ets:new(gun_pools, [ordered_set, public, named_table]),
27+
gun_pool_counters = ets:new(gun_pool_counters, [
28+
set, public, named_table, {read_concurrency, true}
29+
]),
2730
gun_sup:start_link().
2831

2932
stop(_State) ->

src/gun_pool.erl

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
-export([info/0]).
2424
-export([info/1]).
2525
-export([info/2]).
26+
-export([metrics/0]).
27+
-export([metrics/2]).
2628
-export([await_up/1]).
2729
-export([await_up/2]).
2830
-export([checkout/2]). %% Use responsibly!
@@ -151,11 +153,12 @@
151153
conns :: #{pid() => down | {setup, any()} | {up, http | http2 | ws | raw, map()}},
152154
conns_meta = #{} :: meta(),
153155
await_up = [] :: [{pid(), any()}],
154-
lookup :: #{strategy := random, table := ets:tid()}
156+
lookup :: #{strategy := random, table := ets:tid(),
157+
active := atomics:atomics_ref(), pool_key := {any(), binary()}}
155158
| #{strategy := least_loaded, available := gb_trees:tree(),
156159
stream_counts := #{pid() => non_neg_integer()},
157160
seqs := #{pid() => non_neg_integer()}, next_seq := non_neg_integer(),
158-
up_count := non_neg_integer()}
161+
up_count := non_neg_integer(), manager_pid := pid()}
159162
}).
160163

161164
%% Pool management.
@@ -210,6 +213,55 @@ info(Authority, Scope) ->
210213
gen_statem:call(ManagerPid, info)
211214
end.
212215

216+
%% Read pool metrics from ETS without calling the manager (random pools only).
217+
%% Counters may reflect a torn snapshot across connections but no call is made.
218+
-spec metrics() -> [map()].
219+
metrics() ->
220+
[M || {PoolKey, Row} <- ets:tab2list(gun_pool_counters),
221+
M <- [metrics_from_row(PoolKey, Row)], M =/= undefined].
222+
223+
-spec metrics(binary(), any()) -> undefined | map().
224+
metrics(Authority, Scope) ->
225+
metrics_by_key({Scope, Authority}).
226+
227+
metrics_by_key(PoolKey) ->
228+
case ets:lookup(gun_pool_counters, PoolKey) of
229+
[] ->
230+
undefined;
231+
[{_, Row}] ->
232+
metrics_from_row(PoolKey, Row)
233+
end.
234+
235+
metrics_from_row({Scope, _}, #{table := Tid, max_streams := Max, active := Active, size := Size}) ->
236+
%% The per-pool table is owned by the manager and deleted on terminate.
237+
try
238+
ActiveN = atomics:get(Active, 1),
239+
StreamRows = ets:tab2list(Tid),
240+
Streams = lists:sum([C || {_, C} <- StreamRows]),
241+
{Full, High} = occupancy(StreamRows, Max),
242+
#{
243+
scope => Scope,
244+
size => Size,
245+
active => ActiveN,
246+
max_streams => Max,
247+
streams => Streams,
248+
full => Full,
249+
high => High
250+
}
251+
catch _:_ ->
252+
undefined
253+
end.
254+
255+
occupancy(_Rows, infinity) ->
256+
{0, 0};
257+
occupancy(Rows, Max) ->
258+
HighThreshold = (Max * 9 + 9) div 10,
259+
lists:foldl(fun({_, Count}, {F, H}) ->
260+
F1 = if Count >= Max -> F + 1; true -> F end,
261+
H1 = if Count >= HighThreshold -> H + 1; true -> H end,
262+
{F1, H1}
263+
end, {0, 0}, Rows).
264+
213265
-spec await_up(pid() | binary()) -> ok | {error, pool_not_found, atom()}.
214266
await_up(ManagerPid) when is_pid(ManagerPid) ->
215267
gen_statem:call(ManagerPid, await_up, 5000);
@@ -526,13 +578,21 @@ start_link(Host, Port, Opts) ->
526578

527579
init({Host, Port, Opts}) ->
528580
process_flag(trap_exit, true),
529-
true = ets:insert_new(gun_pools, {gun_pools_key(Host, Port, Opts), self()}),
581+
PoolKey = gun_pools_key(Host, Port, Opts),
582+
true = ets:insert_new(gun_pools, {PoolKey, self()}),
530583
Size = maps:get(size, Opts, 8),
531584
%% @todo Only start processes in static mode.
532585
Lookup = case maps:get(lookup_strategy, Opts, random) of
533586
random ->
534587
Tid = ets:new(gun_pooled_conns, [ordered_set, public]),
535-
#{strategy => random, table => Tid};
588+
Active = atomics:new(1, [{signed, false}]),
589+
ets:insert(gun_pool_counters, {PoolKey, #{
590+
table => Tid,
591+
size => Size,
592+
max_streams => infinity,
593+
active => Active
594+
}}),
595+
#{strategy => random, table => Tid, active => Active, pool_key => PoolKey};
536596
least_loaded ->
537597
#{strategy => least_loaded, available => gb_trees:empty(),
538598
stream_counts => #{}, seqs => #{}, next_seq => 0,
@@ -630,6 +690,7 @@ degraded_setup(ConnPid, Msg, StateData0=#state{conns=Conns, conns_meta=ConnsMeta
630690
%% Websocket or tunnel stream refs.
631691
{up, Protocol, Meta} ->
632692
Settings = #{},
693+
metrics_conn_up(Lookup, Protocol),
633694
StateData = StateData0#state{
634695
conns=Conns#{ConnPid => {up, Protocol, Settings}},
635696
conns_meta=ConnsMeta#{ConnPid => Meta},
@@ -681,13 +742,15 @@ handle_common({call, From}, {checkout, _ReqOpts}, _,
681742
end;
682743
handle_common(cast, {release_stream, ConnPid}, _, StateData) ->
683744
{keep_state, adjust_stream_count(ConnPid, -1, StateData)};
684-
handle_common(info, {gun_notify, ConnPid, settings_changed, Settings}, _, StateData=#state{conns=Conns}) ->
745+
handle_common(info, {gun_notify, ConnPid, settings_changed, Settings}, _, StateData=#state{conns=Conns, lookup=Lookup}) ->
685746
%% Assert that the state is correct.
686747
{up, http2, _} = maps:get(ConnPid, Conns),
748+
metrics_update_max_streams(Lookup, Settings),
687749
{keep_state, StateData#state{conns=Conns#{ConnPid => {up, http2, Settings}}}};
688750
handle_common(info, {gun_down, ConnPid, Protocol, _Reason, _KilledStreams}, _,
689-
StateData=#state{lookup=#{strategy := random}, conns=Conns}) ->
751+
StateData=#state{lookup=#{strategy := random, active := Active}, conns=Conns}) ->
690752
{up, Protocol, _} = maps:get(ConnPid, Conns),
753+
atomics:sub(Active, 1, 1),
691754
{next_state, degraded, StateData#state{conns=Conns#{ConnPid => down}}};
692755
handle_common(info, {gun_down, ConnPid, Protocol, _Reason, _KilledStreams}, _,
693756
StateData=#state{lookup=#{strategy := least_loaded, available := Available,
@@ -706,6 +769,15 @@ handle_common(info, {gun_down, ConnPid, Protocol, _Reason, _KilledStreams}, _,
706769
}};
707770
%% @todo We do not want to reconnect automatically when the pool is dynamic.
708771
handle_common(info, {'DOWN', _MRef, process, ConnPid0, Reason}, _, StateData0) ->
772+
%% gun_down fires before DOWN in normal cases and already decrements active.
773+
%% Guard against sudden process death (no gun_down) where the conn is still up.
774+
_ = case {maps:get(ConnPid0, StateData0#state.conns, undefined),
775+
StateData0#state.lookup} of
776+
{{up, _, _}, #{strategy := random, active := Active}} ->
777+
atomics:sub(Active, 1, 1);
778+
_ ->
779+
ok
780+
end,
709781
StateData=#state{host=Host, port=Port, opts=Opts, lookup=Lookup, conns=Conns}
710782
= remove_down_conn(ConnPid0, StateData0),
711783
case Reason of
@@ -873,5 +945,29 @@ terminate(Reason, StateName, #state{host=Host, port=Port, opts=Opts, await_up=Aw
873945
gen_statem:reply([
874946
{reply, ReplyTo, {error, {terminate, StateName, Reason}}}
875947
|| ReplyTo <- AwaitUp]),
876-
true = ets:delete(gun_pools, gun_pools_key(Host, Port, Opts)),
948+
PoolKey = gun_pools_key(Host, Port, Opts),
949+
true = ets:delete(gun_pools, PoolKey),
950+
ets:delete(gun_pool_counters, PoolKey),
877951
ok.
952+
953+
metrics_conn_up(#{strategy := random, active := Active, pool_key := PoolKey}, http) ->
954+
atomics:add(Active, 1, 1),
955+
update_pool_counter_max_streams(PoolKey, 1);
956+
metrics_conn_up(#{strategy := random, active := Active}, _Protocol) ->
957+
atomics:add(Active, 1, 1);
958+
metrics_conn_up(_Lookup, _Protocol) ->
959+
ok.
960+
961+
metrics_update_max_streams(#{strategy := random, pool_key := PoolKey}, Settings) ->
962+
MaxStreams = maps:get(max_concurrent_streams, Settings, infinity),
963+
update_pool_counter_max_streams(PoolKey, MaxStreams);
964+
metrics_update_max_streams(_Lookup, _Settings) ->
965+
ok.
966+
967+
update_pool_counter_max_streams(PoolKey, MaxStreams) ->
968+
case ets:lookup(gun_pool_counters, PoolKey) of
969+
[{_, Row}] ->
970+
ets:insert(gun_pool_counters, {PoolKey, Row#{max_streams => MaxStreams}});
971+
[] ->
972+
ok
973+
end.

test/pool_SUITE.erl

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ groups() ->
4141
stop_pool,
4242
degraded_configuration_error
4343
],
44-
[{random, [], Tests},
45-
{least_loaded, [], Tests ++ [least_loaded_routing, least_loaded_round_robin]}].
44+
[{random, [], Tests ++ [metrics_idle, metrics_streams]},
45+
{least_loaded, [], Tests ++ [least_loaded_routing, least_loaded_round_robin, metrics_undefined]}].
4646

4747
init_per_suite(Config) ->
4848
{ok, _} = cowboy:start_clear({?MODULE, tcp}, [], do_proto_opts()),
@@ -57,11 +57,13 @@ end_per_suite(_) ->
5757
max_streams_h2_size_2_random,
5858
max_streams_h2_size_2_retry_random,
5959
reconnect_h1_random,
60+
metrics_streams_random,
6061
max_streams_h2_size_1_least_loaded,
6162
max_streams_h2_size_1_retry_least_loaded,
6263
max_streams_h2_size_2_least_loaded,
6364
max_streams_h2_size_2_retry_least_loaded,
64-
reconnect_h1_least_loaded
65+
reconnect_h1_least_loaded,
66+
least_loaded_routing_least_loaded
6567
],
6668
_ = [cowboy:stop_listener(Listener) || Listener <- ExtraListeners],
6769
ok.
@@ -543,6 +545,84 @@ least_loaded_round_robin(Config) ->
543545
%% Every connection must have been used exactly once.
544546
Size = length(lists:usort(ConnPids)).
545547

548+
metrics_idle(Config) ->
549+
doc("Confirm pool metrics reflect active connections and zero streams when idle."),
550+
Port = config(port, Config),
551+
Authority = iolist_to_binary(["localhost:", integer_to_binary(Port)]),
552+
Scope = scope(?FUNCTION_NAME, Config),
553+
Size = 3,
554+
{ok, ManagerPid} = gun_pool:start_pool("localhost", Port, #{
555+
conn_opts => #{protocols => [http2]},
556+
scope => Scope,
557+
lookup_strategy => random,
558+
size => Size
559+
}),
560+
gun_pool:await_up(ManagerPid),
561+
%% Make a request so SETTINGS have been negotiated.
562+
{async, PoolStreamRef} = gun_pool:get("/",
563+
#{<<"host">> => Authority}, #{scope => Scope}),
564+
{response, nofin, 200, _} = gun_pool:await(PoolStreamRef),
565+
{ok, _} = gun_pool:await_body(PoolStreamRef),
566+
%% Flush pending manager messages (e.g. settings_changed) with a synchronous call.
567+
gun_pool:info(Authority, Scope),
568+
#{size := Size, active := Size, streams := 0, full := 0, high := 0} = gun_pool:metrics(Authority, Scope).
569+
570+
metrics_streams(Config) ->
571+
doc("Confirm pool metrics count in-flight streams and detect full/high connections."),
572+
Listener = listener_name(?FUNCTION_NAME, Config),
573+
ProtoOpts = do_proto_opts(),
574+
{ok, _} = cowboy:start_clear(Listener, [], ProtoOpts#{max_concurrent_streams => 5}),
575+
Port = ranch:get_port(Listener),
576+
Authority = iolist_to_binary(["localhost:", integer_to_binary(Port)]),
577+
Scope = scope(?FUNCTION_NAME, Config),
578+
{ok, ManagerPid} = gun_pool:start_pool("localhost", Port, #{
579+
conn_opts => #{protocols => [http2]},
580+
scope => Scope,
581+
lookup_strategy => random,
582+
size => 1
583+
}),
584+
gun_pool:await_up(ManagerPid),
585+
%% Fill the single connection to max capacity.
586+
[gun_pool:get("/delay", #{<<"host">> => Authority},
587+
#{scope => Scope}) || _ <- lists:seq(1, 5)],
588+
%% Poll until event handler has counted all 5 streams and manager has processed SETTINGS.
589+
ok = wait_for_metrics(Authority, Scope, #{size => 1, active => 1, streams => 5, full => 1,
590+
high => 1, max_streams => 5}).
591+
592+
metrics_undefined(Config) ->
593+
doc("Confirm metrics returns undefined for the least_loaded strategy."),
594+
Port = config(port, Config),
595+
Authority = iolist_to_binary(["localhost:", integer_to_binary(Port)]),
596+
Scope = scope(?FUNCTION_NAME, Config),
597+
{ok, ManagerPid} = gun_pool:start_pool("localhost", Port, #{
598+
conn_opts => #{protocols => [http2]},
599+
scope => Scope,
600+
lookup_strategy => least_loaded,
601+
size => 1
602+
}),
603+
gun_pool:await_up(ManagerPid),
604+
undefined = gun_pool:metrics(Authority, Scope).
605+
606+
%% Poll until metrics match the expected map.
607+
%% Calls info/2 each iteration to flush pending manager messages (e.g. settings_changed).
608+
wait_for_metrics(Authority, Scope, Expected) ->
609+
wait_for_metrics(Authority, Scope, Expected, 50).
610+
611+
wait_for_metrics(_Authority, _Scope, _Expected, 0) ->
612+
{error, timeout};
613+
wait_for_metrics(Authority, Scope, Expected, N) ->
614+
gun_pool:info(Authority, Scope),
615+
Actual = gun_pool:metrics(Authority, Scope),
616+
Match = is_map(Actual) andalso maps:fold(
617+
fun(K, V, Acc) -> Acc andalso maps:get(K, Actual, undefined) =:= V end,
618+
true, Expected),
619+
case Match of
620+
true -> ok;
621+
false ->
622+
timer:sleep(20),
623+
wait_for_metrics(Authority, Scope, Expected, N - 1)
624+
end.
625+
546626
%% Poll the manager until every connection's stream count is back to 0.
547627
wait_all_streams_released(ManagerPid) ->
548628
wait_all_streams_released(ManagerPid, 100).

0 commit comments

Comments
 (0)