mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
297 lines
12 KiB
Erlang
297 lines
12 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(channel_threads_flip_tests).
|
|
-typing([eqwalizer]).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
guild_fan_out_touches_only_guilds_whose_activity_changed_test() ->
|
|
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>, <<"2">>]}),
|
|
Current = config(#{<<"enabled_guild_ids">> => [<<"2">>, <<"3">>]}),
|
|
Candidates = [{guild, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{user, 1, self()}],
|
|
?assertEqual(
|
|
[{guild, 1, self()}, {guild, 3, self()}],
|
|
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
|
).
|
|
|
|
user_fan_out_touches_only_users_whose_activity_or_exclusion_changed_test() ->
|
|
Previous = config(#{<<"included_user_ids">> => [<<"1">>]}),
|
|
Current = config(#{
|
|
<<"included_user_ids">> => [<<"1">>, <<"2">>], <<"excluded_user_ids">> => [<<"3">>]
|
|
}),
|
|
Candidates = [{user, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{guild, 2, self()}],
|
|
?assertEqual(
|
|
[{user, 2, self()}, {user, 3, self()}],
|
|
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
|
).
|
|
|
|
user_only_change_also_recomputes_active_guilds_test() ->
|
|
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>]}),
|
|
Current = Previous#{excluded_users := #{<<"7">> => true}, config_version := 2},
|
|
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
|
|
?assertEqual(
|
|
[{viewers, 1, self()}, {user, 7, self()}],
|
|
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
|
).
|
|
|
|
guild_flip_is_not_queued_behind_a_viewer_sweep_test() ->
|
|
Sweep = [{viewers, Id, self()} || Id <- lists:seq(1, 100)],
|
|
Pending0 = channel_threads_flip:schedule(Sweep, 0, empty(), fun() -> 0 end),
|
|
Pending1 = channel_threads_flip:schedule([{guild, 500, self()}], 10, Pending0, fun() ->
|
|
0
|
|
end),
|
|
{Due, _Pending2} = channel_threads_flip:take_due(Pending1, 10, limits(10, 1000)),
|
|
?assert(lists:member({guild, 500, self()}, Due)),
|
|
?assertEqual(10, length([T || {viewers, _, _} = T <- Due])).
|
|
|
|
first_load_flips_guilds_but_not_users_test() ->
|
|
Previous = channel_threads_config:default_config(),
|
|
Current = config(#{
|
|
<<"config_version">> => 3,
|
|
<<"enabled_guild_ids">> => [<<"1">>],
|
|
<<"user_basis_points">> => 10000
|
|
}),
|
|
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
|
|
?assertEqual(
|
|
[{guild, 1, self()}],
|
|
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
|
).
|
|
|
|
version_only_change_touches_nothing_test() ->
|
|
Previous = config(#{<<"guild_basis_points">> => 10000, <<"user_basis_points">> => 10000}),
|
|
Current = Previous#{config_version := 42},
|
|
Candidates =
|
|
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
|
|
[{user, Id, self()} || Id <- lists:seq(1, 50)],
|
|
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
|
|
|
|
changes_while_disabled_touch_nothing_test() ->
|
|
Previous = config(#{<<"enabled">> => false}),
|
|
Current = config(#{
|
|
<<"enabled">> => false,
|
|
<<"enabled_guild_ids">> => [<<"1">>],
|
|
<<"included_user_ids">> => [<<"1">>],
|
|
<<"excluded_user_ids">> => [<<"2">>]
|
|
}),
|
|
Candidates = [{guild, 1, self()}, {user, 1, self()}, {user, 2, self()}],
|
|
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
|
|
|
|
disabled_config_change_skips_registry_scan_test() ->
|
|
process_registry:init(),
|
|
Entries = [{{guild, 9200001}, self()}, {{presence, 9200001}, self()}],
|
|
ets:insert(process_registry_table, Entries),
|
|
try
|
|
Previous = config(#{<<"enabled">> => false}),
|
|
Current = config(#{
|
|
<<"enabled">> => false,
|
|
<<"enabled_guild_ids">> => [<<"9200001">>],
|
|
<<"included_user_ids">> => [<<"9200001">>]
|
|
}),
|
|
State0 = #{pending => empty(), timer => undefined, healed => #{}},
|
|
?assertEqual(
|
|
{noreply, State0},
|
|
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0)
|
|
)
|
|
after
|
|
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries]
|
|
end.
|
|
|
|
global_kill_flips_every_active_guild_test() ->
|
|
Previous = config(#{<<"guild_basis_points">> => 10000}),
|
|
Current = Previous#{enabled := false, config_version := 2},
|
|
Candidates = [{guild, Id, self()} || Id <- lists:seq(1, 20)],
|
|
?assertEqual(
|
|
Candidates, channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
|
).
|
|
|
|
schedule_applies_jitter_and_dedupes_targets_test() ->
|
|
Jitters = counter_jitter([500, 100, 900]),
|
|
Pending0 = channel_threads_flip:schedule(
|
|
[{guild, 1, self()}, {guild, 2, self()}, {guild, 1, self()}, {user, 1, self()}],
|
|
1000,
|
|
empty(),
|
|
Jitters
|
|
),
|
|
?assertEqual(#{{guild, 1} => 1500, {guild, 2} => 1100, {user, 1} => 1900}, keys(Pending0)),
|
|
{Due, Pending1} = channel_threads_flip:take_due(Pending0, 1600, limits(10, 10)),
|
|
?assertEqual([{guild, 2, self()}, {guild, 1, self()}], Due),
|
|
?assertEqual(#{{user, 1} => 1900}, keys(Pending1)).
|
|
|
|
rescheduling_a_restarted_process_keeps_its_slot_and_takes_the_new_pid_test() ->
|
|
Stale = spawn(fun() -> ok end),
|
|
Pending0 = channel_threads_flip:schedule([{guild, 1, Stale}], 0, empty(), fun() -> 500 end),
|
|
Pending1 = channel_threads_flip:schedule([{guild, 1, self()}], 100, Pending0, fun() ->
|
|
900
|
|
end),
|
|
?assertEqual(#{{guild, 1} => 500}, keys(Pending1)),
|
|
?assertEqual(
|
|
{[{guild, 1, self()}], empty()},
|
|
channel_threads_flip:take_due(Pending1, 500, limits(10, 10))
|
|
).
|
|
|
|
take_due_respects_the_batch_size_test() ->
|
|
Pending0 = channel_threads_flip:schedule(
|
|
[{guild, Id, self()} || Id <- lists:seq(1, 25)], 0, empty(), fun() -> 0 end
|
|
),
|
|
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
|
|
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
|
|
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
|
|
?assertEqual([10, 10, 5], [length(First), length(Second), length(Third)]),
|
|
?assertEqual(#{}, keys(Pending3)),
|
|
?assertEqual(
|
|
lists:seq(1, 25), lists:sort([Id || {guild, Id, _} <- First ++ Second ++ Third])
|
|
).
|
|
|
|
user_flips_drain_in_large_batches_behind_throttled_guilds_test() ->
|
|
Targets =
|
|
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
|
|
[{user, Id, self()} || Id <- lists:seq(1, 2500)],
|
|
Pending0 = channel_threads_flip:schedule(Targets, 0, empty(), fun() -> 0 end),
|
|
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
|
|
?assertEqual({10, 1000}, kind_counts(First)),
|
|
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
|
|
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
|
|
?assertEqual([{10, 1000}, {10, 500}], [kind_counts(Second), kind_counts(Third)]),
|
|
?assertEqual(20, map_size(keys(Pending3))),
|
|
?assert(lists:all(fun({Kind, _Id}) -> Kind =:= guild end, maps:keys(keys(Pending3)))).
|
|
|
|
self_heal_flips_immediately_once_per_cooldown_test() ->
|
|
Pending0 = channel_threads_flip:schedule(
|
|
[{guild, 7, self()}, {guild, 8, self()}], 0, empty(), fun() -> 50000 end
|
|
),
|
|
State0 = #{pending => Pending0, timer => undefined, healed => #{}},
|
|
{Healed1, Pending1} = channel_threads_flip:heal(7, self(), 1000, State0),
|
|
?assertEqual(ok, receive_flip()),
|
|
?assertEqual(#{{guild, 8} => 50000}, keys(Pending1)),
|
|
State1 = State0#{healed := Healed1, pending := Pending1},
|
|
{Healed2, Pending2} = channel_threads_flip:heal(7, self(), 20000, State1),
|
|
?assertEqual(none, receive_flip()),
|
|
?assertEqual(Pending1, Pending2),
|
|
{_Healed3, _Pending3} = channel_threads_flip:heal(
|
|
7, self(), 40000, State1#{healed := Healed2}
|
|
),
|
|
?assertEqual(ok, receive_flip()).
|
|
|
|
config_change_flips_only_local_registry_targets_test() ->
|
|
process_registry:init(),
|
|
Parent = self(),
|
|
[LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session] =
|
|
[spawn(fun() -> forward(Parent) end) || _ <- lists:seq(1, 5)],
|
|
Remote = remote_pid(),
|
|
Entries = [
|
|
{{guild, 9100001}, LocalGuild},
|
|
{{guild, 9100002}, Remote},
|
|
{{guild, 9100003}, OtherGuild},
|
|
{{guild, 9100004}, loading},
|
|
{{guild, <<"9100005">>}, OtherGuild},
|
|
{{presence, 9100001}, LocalUser},
|
|
{{presence, 9100002}, ExcludedUser},
|
|
{{presence, 9100003}, Remote},
|
|
{{session, 9100001}, Session}
|
|
],
|
|
ets:insert(process_registry_table, Entries),
|
|
try
|
|
Previous = config(#{}),
|
|
Current = config(#{
|
|
<<"enabled_guild_ids">> => [<<"9100001">>, <<"9100002">>, <<"9100004">>],
|
|
<<"included_user_ids">> => [<<"9100001">>, <<"9100003">>],
|
|
<<"excluded_user_ids">> => [<<"9100002">>]
|
|
}),
|
|
State0 = #{pending => empty(), timer => undefined, healed => #{}},
|
|
{noreply, #{pending := Scheduled, timer := Timer}} =
|
|
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0),
|
|
_ = erlang:cancel_timer(Timer),
|
|
Expected = [
|
|
{guild, 9100001, LocalGuild},
|
|
{user, 9100001, LocalUser},
|
|
{user, 9100002, ExcludedUser}
|
|
],
|
|
?assertEqual(lists:sort(Expected), lists:sort(ours(Scheduled))),
|
|
Due = channel_threads_flip:schedule(
|
|
Expected, erlang:monotonic_time(millisecond), empty(), fun() -> 0 end
|
|
),
|
|
{noreply, #{pending := Rest}} =
|
|
channel_threads_flip:handle_info(flip_tick, State0#{pending := Due}),
|
|
?assertEqual(#{}, keys(Rest)),
|
|
Version = channel_threads_config:version(),
|
|
?assertEqual(
|
|
lists:sort([
|
|
{LocalGuild, {thread_gate_flip, Version}},
|
|
{LocalUser, {thread_user_flip, Version}},
|
|
{ExcludedUser, {thread_user_flip, Version}}
|
|
]),
|
|
lists:sort(collect(3))
|
|
),
|
|
?assertEqual([], collect(1))
|
|
after
|
|
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries],
|
|
[exit(Pid, kill) || Pid <- [LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session]]
|
|
end.
|
|
|
|
ours(#{due := Due}) ->
|
|
[
|
|
{Kind, Id, Pid}
|
|
|| KindDue <- maps:values(Due),
|
|
{_At, {Kind, Id}, Pid} <- gb_sets:to_list(KindDue),
|
|
Id div 100 =:= 91000
|
|
].
|
|
|
|
limits(Guilds, Users) ->
|
|
#{guild => Guilds, viewers => Guilds, user => Users}.
|
|
|
|
kind_counts(Targets) ->
|
|
{length([T || {guild, _, _} = T <- Targets]), length([T || {user, _, _} = T <- Targets])}.
|
|
|
|
forward(Parent) ->
|
|
receive
|
|
Msg ->
|
|
Parent ! {self(), Msg},
|
|
forward(Parent)
|
|
end.
|
|
|
|
collect(0) ->
|
|
[];
|
|
collect(N) ->
|
|
receive
|
|
{Pid, {Tag, _} = Msg} when
|
|
is_pid(Pid), (Tag =:= thread_gate_flip orelse Tag =:= thread_user_flip)
|
|
->
|
|
[{Pid, Msg} | collect(N - 1)]
|
|
after 200 -> []
|
|
end.
|
|
|
|
remote_pid() ->
|
|
Node = <<"remote@nowhere">>,
|
|
binary_to_term(
|
|
<<131, 88, 119, (byte_size(Node)), Node/binary, 1:32, 0:32, 1:32>>
|
|
).
|
|
|
|
receive_flip() ->
|
|
receive
|
|
{thread_gate_flip, Version} when is_integer(Version) -> ok
|
|
after 0 -> none
|
|
end.
|
|
|
|
counter_jitter(Values) ->
|
|
Ref = make_ref(),
|
|
put(Ref, Values),
|
|
fun() ->
|
|
[Next | Rest] = get(Ref),
|
|
put(Ref, Rest),
|
|
Next
|
|
end.
|
|
|
|
empty() ->
|
|
#{
|
|
due => #{guild => gb_sets:new(), viewers => gb_sets:new(), user => gb_sets:new()},
|
|
keys => #{}
|
|
}.
|
|
|
|
keys(#{keys := Keys}) ->
|
|
maps:map(fun(_Key, {At, _Pid}) -> At end, Keys).
|
|
|
|
config(Overrides) ->
|
|
{ok, Config} = channel_threads_config:validate_config(
|
|
maps:merge(#{<<"enabled">> => true}, Overrides)
|
|
),
|
|
Config.
|