mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
376 lines
12 KiB
Erlang
376 lines
12 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(gateway_dispatch_relay_bound_tests).
|
|
-typing([eqwalizer]).
|
|
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
-define(STATE_KEY, {gateway_dispatch_relay, state}).
|
|
-define(CONFIG_KEY, gateway_rollout_config).
|
|
-define(BOUND, 8).
|
|
-define(FILL, 6).
|
|
-define(EVENT_COUNT, 6).
|
|
-define(PROBE_BOUND, 1000).
|
|
-define(PROBE_EVENT_COUNT, 200).
|
|
-define(PROBE_BUDGET, 2).
|
|
-define(SHARD_COUNT, 2).
|
|
-define(FAST_SHARD_TIMEOUT_MS, 1500).
|
|
-define(PRODUCER_COUNTS, [1, 10, 64]).
|
|
-define(PEAK_SAMPLES, 100).
|
|
-define(PEAK_INTERVAL_MS, 5).
|
|
|
|
dispatch_is_bounded_and_stays_ordered_test_() ->
|
|
{timeout, 30, fun dispatch_is_bounded_and_stays_ordered/0}.
|
|
|
|
dispatch_many_is_bounded_and_stays_ordered_test_() ->
|
|
{timeout, 30, fun dispatch_many_is_bounded_and_stays_ordered/0}.
|
|
|
|
max_queue_config_governs_the_bound_test_() ->
|
|
{timeout, 30, fun max_queue_config_governs_the_bound/0}.
|
|
|
|
max_queue_zero_keeps_the_bound_test_() ->
|
|
{timeout, 60, fun max_queue_zero_keeps_the_bound/0}.
|
|
|
|
max_queue_is_capped_by_the_watchdog_kill_threshold_test_() ->
|
|
{timeout, 30, fun max_queue_is_capped_by_the_watchdog_kill_threshold/0}.
|
|
|
|
dispatch_does_not_probe_the_worker_per_event_test_() ->
|
|
{timeout, 30, fun dispatch_does_not_probe_the_worker_per_event/0}.
|
|
|
|
saturated_shard_does_not_delay_other_shards_test_() ->
|
|
{timeout, 60, fun saturated_shard_does_not_delay_other_shards/0}.
|
|
|
|
concurrent_producers_share_the_worker_bound_test_() ->
|
|
{timeout, 120, fun concurrent_producers_share_the_worker_bound/0}.
|
|
|
|
restarted_worker_starts_from_an_empty_bound_test_() ->
|
|
{timeout, 30, fun restarted_worker_starts_from_an_empty_bound/0}.
|
|
|
|
dispatch_is_bounded_and_stays_ordered() ->
|
|
assert_bounded_and_ordered(fun send_dispatch/2).
|
|
|
|
dispatch_many_is_bounded_and_stays_ordered() ->
|
|
assert_bounded_and_ordered(fun send_dispatch_many/2).
|
|
|
|
send_dispatch(SessionPid, N) ->
|
|
gateway_dispatch_relay:dispatch(SessionPid, relay_bound_event, #{<<"n">> => N}).
|
|
|
|
send_dispatch_many(SessionPid, N) ->
|
|
gateway_dispatch_relay:dispatch_many([SessionPid], relay_bound_event, #{<<"n">> => N}).
|
|
|
|
max_queue_config_governs_the_bound() ->
|
|
?assertEqual(blocked, producer_status_with_bound(?BOUND, ?BOUND)),
|
|
?assertEqual(finished, producer_status_with_bound(?BOUND * 8, ?BOUND)).
|
|
|
|
max_queue_zero_keeps_the_bound() ->
|
|
Ceiling = process_health_watchdog:kill_threshold(),
|
|
with_max_queue(0, fun() ->
|
|
?assertEqual(Ceiling, gateway_dispatch_relay_batch:max_queue())
|
|
end),
|
|
?assertEqual(blocked, producer_status_with_bound(0, Ceiling)).
|
|
|
|
max_queue_is_capped_by_the_watchdog_kill_threshold() ->
|
|
Ceiling = process_health_watchdog:kill_threshold(),
|
|
with_max_queue(Ceiling * 4, fun() ->
|
|
?assertEqual(Ceiling, gateway_dispatch_relay_batch:max_queue())
|
|
end),
|
|
with_max_queue(Ceiling - 1, fun() ->
|
|
?assertEqual(Ceiling - 1, gateway_dispatch_relay_batch:max_queue())
|
|
end).
|
|
|
|
dispatch_does_not_probe_the_worker_per_event() ->
|
|
with_max_queue(?PROBE_BOUND, fun() ->
|
|
with_worker(fun(Worker) ->
|
|
Ref = make_ref(),
|
|
Session = spawn_session(self(), Ref),
|
|
Producer = spawn_gated_producer(Session, ?PROBE_EVENT_COUNT),
|
|
Probes = count_probes(Worker, Producer),
|
|
?assertEqual(
|
|
lists:seq(1, ?PROBE_EVENT_COUNT),
|
|
collect_observed(Ref, ?PROBE_EVENT_COUNT, [])
|
|
),
|
|
Session ! stop,
|
|
?assert(Probes =< ?PROBE_BUDGET)
|
|
end)
|
|
end).
|
|
|
|
saturated_shard_does_not_delay_other_shards() ->
|
|
with_max_queue(?BOUND, fun() ->
|
|
with_workers(?SHARD_COUNT, fun([Blocked, _Free]) ->
|
|
SlowRef = make_ref(),
|
|
FastRef = make_ref(),
|
|
Slow = session_for_shard(1, self(), SlowRef),
|
|
Fast = session_for_shard(2, self(), FastRef),
|
|
ok = sys:suspend(Blocked),
|
|
fill_queue(Blocked, ?BOUND),
|
|
Producer = spawn_fanout_producer([Slow, Fast]),
|
|
?assertEqual([1], collect_within(FastRef, ?FAST_SHARD_TIMEOUT_MS)),
|
|
?assertEqual(blocked, producer_status(Producer, 100)),
|
|
ok = sys:resume(Blocked),
|
|
?assertEqual([1], collect_observed(SlowRef, 1, [])),
|
|
?assertEqual(finished, producer_status(Producer, 20000)),
|
|
Slow ! stop,
|
|
Fast ! stop
|
|
end)
|
|
end).
|
|
|
|
restarted_worker_starts_from_an_empty_bound() ->
|
|
with_max_queue(?BOUND, fun() ->
|
|
Saturated = saturate_worker(),
|
|
Ref = make_ref(),
|
|
Session = spawn_session(self(), Ref),
|
|
with_worker(fun(_Restarted) ->
|
|
Producer = spawn_producer(fun send_dispatch/2, Session),
|
|
?assertEqual(finished, producer_status(Producer, 5000)),
|
|
?assertEqual(lists:seq(1, ?EVENT_COUNT), collect_observed(Ref, ?EVENT_COUNT, []))
|
|
end),
|
|
Session ! stop,
|
|
stop_worker(Saturated)
|
|
end).
|
|
|
|
saturate_worker() ->
|
|
Worker = gateway_dispatch_relay_batch:start_worker(0),
|
|
Session = spawn_session(self(), make_ref()),
|
|
with_relay_workers([Worker], fun() ->
|
|
ok = sys:suspend(Worker),
|
|
fill_queue(Worker, ?BOUND),
|
|
Producer = spawn_producer(fun send_dispatch/2, Session),
|
|
?assertEqual(blocked, producer_status(Producer, 500))
|
|
end),
|
|
Session ! stop,
|
|
Worker.
|
|
|
|
concurrent_producers_share_the_worker_bound() ->
|
|
lists:foreach(fun assert_peak_within_bound/1, ?PRODUCER_COUNTS).
|
|
|
|
assert_peak_within_bound(Producers) ->
|
|
Verdict = bound_verdict(peak_queue_len(Producers)),
|
|
?assertEqual({Producers, within_bound}, {Producers, Verdict}).
|
|
|
|
bound_verdict(Peak) when Peak =< ?BOUND + 1 -> within_bound;
|
|
bound_verdict(Peak) -> {over_bound, Peak}.
|
|
|
|
peak_queue_len(Producers) ->
|
|
with_max_queue(?BOUND, fun() ->
|
|
with_worker(fun(Worker) -> concurrent_peak(Worker, Producers) end)
|
|
end).
|
|
|
|
concurrent_peak(Worker, Producers) ->
|
|
Ref = make_ref(),
|
|
Session = spawn_session(self(), Ref),
|
|
ok = sys:suspend(Worker),
|
|
Pids = [spawn_producer(fun send_dispatch/2, Session) || _ <- lists:seq(1, Producers)],
|
|
Peak = sample_peak(Worker, ?PEAK_SAMPLES, 0),
|
|
ok = sys:resume(Worker),
|
|
lists:foreach(fun(Pid) -> ok = await_producer(Pid, blocked) end, Pids),
|
|
_ = collect_observed(Ref, Producers * ?EVENT_COUNT, []),
|
|
Session ! stop,
|
|
Peak.
|
|
|
|
sample_peak(_Worker, 0, Peak) ->
|
|
Peak;
|
|
sample_peak(Worker, Remaining, Peak) ->
|
|
ok = gateway_retry_timer:wait(?PEAK_INTERVAL_MS),
|
|
Sampled = gateway_dispatch_relay_batch:message_queue_len(Worker),
|
|
sample_peak(Worker, Remaining - 1, max(Peak, Sampled)).
|
|
|
|
assert_bounded_and_ordered(SendFun) ->
|
|
{Status, QueueLen, Observed} = run_over_bound(SendFun),
|
|
?assertEqual(lists:seq(1, ?EVENT_COUNT), Observed),
|
|
?assertEqual(blocked, Status),
|
|
?assert(QueueLen =< ?BOUND + 1).
|
|
|
|
run_over_bound(SendFun) ->
|
|
with_max_queue(?BOUND, fun() ->
|
|
with_worker(fun(Worker) ->
|
|
Ref = make_ref(),
|
|
Session = spawn_session(self(), Ref),
|
|
ok = sys:suspend(Worker),
|
|
fill_queue(Worker, ?FILL),
|
|
Producer = spawn_producer(SendFun, Session),
|
|
Status = producer_status(Producer, 500),
|
|
QueueLen = gateway_dispatch_relay_batch:message_queue_len(Worker),
|
|
ok = sys:resume(Worker),
|
|
ok = await_producer(Producer, Status),
|
|
Observed = collect_observed(Ref, ?EVENT_COUNT, []),
|
|
Session ! stop,
|
|
{Status, QueueLen, Observed}
|
|
end)
|
|
end).
|
|
|
|
producer_status_with_bound(MaxQueue, Fill) ->
|
|
with_max_queue(MaxQueue, fun() ->
|
|
with_worker(fun(Worker) ->
|
|
Ref = make_ref(),
|
|
Session = spawn_session(self(), Ref),
|
|
ok = sys:suspend(Worker),
|
|
fill_queue(Worker, Fill),
|
|
Producer = spawn_producer(fun send_dispatch/2, Session),
|
|
Status = producer_status(Producer, 500),
|
|
ok = sys:resume(Worker),
|
|
ok = await_producer(Producer, Status),
|
|
?assertEqual(lists:seq(1, ?EVENT_COUNT), collect_observed(Ref, ?EVENT_COUNT, [])),
|
|
Session ! stop,
|
|
Status
|
|
end)
|
|
end).
|
|
|
|
spawn_producer(SendFun, Session) ->
|
|
Parent = self(),
|
|
spawn_link(fun() ->
|
|
lists:foreach(fun(N) -> ok = SendFun(Session, N) end, lists:seq(1, ?EVENT_COUNT)),
|
|
Parent ! {producer_finished, self()}
|
|
end).
|
|
|
|
spawn_gated_producer(Session, Count) ->
|
|
Parent = self(),
|
|
spawn_link(fun() ->
|
|
receive
|
|
start -> ok
|
|
end,
|
|
lists:foreach(fun(N) -> ok = send_dispatch(Session, N) end, lists:seq(1, Count)),
|
|
Parent ! {producer_finished, self()}
|
|
end).
|
|
|
|
spawn_fanout_producer(Sessions) ->
|
|
Parent = self(),
|
|
spawn_link(fun() ->
|
|
ok = gateway_dispatch_relay:dispatch_many(Sessions, relay_bound_event, #{<<"n">> => 1}),
|
|
Parent ! {producer_finished, self()}
|
|
end).
|
|
|
|
session_for_shard(Index, Parent, Ref) ->
|
|
Session = spawn_session(Parent, Ref),
|
|
case erlang:phash2(Session, ?SHARD_COUNT) + 1 of
|
|
Index -> Session;
|
|
_ -> session_for_shard(Index, Parent, Ref)
|
|
end.
|
|
|
|
count_probes(Worker, Producer) ->
|
|
1 = erlang:trace(Producer, true, [call]),
|
|
_ = erlang:trace_pattern({erlang, process_info, 2}, true, [global]),
|
|
Producer ! start,
|
|
try
|
|
drain_probes(Worker, Producer, 0)
|
|
after
|
|
_ = erlang:trace_pattern({erlang, process_info, 2}, false, [global]),
|
|
untrace(Producer)
|
|
end.
|
|
|
|
untrace(Producer) ->
|
|
try erlang:trace(Producer, false, [call]) of
|
|
_ -> ok
|
|
catch
|
|
error:badarg -> ok
|
|
end.
|
|
|
|
drain_probes(Worker, Producer, Count) ->
|
|
receive
|
|
{trace, Producer, call, {erlang, process_info, [Worker, message_queue_len]}} ->
|
|
drain_probes(Worker, Producer, Count + 1);
|
|
{producer_finished, Producer} ->
|
|
Count
|
|
after 20000 ->
|
|
?assert(false, {probe_count_timeout, Count})
|
|
end.
|
|
|
|
await_producer(_Producer, finished) ->
|
|
ok;
|
|
await_producer(Producer, blocked) ->
|
|
?assertEqual(finished, producer_status(Producer, 20000)),
|
|
ok.
|
|
|
|
producer_status(Producer, Timeout) ->
|
|
receive
|
|
{producer_finished, Producer} -> finished
|
|
after Timeout ->
|
|
blocked
|
|
end.
|
|
|
|
fill_queue(_Worker, 0) ->
|
|
ok;
|
|
fill_queue(Worker, Remaining) ->
|
|
Worker ! relay_bound_filler,
|
|
fill_queue(Worker, Remaining - 1).
|
|
|
|
spawn_session(Parent, Ref) ->
|
|
spawn_link(fun() -> session_loop(Parent, Ref) end).
|
|
|
|
session_loop(Parent, Ref) ->
|
|
receive
|
|
{'$gen_cast', {dispatch, relay_bound_event, #{<<"n">> := N}}} ->
|
|
Parent ! {relay_bound_received, Ref, N},
|
|
session_loop(Parent, Ref);
|
|
stop ->
|
|
ok
|
|
after 30000 ->
|
|
ok
|
|
end.
|
|
|
|
collect_within(Ref, Timeout) ->
|
|
receive
|
|
{relay_bound_received, Ref, N} -> [N]
|
|
after Timeout ->
|
|
[]
|
|
end.
|
|
|
|
collect_observed(_Ref, 0, Acc) ->
|
|
lists:reverse(Acc);
|
|
collect_observed(Ref, Remaining, Acc) ->
|
|
receive
|
|
{relay_bound_received, Ref, N} -> collect_observed(Ref, Remaining - 1, [N | Acc])
|
|
after 10000 ->
|
|
?assert(false, {relay_bound_timeout, Remaining, lists:reverse(Acc)})
|
|
end.
|
|
|
|
with_worker(Fun) ->
|
|
with_workers(1, fun([Worker]) -> Fun(Worker) end).
|
|
|
|
with_workers(Count, Fun) ->
|
|
Workers = [
|
|
gateway_dispatch_relay_batch:start_worker(Index)
|
|
|| Index <- lists:seq(0, Count - 1)
|
|
],
|
|
try
|
|
with_relay_workers(Workers, fun() -> Fun(Workers) end)
|
|
after
|
|
lists:foreach(fun stop_worker/1, Workers)
|
|
end.
|
|
|
|
with_relay_workers(Workers, Fun) ->
|
|
Previous = persistent_term:get(?STATE_KEY, undefined),
|
|
persistent_term:put(?STATE_KEY, #{
|
|
workers => list_to_tuple(Workers),
|
|
shard_count => length(Workers)
|
|
}),
|
|
try
|
|
Fun()
|
|
after
|
|
restore_term(?STATE_KEY, Previous)
|
|
end.
|
|
|
|
with_max_queue(MaxQueue, Fun) ->
|
|
Previous = persistent_term:get(?CONFIG_KEY, undefined),
|
|
Config = gateway_rollout_config:get(),
|
|
persistent_term:put(?CONFIG_KEY, Config#{
|
|
<<"gateway_dispatch_relay_max_queue">> => MaxQueue
|
|
}),
|
|
try
|
|
Fun()
|
|
after
|
|
restore_term(?CONFIG_KEY, Previous)
|
|
end.
|
|
|
|
restore_term(Key, undefined) ->
|
|
_ = persistent_term:erase(Key),
|
|
ok;
|
|
restore_term(Key, Previous) ->
|
|
persistent_term:put(Key, Previous).
|
|
|
|
stop_worker(Pid) ->
|
|
try gen_server:stop(Pid, normal, 5000) of
|
|
_ -> ok
|
|
catch
|
|
exit:_ -> ok
|
|
end.
|