perf(gateway): speed up reconnects and pin guilds to nodes (#3107)

This commit is contained in:
Hampus
2026-10-02 14:02:22 +02:00
committed by GitHub
parent ee2d11ee0a
commit a9265cbb39
48 changed files with 5459 additions and 453 deletions
@@ -187,6 +187,40 @@ rpc_concurrency_key_defaults_test() ->
?assertEqual(512, maps:get(gateway_nats_rpc_max_handlers, Config)),
?assertEqual(512, maps:get(gateway_http_rpc_max_concurrency, Config)).
pinned_node_defaults_keep_release_behaviour_test() ->
Config = fluxer_gateway_config:load(),
?assertEqual(true, maps:get(nats_rpc_enabled, Config)),
?assertEqual([], maps:get(pinned_guild_ids, Config)),
?assertEqual(undefined, maps:get(guild_pin_keeper_beam, Config)).
pinned_node_env_test() ->
with_envs(
[
{"FLUXER_GATEWAY_NATS_RPC_ENABLED", "false"},
{"FLUXER_GATEWAY_PINNED_GUILD_IDS", "1100000000000000001, 42"},
{"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM", "/etc/fluxer/gw/gateway_node_router.beam"},
{"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM_MD5", "D5E42B1D6D85C4CDEE93AA0CCA18A420"}
],
fun() ->
Config = fluxer_gateway_config:load(),
?assertEqual(false, maps:get(nats_rpc_enabled, Config)),
?assertEqual([42, 1100000000000000001], maps:get(pinned_guild_ids, Config)),
?assertEqual(
"/etc/fluxer/gw/gateway_node_router.beam",
maps:get(guild_pin_keeper_beam, Config)
),
?assertEqual(
<<"D5E42B1D6D85C4CDEE93AA0CCA18A420">>,
maps:get(guild_pin_keeper_beam_md5, Config)
)
end
).
pinned_guild_ids_reject_non_snowflakes_test() ->
with_env("FLUXER_GATEWAY_PINNED_GUILD_IDS", "1100000000000000001,ab", fun() ->
?assertError({invalid_pinned_guild_id, "ab"}, fluxer_gateway_config:load())
end).
optional_string_test() ->
?assertEqual(undefined, fluxer_gateway_config:optional_string(undefined)),
?assertEqual("hello", fluxer_gateway_config:optional_string(<<"hello">>)),
@@ -0,0 +1,93 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_stall_monitor_tests).
-include_lib("eunit/include/eunit.hrl").
stall_monitor_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
fun installs_and_releases_system_monitor/1,
fun counts_forwarded_events_and_names_the_process/1,
fun leaves_a_foreign_system_monitor_alone/1,
fun records_late_timer_ticks/1
]}.
setup() ->
_ = erlang:system_monitor(undefined),
application:unset_env(fluxer_gateway, stall_monitor_enabled),
ok.
cleanup(_) ->
case whereis(gateway_stall_monitor) of
undefined -> ok;
Pid -> gen_server:stop(Pid)
end,
_ = erlang:system_monitor(undefined),
ok.
installs_and_releases_system_monitor(_) ->
fun() ->
{ok, Pid} = gateway_stall_monitor:start_link(),
unlink(Pid),
{Pid, Opts} = erlang:system_monitor(),
?assertEqual(200, proplists:get_value(long_schedule, Opts)),
?assertEqual(200, proplists:get_value(long_gc, Opts)),
?assertMatch(#{status := installed}, gateway_stall_monitor:snapshot()),
ok = gen_server:stop(Pid),
?assertEqual(undefined, erlang:system_monitor())
end.
counts_forwarded_events_and_names_the_process(_) ->
fun() ->
{ok, Pid} = gateway_stall_monitor:start_link(),
unlink(Pid),
Busy = spawn(fun() ->
receive
stop -> ok
end
end),
Info = [{timeout, 1500}, {in, {guild, handle_info, 2}}, {out, {guild, handle_info, 2}}],
Pid ! {monitor, Busy, long_schedule, Info},
Pid ! {monitor, Busy, long_gc, [{timeout, 300}, {heap_size, 10}, {old_heap_size, 0}]},
#{stats := Stats, recent := Recent} = gateway_stall_monitor:snapshot(),
?assertEqual(
#{count => 1, severe => 1, max_ms => 1500}, maps:get(long_schedule, Stats)
),
?assertEqual(#{count => 1, severe => 0, max_ms => 300}, maps:get(long_gc, Stats)),
[GcEvent, SchedEvent] = Recent,
?assertMatch(#{kind := long_gc, ms := 300, info := #{heap_size := 10}}, GcEvent),
?assertMatch(
#{kind := long_schedule, who := Busy, info := #{in := {guild, handle_info, 2}}},
SchedEvent
),
?assertMatch(#{who_info := #{guild_id := undefined}}, SchedEvent),
Busy ! stop
end.
leaves_a_foreign_system_monitor_alone(_) ->
fun() ->
Foreign = spawn(fun() ->
receive
stop -> ok
end
end),
_ = erlang:system_monitor(Foreign, [{long_gc, 500}]),
{ok, Pid} = gateway_stall_monitor:start_link(),
unlink(Pid),
?assertMatch(#{status := not_owner}, gateway_stall_monitor:snapshot()),
ok = gen_server:stop(Pid),
?assertMatch({Foreign, _}, erlang:system_monitor()),
Foreign ! stop
end.
records_late_timer_ticks(_) ->
fun() ->
{ok, Pid} = gateway_stall_monitor:start_link(),
unlink(Pid),
_ = sys:replace_state(Pid, fun(S) -> S#{tick_due := maps:get(tick_due, S) - 1300} end),
Pid ! tick,
#{stats := Stats} = gateway_stall_monitor:snapshot(),
#{count := Count, max_ms := Max} = maps:get(timer_late, Stats),
?assert(Count >= 1),
?assert(Max >= 1200)
end.
@@ -479,6 +479,76 @@ stale_detection_tracks_every_permission_input_test_() ->
end)
end}.
twin_channel_engines_match_a_fresh_build_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{State0, Env} = materialize(twin_spec()),
Lists = twin_lists(),
State = lists:foldl(fun guild_member_list_channel_engine:ensure/2, State0, Lists),
Refs = [guild_member_list_channel_engine:ref(L, State) || L <- Lists],
?assertEqual(length(Lists), length(lists:usort(Refs))),
Final = lists:foldl(fun assert_engine_matches_fresh_build/2, State, Lists),
teardown(Final, Env)
end)
end}.
stale_twin_engine_is_not_cloned_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{State0, Env} = materialize(twin_spec()),
[A, B | _] = twin_lists(),
State1 = guild_member_list_channel_engine:ensure(A, State0),
Hidden = with_channels(
fun(C) ->
case integer_to_binary(channel_int_id(C)) of
A -> C#{<<"permission_overwrites">> => [hidden_overwrite()]};
_ -> C
end
end,
State1
),
[
ok = guild_member_list_channel_engine:update_user(user_id(I), A, Hidden)
|| I <- lists:seq(1, 60)
],
State2 = guild_member_list_channel_engine:ensure(B, Hidden),
?assert(guild_member_list_engine_inputs:is_stale(A, State2)),
?assertNotEqual(engine_content(A, State2), engine_content(B, State2)),
teardown(assert_engine_matches_fresh_build(B, State2), Env)
end)
end}.
twin_spec() ->
Restricted = [overwrite(role_id(1), 0, 0, view())],
single_session_spec(
renumber([
channel(?CHAN_BASE + 700, 0, null, []),
channel(?CHAN_BASE + 701, 0, null, []),
channel(?CHAN_BASE + 702, 0, null, Restricted),
channel(?CHAN_BASE + 703, 0, null, Restricted)
])
).
twin_lists() ->
[integer_to_binary(?CHAN_BASE + I) || I <- [700, 701, 702, 703]].
assert_engine_matches_fresh_build(ListId, State) ->
Built = engine_content(ListId, State),
Rebuilt = guild_member_list_channel_engine:rebuild(ListId, State),
?assertEqual(engine_content(ListId, Rebuilt), Built),
Rebuilt.
engine_content(ListId, State) ->
Ref = guild_member_list_channel_engine:ref(ListId, State),
Items = guild_member_list_engine:get_all_item_keys(Ref),
Members = [UserId || {member, UserId} <- Items],
{
guild_member_list_engine:get_counts(Ref),
guild_member_list_engine:get_groups(Ref),
Items,
[{U, guild_member_list_engine:is_member_online(Ref, U)} || U <- Members]
}.
input_changes() ->
Flip = flip_channel(),
[
@@ -123,6 +123,132 @@ enqueue_session_connect_async_allows_immediate_start_when_wait_queue_disabled_te
WorkerRefs = maps:get(session_connect_worker_refs, State1, #{}),
cleanup_worker_refs(WorkerRefs).
finalize_batch_upserts_every_result_and_frees_one_worker_test() ->
UserId = 10,
Sessions = [<<"s1">>, <<"s2">>],
State0 = (finalize_state(<<"s1">>, UserId, 1, true, #{}, sets:new(), #{UserId => 1}))#{
sessions => maps:from_list([{S, pending_entry(S, UserId)} || S <- Sessions]),
session_connect_pending => maps:from_list([{S, 1} || S <- Sessions]),
session_connect_inflight => 2
},
State1 = guild_connect_async:finalize_session_connect_batch(
[{S, 1, {ok, #{}}, finalize_computed(S, UserId)} || S <- Sessions], State0
),
?assertEqual(1, maps:get(session_connect_inflight, State1)),
?assertEqual(#{}, maps:get(session_connect_pending, State1)),
[
?assertEqual(false, maps:get(pending_connect, maps:get(S, maps:get(sessions, State1))))
|| S <- Sessions
],
?assertEqual(#{UserId => 2}, maps:get(user_session_counts, State1)),
?assertEqual(3, maps:get(UserId, maps:get(presence_subscriptions, State1))),
?assertEqual(2, length(flush_connect_results())).
finalize_batch_skips_stale_attempts_test() ->
UserId = 11,
State0 = finalize_state(<<"s1">>, UserId, 2, true, #{}, sets:new(), #{UserId => 1}),
State1 = guild_connect_async:finalize_session_connect_batch(
[{<<"s1">>, 1, {ok, #{}}, finalize_computed(<<"s1">>, UserId)}], State0
),
?assertEqual(#{<<"s1">> => 2}, maps:get(session_connect_pending, State1)),
?assertEqual(
true, maps:get(pending_connect, maps:get(<<"s1">>, maps:get(sessions, State1)))
),
?assertEqual([], flush_connect_results()).
full_guild_reconnect_fits_the_connect_queue_test() ->
GuildId = 42,
State0 = (saturated_connect_state(GuildId, <<"s-0">>, 0, 1))#{
session_connect_queue => queue:new(),
session_connect_pending => #{},
sessions => #{}
},
State1 = lists:foldl(
fun(N, Acc) ->
SessionId = integer_to_binary(N),
guild_connect_async:enqueue_session_connect_async(
GuildId,
0,
connect_request(SessionId, N),
#{},
maps:remove(session_connect_max_queue, Acc)
)
end,
State0,
lists:seq(1, 3000)
),
?assertEqual(3000, queue:len(maps:get(session_connect_queue, State1))),
?assertEqual(3000, map_size(maps:get(session_connect_pending, State1))),
receive
{guild_connect_result, GuildId, _, {error, overloaded}} ->
?assert(false, connect_dropped)
after 0 ->
ok
end.
queued_connects_spread_over_batched_workers_test() ->
ok = flush_batches(),
GuildId = 42,
Requests = [connect_request(integer_to_binary(N), N) || N <- lists:seq(1, 20)],
State0 = #{
id => GuildId,
sessions => #{},
session_connect_queue => queue:from_list([
#{guild_id => GuildId, attempt => 0, request => R, reply_via_pid => undefined}
|| R <- Requests
]),
session_connect_pending => #{},
session_connect_inflight => 0,
data => #{},
member_count => 0,
voice_states => #{},
member_list_engine => undefined,
virtual_channel_access => #{}
},
State1 = guild_connect_async:maybe_start_session_connect_workers(State0),
WorkerRefs = maps:get(session_connect_worker_refs, State1),
?assertEqual(8, maps:get(session_connect_inflight, State1)),
?assertEqual(8, map_size(WorkerRefs)),
?assertEqual(0, queue:len(maps:get(session_connect_queue, State1))),
Batches = [receive_batch() || _ <- lists:seq(1, 8)],
?assertEqual(
lists:sort([maps:get(session_id, R) || R <- Requests]),
lists:sort([SessionId || Batch <- Batches, {SessionId, 0, _, _} <- Batch])
),
cleanup_worker_refs(WorkerRefs).
pending_entry(SessionId, UserId) ->
#{
session_id => SessionId,
user_id => UserId,
pid => self(),
mref => make_ref(),
pending_connect => true,
active_guilds => sets:new()
}.
receive_batch() ->
receive
{'$gen_cast', {session_connect_worker_batch_done, Results}} -> Results
after 5000 ->
?assert(false, connect_batch_not_received)
end.
flush_batches() ->
receive
{'$gen_cast', {session_connect_worker_batch_done, _Results}} -> flush_batches()
after 100 ->
ok
end.
flush_connect_results() ->
receive
{guild_connect_result, _GuildId, _Attempt, _Reply} = Msg ->
[Msg | flush_connect_results()]
after 0 ->
[]
end.
finalize_state(SessionId, UserId, Attempt, PendingConnect, Counts, Connected, PresenceSubs) ->
Existing = #{
session_id => SessionId,
+29
View File
@@ -81,6 +81,35 @@ read_model_observes_role_and_collection_changes_test() ->
cleanup_read_model(State)
end.
read_model_observes_last_message_and_pin_advances_test() ->
State = read_model_state(),
try
ok = guild_read_model:put_state(State),
Data = maps:get(data, State),
Advanced = guild_state_channels:handle_message_create(
#{<<"channel_id">> => <<"500">>, <<"id">> => <<"900">>}, Data
),
Pinned = guild_state_channels:handle_channel_pins_update(
#{
<<"channel_id">> => <<"500">>,
<<"last_pin_timestamp">> => <<"2026-10-02T00:00:00Z">>
},
Advanced
),
Updated = State#{data => Pinned},
ok = guild_read_model:update(State, Updated),
{reply, Expected, _} = guild_data:get_guild_data(#{user_id => 200}, Updated),
?assertEqual(
{ok, Expected}, guild_read_model:query(100, {get_guild_data, #{user_id => 200}})
),
#{guild_data := #{<<"channels">> := Channels}} = Expected,
[Channel] = [C || C <- Channels, maps:get(<<"id">>, C) =:= 500],
?assertEqual(900, maps:get(<<"last_message_id">>, Channel)),
?assertEqual(<<"2026-10-02T00:00:00Z">>, maps:get(<<"last_pin_timestamp">>, Channel))
after
cleanup_read_model(State)
end.
read_model_survives_blocked_owner_and_rejects_dead_tables_test() ->
State = read_model_state(),
Self = self(),
@@ -83,6 +83,17 @@ guild_wide_visibility_change_reevaluates_test() ->
?assertEqual({dm_partner_mutual, ?GUILD_ID, [30]}, receive_mutual())
end).
partner_role_change_after_registration_reevaluates_test() ->
with_flag(true, fun() ->
Before = register_partners(state(), [30]),
?assertEqual(none, receive_mutual()),
After = with_member(member(30, [?VIEWER_ROLE]), Before),
_ = guild_dm_partners:maybe_reevaluate(
guild_member_update, #{<<"user">> => #{<<"id">> => <<"30">>}}, Before, After
),
?assertEqual({dm_partner_mutual, ?GUILD_ID, [30]}, receive_mutual())
end).
disconnected_sessions_are_dropped_on_reevaluation_test() ->
with_flag(true, fun() ->
Before = register_partners(state(), [20]),
@@ -136,6 +147,11 @@ members() ->
without_member(UserId, #{data := Data} = State) ->
State#{data => Data#{<<"members">> => maps:remove(UserId, maps:get(<<"members">>, Data))}}.
with_member(Member, #{data := Data} = State) ->
#{<<"user">> := #{<<"id">> := RawId}} = Member,
Members = maps:get(<<"members">>, Data),
State#{data => Data#{<<"members">> => Members#{binary_to_integer(RawId) => Member}}}.
with_channels(Channels, #{data := Data} = State) ->
State#{
data => Data#{
@@ -0,0 +1,468 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_handoff_freeze_tests).
-behaviour(gen_server).
-include_lib("eunit/include/eunit.hrl").
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-define(GUILD_ID, 4242).
-define(SHARD_TABLE, guild_manager_shard_table).
-define(SINK, guild_handoff_freeze_tests_sink).
transfer_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
instantiate(fun frozen_backlog_reaches_target_and_source_applies_nothing/1),
instantiate(fun final_window_call_fails_unapplied_and_cast_is_kept/1),
instantiate(fun route_failure_restores_routes_resumes_source_and_stops_target/1),
instantiate(fun controller_death_before_start_resumes_source/1),
instantiate(fun controller_death_after_start_kills_target_and_resumes_source/1),
instantiate(fun target_death_before_commit_keeps_source/1),
instantiate(fun sessions_stay_behind_when_not_transferred/1),
instantiate(fun cast_during_source_terminate_reaches_target/1),
instantiate(fun monitor_flush_in_source_terminate_loses_no_cast/1),
instantiate(fun connect_worker_results_stay_with_the_source/1),
instantiate(fun abort_after_forwarding_reports_the_forwarded_count/1),
instantiate(fun late_source_stop_keeps_the_committed_target/1),
instantiate(fun controller_death_after_commit_keeps_the_target/1),
instantiate(fun lost_guard_aborts_before_other_nodes_route_to_the_target/1)
]}.
instantiate(Test) ->
fun(Ctx) -> {timeout, 30, fun() -> Test(Ctx) end} end.
frozen_backlog_reaches_target_and_source_applies_nothing(#{src := Src, src_shard := SrcShard}) ->
Test = self(),
BeforeStart = fun() ->
gen_server:cast(Src, {append, b}),
spawn(fun() -> Test ! {call_reply, gen_server:call(Src, {append_call, c}, 30000)} end),
wait_mailbox(Src, 2)
end,
Result = guild_handoff_freeze:transfer(
?GUILD_ID, Src, SrcShard, node(), #{before_start => BeforeStart}
),
?assertMatch({ok, #{new_pid := _}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual({call_reply, {ok, NewPid}}, receive_tagged(call_reply)),
?assertEqual([{Src, {shutdown, handoff}, [a]}], ets:lookup(?SINK, Src)),
?assertEqual([a, b, c], gen_server:call(NewPid, get_log)),
?assertEqual(false, is_process_alive(Src)).
final_window_call_fails_unapplied_and_cast_is_kept(#{src := Src, src_shard := SrcShard}) ->
Test = self(),
BeforeStart = fun() ->
gen_server:cast(Src, {append, b}),
spawn(fun() -> Test ! {call_reply, call_or_exit(Src, {append_call, c})} end),
wait_mailbox(Src, 2)
end,
Opts = #{before_start => BeforeStart, forward_rounds => 0},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({ok, #{final := #{cast := 1, dropped_call := 1}}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertMatch({call_reply, {exit, {{shutdown, handoff}, _}}}, receive_tagged(call_reply)),
?assertEqual([a, b], gen_server:call(NewPid, get_log)).
route_failure_restores_routes_resumes_source_and_stops_target(#{
src := Src, src_shard := SrcShard, dst_shard := DstShard
}) ->
Test = self(),
Opts = #{
before_start => fun() -> gen_server:cast(Src, {append, b}) end,
after_start => fun(_NewPid) -> {error, route_rejected} end,
on_abort => fun() ->
Test ! routes_restored,
ok
end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({error, #{phase := after_start, reason := route_rejected}}, Result),
?assertEqual(routes_restored, receive_tagged(routes_restored)),
?assertEqual({error, not_found}, gen_server:call(DstShard, {lookup, ?GUILD_ID})),
?assertEqual([a, b], gen_server:call(Src, get_log)),
?assertEqual(false, guild_handoff_freeze:is_frozen(Src)).
controller_death_before_start_resumes_source(#{src := Src, src_shard := SrcShard}) ->
Opts = #{
before_start => fun() ->
gen_server:cast(Src, {append, b}),
exit(self(), kill)
end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({error, #{phase := crashed, reason := killed}}, Result),
?assertEqual([a, b], gen_server:call(Src, get_log, 5000)).
controller_death_after_start_kills_target_and_resumes_source(#{
src := Src, src_shard := SrcShard, dst_shard := DstShard
}) ->
Test = self(),
Opts = #{
after_start => fun(NewPid) ->
Test ! {target, NewPid},
exit(self(), kill)
end,
on_abort => fun() ->
Test ! routes_restored,
ok
end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({error, #{phase := crashed, abort := #{routes := ok, target := ok}}}, Result),
?assertEqual(routes_restored, receive_tagged(routes_restored)),
{target, NewPid} = receive_tagged(target),
?assertEqual(ok, wait_dead(NewPid)),
?assertEqual({error, not_found}, gen_server:call(DstShard, {lookup, ?GUILD_ID})),
?assertEqual([a], gen_server:call(Src, get_log, 5000)).
target_death_before_commit_keeps_source(#{src := Src, src_shard := SrcShard}) ->
Opts = #{
after_start => fun(NewPid) ->
exit(NewPid, kill),
wait_dead(NewPid)
end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({error, #{phase := target_died, reason := killed}}, Result),
?assertEqual([a], gen_server:call(Src, get_log, 5000)),
?assertEqual(false, guild_handoff_freeze:is_frozen(Src)).
sessions_stay_behind_when_not_transferred(#{src := Src, src_shard := SrcShard}) ->
ok = gen_server:call(Src, {put_session, <<"s1">>, #{pid => self(), user_id => 7}}),
Opts = #{transfer_sessions => false},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({ok, #{new_pid := _}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual(#{}, gen_server:call(NewPid, get_sessions)),
?assertEqual([a], gen_server:call(NewPid, get_log)).
cast_during_source_terminate_reaches_target(#{src := Src, src_shard := SrcShard}) ->
ok = gen_server:call(Src, cast_late_on_terminate),
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), #{}),
?assertMatch({ok, #{final := #{cast := 1}}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual([a, late], gen_server:call(NewPid, get_log)).
monitor_flush_in_source_terminate_loses_no_cast(#{src := Src, src_shard := SrcShard}) ->
ok = gen_server:call(Src, flush_down_on_terminate),
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), #{}),
?assertMatch({ok, #{final := #{cast := 1}}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual([a, late], gen_server:call(NewPid, get_log)).
connect_worker_results_stay_with_the_source(#{src := Src, src_shard := SrcShard}) ->
BeforeStart = fun() ->
gen_server:cast(Src, {session_connect_worker_done, <<"s1">>, 1, {ok, #{}}, #{}}),
gen_server:cast(
Src, {session_connect_worker_batch_done, [{<<"s1">>, 1, {ok, #{}}, #{}}]}
),
gen_server:cast(Src, {append, b}),
wait_mailbox(Src, 3)
end,
Result = guild_handoff_freeze:transfer(
?GUILD_ID, Src, SrcShard, node(), #{before_start => BeforeStart}
),
?assertMatch({ok, #{new_pid := _}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual([a, b], gen_server:call(NewPid, get_log)).
abort_after_forwarding_reports_the_forwarded_count(#{src := Src, src_shard := SrcShard}) ->
BeforeStart = fun() ->
gen_server:cast(Src, crash),
wait_mailbox(Src, 1)
end,
Result = guild_handoff_freeze:transfer(
?GUILD_ID, Src, SrcShard, node(), #{before_start => BeforeStart}
),
?assertMatch(
{error, #{phase := target_died, abort := #{forwarded := 1, thaw := ok}}}, Result
).
late_source_stop_keeps_the_committed_target(#{
src := Src, src_shard := SrcShard, dst_shard := DstShard
}) ->
Opts = #{
stop_timeout => 100,
after_start => fun(_NewPid) -> hold(SrcShard, 600) end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch({ok, #{new_pid := _}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual(ok, wait_dead(Src)),
?assert(is_process_alive(NewPid)),
?assertEqual({ok, NewPid}, gen_server:call(DstShard, {lookup, ?GUILD_ID})),
?assertEqual([a], gen_server:call(NewPid, get_log)).
controller_death_after_commit_keeps_the_target(#{
src := Src, src_shard := SrcShard, dst_shard := DstShard
}) ->
Test = self(),
Opts = #{
after_start => fun(NewPid) ->
Test ! {started, self(), NewPid},
hold(SrcShard, 1000)
end
},
spawn(fun() ->
Test ! {result, guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts)}
end),
{started, Controller, NewPid} = receive_tagged(started),
ok = wait_mailbox(SrcShard, 1),
exit(Controller, kill),
{result, Result} = receive_tagged(result),
?assertMatch({error, #{phase := crashed, committed := true}}, Result),
?assertEqual(ok, wait_dead(Src)),
?assert(is_process_alive(NewPid)),
?assertEqual({ok, NewPid}, gen_server:call(DstShard, {lookup, ?GUILD_ID})).
lost_guard_aborts_before_other_nodes_route_to_the_target(#{
src := Src, src_shard := SrcShard, dst_shard := DstShard
}) ->
Test = self(),
Opts = #{
before_start => fun() ->
{monitored_by, Watchers} = process_info(self(), monitored_by),
[exit(W, kill) || W <- Watchers, W =/= Test],
ok
end,
after_start => fun(_NewPid) ->
Test ! routed,
ok
end
},
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
?assertMatch(
{error, #{
phase := after_start, reason := {guard_lost, killed}, abort := #{exposed := false}
}},
Result
),
?assertEqual({timeout, routed}, receive_tagged(routed, 200)),
?assertEqual({error, not_found}, gen_server:call(DstShard, {lookup, ?GUILD_ID})),
?assertEqual(false, guild_handoff_freeze:is_frozen(Src)),
?assertEqual([a], gen_server:call(Src, get_log, 5000)).
hold(Pid, Ms) ->
Test = self(),
spawn(fun() ->
true = erlang:suspend_process(Pid),
Test ! {held, Pid},
timer:sleep(Ms),
true = erlang:resume_process(Pid)
end),
receive
{held, Pid} -> ok
end.
export_drops_only_keys_the_importer_rebuilds_test() ->
Raw = #{
<<"guild">> => #{<<"id">> => <<"4242">>},
<<"roles">> => [#{<<"id">> => <<"4242">>, <<"permissions">> => <<"1024">>}],
<<"channels">> => [#{<<"id">> => <<"77">>, <<"type">> => 0}],
<<"members">> => [
#{<<"user">> => #{<<"id">> => <<"5">>}, <<"roles">> => []},
#{<<"user">> => #{<<"id">> => <<"6">>}, <<"roles">> => [<<"4242">>]}
]
},
Full = guild_data_index:normalize_map(Raw),
State = #{id => ?GUILD_ID, data => Full#{members_ets => make_ref()}, sessions => #{}},
Slim = maps:get(data, guild_handoff:export_handoff_state(State)),
?assertEqual([], [K || K <- guild_handoff:derived_data_keys(), maps:is_key(K, Slim)]),
Rebuild = fun(D) ->
maps:remove(member_list_revision, guild_data_index:normalize_map(D))
end,
?assertEqual(Rebuild(Full), Rebuild(Slim)).
export_keeps_channel_fields_that_only_the_index_holds_test() ->
Raw = #{
<<"guild">> => #{<<"id">> => <<"4242">>},
<<"channels">> => [
#{<<"id">> => <<"77">>, <<"type">> => 0, <<"last_message_id">> => <<"10">>}
]
},
Full = guild_state_channels:handle_message_create(
#{<<"channel_id">> => <<"77">>, <<"id">> => <<"900">>},
guild_data_index:normalize_map(Raw)
),
State = #{id => ?GUILD_ID, data => Full, sessions => #{}},
Slim = maps:get(data, guild_handoff:export_handoff_state(State)),
?assertMatch({rebuilt, _}, bounded_rebuild(Slim)),
{rebuilt, Rebuilt} = bounded_rebuild(Slim),
[Channel] = guild_data_index:channel_list(Rebuilt),
?assertEqual(900, maps:get(<<"last_message_id">>, Channel)).
bounded_rebuild(Data) ->
{Pid, Ref} = spawn_monitor(fun() ->
process_flag(max_heap_size, #{size => 4000000, kill => true, error_logger => false}),
exit({rebuilt, guild_data_index:normalize_map(Data)})
end),
receive
{'DOWN', Ref, process, Pid, Reason} -> Reason
after 10000 ->
exit(Pid, kill),
timeout
end.
setup() ->
ets:new(?SHARD_TABLE, [named_table, public, set]),
ets:new(?SINK, [named_table, public, set]),
{ok, SrcShard} = gen_server:start(?MODULE, shard, []),
{ok, DstShard} = gen_server:start(?MODULE, shard, []),
Index = guild_manager_shards:select_shard(?GUILD_ID, 1),
ets:insert(?SHARD_TABLE, [{shard_count, 1}, {{shard_pid, Index}, DstShard}]),
{ok, Src} = gen_server:call(
SrcShard, {start_transferred, ?GUILD_ID, guild_export([a])}
),
#{src => Src, src_shard => SrcShard, dst_shard => DstShard}.
cleanup(#{src_shard := SrcShard, dst_shard := DstShard}) ->
Guilds = lists:append([gen_server:call(S, all_guilds) || S <- [SrcShard, DstShard]]),
[exit(G, kill) || G <- Guilds],
[gen_server:stop(S) || S <- [SrcShard, DstShard]],
ets:delete(?SHARD_TABLE),
ets:delete(?SINK),
flush().
guild_export(Log) ->
#{
id => ?GUILD_ID,
data => #{<<"log">> => Log},
sessions => #{},
voice_states => #{}
}.
init(shard) ->
{ok, #{role => shard, guilds => #{}}};
init({guild, Export}) ->
process_flag(trap_exit, true),
{ok, Export#{role => guild}}.
handle_call({start_transferred, GuildId, Export}, _From, #{role := shard} = State) ->
{ok, Pid} = gen_server:start(?MODULE, {guild, Export}, []),
{reply, {ok, Pid}, put_guild(GuildId, Pid, State)};
handle_call({stop_guild, GuildId}, From, #{role := shard} = State) ->
handle_call({stop_guild, GuildId, normal}, From, State);
handle_call({stop_guild, GuildId, Reason}, _From, #{role := shard, guilds := Guilds} = State) ->
case maps:get(GuildId, Guilds, undefined) of
Pid when is_pid(Pid) -> stop_quietly(Pid, Reason);
undefined -> ok
end,
{reply, ok, State#{guilds => maps:remove(GuildId, Guilds)}};
handle_call({lookup, GuildId}, _From, #{role := shard, guilds := Guilds} = State) ->
case maps:get(GuildId, Guilds, undefined) of
Pid when is_pid(Pid) ->
case is_process_alive(Pid) of
true -> {reply, {ok, Pid}, State};
false -> {reply, {error, not_found}, State}
end;
undefined ->
{reply, {error, not_found}, State}
end;
handle_call(all_guilds, _From, #{role := shard, guilds := Guilds} = State) ->
{reply, maps:values(Guilds), State};
handle_call({get_guild_id}, _From, #{role := guild, id := Id} = State) ->
{reply, Id, State};
handle_call({put_session, Id, Session}, _From, #{role := guild, sessions := Sessions} = State) ->
{reply, ok, State#{sessions => Sessions#{Id => Session}}};
handle_call(flush_down_on_terminate, _From, #{role := guild} = State) ->
Helper = spawn(fun() ->
receive
stop -> ok
end
end),
{reply, ok, State#{flush_down => {Helper, erlang:monitor(process, Helper)}}};
handle_call(cast_late_on_terminate, _From, #{role := guild} = State) ->
{reply, ok, State#{cast_late => true}};
handle_call(get_sessions, _From, #{role := guild, sessions := Sessions} = State) ->
{reply, Sessions, State};
handle_call(get_log, _From, #{role := guild} = State) ->
{reply, log(State), State};
handle_call({append_call, Item}, _From, #{role := guild} = State) ->
{reply, {ok, self()}, append(Item, State)}.
handle_cast({append, Item}, #{role := guild} = State) ->
{noreply, append(Item, State)};
handle_cast(crash, #{role := guild} = State) ->
{stop, crashed, State}.
handle_info({'EXIT', _Pid, Reason}, #{role := guild} = State) ->
{stop, Reason, State};
handle_info(_Msg, State) ->
{noreply, State}.
terminate(Reason, #{role := guild} = State) ->
ets:insert(?SINK, {self(), Reason, log(State)}),
case maps:get(cast_late, State, false) of
true ->
gen_server:cast(self(), {append, late}),
timer:sleep(200);
false ->
ok
end,
case maps:get(flush_down, State, undefined) of
{Helper, Ref} ->
exit(Helper, kill),
timer:sleep(100),
erlang:demonitor(Ref, [flush]),
gen_server:cast(self(), {append, late}),
timer:sleep(200);
undefined ->
ok
end;
terminate(_Reason, _State) ->
ok.
put_guild(GuildId, Pid, #{guilds := Guilds} = State) ->
State#{guilds => Guilds#{GuildId => Pid}}.
log(#{data := Data}) ->
maps:get(<<"log">>, Data).
append(Item, #{data := Data} = State) ->
State#{data => Data#{<<"log">> => log(State) ++ [Item]}}.
call_or_exit(Pid, Request) ->
try
gen_server:call(Pid, Request, 30000)
catch
exit:Reason -> {exit, Reason}
end.
stop_quietly(Pid, Reason) ->
try
gen_server:stop(Pid, Reason, 5000)
catch
exit:_ -> ok
end.
wait_mailbox(Pid, N) ->
case process_info(Pid, message_queue_len) of
{message_queue_len, Len} when Len >= N ->
ok;
_ ->
timer:sleep(5),
wait_mailbox(Pid, N)
end.
wait_dead(Pid) ->
Ref = erlang:monitor(process, Pid),
receive
{'DOWN', Ref, process, Pid, _} -> ok
after 5000 -> still_alive
end.
receive_tagged(Tag) ->
receive_tagged(Tag, 10000).
receive_tagged(Tag, Timeout) ->
receive
Msg when element(1, Msg) =:= Tag -> Msg;
Tag -> Tag
after Timeout -> {timeout, Tag}
end.
flush() ->
receive
_ -> flush()
after 0 -> ok
end.
+9 -2
View File
@@ -36,7 +36,13 @@ restore_transferred_session_state_rebuilds_connected_counts_test() ->
<<"s1">> => #{pid => self(), user_id => UserId},
<<"s2">> => #{pid => self(), user_id => UserId, pending_connect => false},
<<"pending">> => #{pid => self(), user_id => 11, pending_connect => true},
<<"bad">> => #{user_id => 12}
<<"bad">> => #{user_id => 12},
<<"reconnecting">> => #{
pid => self(),
user_id => 13,
pending_connect => true,
owns_connected_tracking => true
}
},
State0 = #{
sessions => Sessions,
@@ -45,10 +51,11 @@ restore_transferred_session_state_rebuilds_connected_counts_test() ->
presence_subscriptions => #{UserId => 1}
},
State1 = guild_handoff:restore_transferred_session_state(State0),
?assertEqual(#{UserId => 2}, maps:get(user_session_counts, State1)),
?assertEqual(#{UserId => 2, 13 => 1}, maps:get(user_session_counts, State1)),
Connected = maps:get(connected_user_ids, State1),
?assertEqual(true, sets:is_element(UserId, Connected)),
?assertEqual(false, sets:is_element(11, Connected)),
?assertEqual(true, sets:is_element(13, Connected)),
?assertEqual(3, maps:get(UserId, maps:get(presence_subscriptions, State1))).
export_handoff_state_contains_all_required_keys_test() ->
@@ -69,6 +69,35 @@ handoff_guild_ids_counts_attempts_and_successes_test() ->
?assertEqual(#{attempted => 2, handed_off => 1}, Result),
?assertEqual(3, maps:get(shard_count, FinalState)).
handoff_to_topology_keeps_guild_the_router_owns_here_test() ->
GuildId = 77,
ShardPid = spawn(fun() -> local_ids_stub_loop([GuildId]) end),
State = #{shards => #{0 => #{pid => ShardPid, ref => make_ref()}}, shard_count => 1},
persistent_term:put({gateway_cluster_membership, members}, [node()]),
persistent_term:put({gateway_cluster_membership, members_by_role}, #{guilds => [node()]}),
try
{Result, _State} = guild_manager_handoff:perform_handoff_to_topology(
['[email protected]'], State
),
?assertEqual(#{attempted => 0, handed_off => 0}, Result)
after
ShardPid ! stop,
persistent_term:erase({gateway_cluster_membership, members}),
persistent_term:erase({gateway_cluster_membership, members_by_role})
end.
local_ids_stub_loop(GuildIds) ->
receive
stop ->
ok;
{'$gen_call', From, get_local_guild_ids} ->
gen_server:reply(From, {ok, GuildIds}),
local_ids_stub_loop(GuildIds);
{'$gen_call', From, _Request} ->
gen_server:reply(From, {error, not_found}),
local_ids_stub_loop(GuildIds)
end.
find_shard_by_ref_found_test() ->
Ref = make_ref(),
Shards = #{0 => #{pid => self(), ref => Ref}},
@@ -153,8 +153,7 @@ subscribe_and_drain(GuildPid, Requests) ->
end,
Requests
),
GuildPid ! flush_lazy_subscribe_buffer,
SubscribedState = get_guild_state(GuildPid),
SubscribedState = drain_lazy_subscribe_buffer(GuildPid),
assert_requests_subscribed(Requests, SubscribedState),
await_initial_syncs(Requests, SubscribedState),
_ = collect_captures(100),
@@ -259,6 +258,14 @@ presence_update(GuildPid, UserId, Status) ->
_ = gen_server:call(GuildPid, {get_counts}, 60000),
ok.
drain_lazy_subscribe_buffer(GuildPid) ->
GuildPid ! flush_lazy_subscribe_buffer,
State = get_guild_state(GuildPid),
case maps:is_key(lazy_subscribe_buffer, State) of
true -> drain_lazy_subscribe_buffer(GuildPid);
false -> State
end.
get_guild_state(GuildPid) ->
gen_server:call(GuildPid, {get_sessions}, 60000).
@@ -0,0 +1,396 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_session_tracking_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD_ID, 910100).
-define(CHANNEL_ID, 910500).
-define(USER_A, 810001).
-define(USER_B, 810002).
-define(USER_C, 810003).
handoff_reconnect_counts_each_session_once_test_() ->
{timeout, 60, fun handoff_reconnect_counts_each_session_once/0}.
handoff_reconnect_overflow_keeps_tracking_test_() ->
{timeout, 60, fun handoff_reconnect_overflow_keeps_tracking/0}.
reconnect_of_connected_session_counts_once_test_() ->
{timeout, 60, fun reconnect_of_connected_session_counts_once/0}.
not_member_reconnect_releases_tracking_test_() ->
{timeout, 60, fun not_member_reconnect_releases_tracking/0}.
reconnecting_session_down_releases_tracking_test_() ->
{timeout, 60, fun reconnecting_session_down_releases_tracking/0}.
fresh_pending_down_keeps_reconnecting_owner_tracked_test_() ->
{timeout, 60, fun fresh_pending_down_keeps_reconnecting_owner_tracked/0}.
unavailable_removal_of_fresh_pending_keeps_owner_tracked_test_() ->
{timeout, 60, fun unavailable_removal_of_fresh_pending_keeps_owner_tracked/0}.
fresh_pending_sibling_keeps_mutual_members_test_() ->
{timeout, 60, fun fresh_pending_sibling_keeps_mutual_members/0}.
handoff_reconnect_counts_each_session_once() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"a2">>, ?USER_A}, {<<"b1">>, ?USER_B}],
fun(Sessions) ->
with_guild(base_state(Sessions), fun(Source) ->
await_tracking(#{?USER_A => 2, ?USER_B => 1}, Source),
{ok, Exported} = gen_server:call(Source, export_handoff_state, 10000),
with_guild(transferred_state(Exported), fun(Target) ->
await_tracking(#{?USER_A => 2, ?USER_B => 1}, Target),
reconnect_all(Target, Sessions, 0),
await_tracking(#{?USER_A => 2, ?USER_B => 1}, Target),
kill_session(<<"b1">>, Sessions),
await_tracking(#{?USER_A => 2}, Target),
kill_session(<<"a1">>, Sessions),
await_tracking(#{?USER_A => 1}, Target),
kill_session(<<"a2">>, Sessions),
await_tracking(#{}, Target)
end)
end)
end
).
handoff_reconnect_overflow_keeps_tracking() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"b1">>, ?USER_B}, {<<"c1">>, ?USER_C}],
fun(Sessions) ->
with_guild(base_state(Sessions), fun(Source) ->
{ok, Exported} = gen_server:call(Source, export_handoff_state, 10000),
with_guild(transferred_state(Exported), fun(Target) ->
Expected = #{?USER_A => 1, ?USER_B => 1, ?USER_C => 1},
await_tracking(Expected, Target),
hold_connect_workers(Target, 1),
[
send_connect(Target, Sid, Sessions, 0)
|| Sid <- [<<"a1">>, <<"b1">>, <<"c1">>]
],
?assertEqual({error, overloaded}, await_result(<<"a1">>, 0)),
?assertEqual({error, overloaded}, await_result(<<"b1">>, 0)),
State = get_state(Target),
Dropped = maps:get(<<"a1">>, maps:get(sessions, State)),
?assertEqual(true, maps:get(pending_connect, Dropped)),
?assertNot(maps:is_key(<<"a1">>, maps:get(session_connect_pending, State))),
assert_tracking(Expected, State),
release_connect_workers(Target),
?assertMatch({ok, _, _}, await_result(<<"c1">>, 0)),
send_connect(Target, <<"a1">>, Sessions, 1),
send_connect(Target, <<"b1">>, Sessions, 1),
?assertMatch({ok, _, _}, await_result(<<"a1">>, 1)),
?assertMatch({ok, _, _}, await_result(<<"b1">>, 1)),
await_tracking(Expected, Target),
kill_session(<<"a1">>, Sessions),
await_tracking(#{?USER_B => 1, ?USER_C => 1}, Target)
end)
end)
end
).
reconnect_of_connected_session_counts_once() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"b1">>, ?USER_B}],
fun(Sessions) ->
with_guild(base_state(Sessions), fun(Guild) ->
reconnect_all(Guild, Sessions, 1),
reconnect_all(Guild, Sessions, 2),
State = get_state(Guild),
assert_tracking(#{?USER_A => 1, ?USER_B => 1}, State),
[
?assertEqual(false, maps:get(pending_connect, Entry))
|| Entry <- maps:values(maps:get(sessions, State))
],
kill_session(<<"b1">>, Sessions),
await_tracking(#{?USER_A => 1}, Guild)
end)
end
).
not_member_reconnect_releases_tracking() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"b1">>, ?USER_B}],
fun(Sessions) ->
with_guild(base_state(Sessions), fun(Guild) ->
sys:replace_state(Guild, fun(S) ->
S#{data => guild_data_index:remove_member(?USER_B, maps:get(data, S))}
end),
send_connect(Guild, <<"b1">>, Sessions, 1),
?assertEqual({error, not_member}, await_result(<<"b1">>, 1)),
State = get_state(Guild),
?assertNot(maps:is_key(<<"b1">>, maps:get(sessions, State))),
assert_tracking(#{?USER_A => 1}, State)
end)
end
).
reconnecting_session_down_releases_tracking() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"b1">>, ?USER_B}],
fun(Sessions) ->
with_guild(base_state(Sessions), fun(Guild) ->
hold_connect_workers(Guild, 16),
send_connect(Guild, <<"b1">>, Sessions, 1),
Pending = maps:get(<<"b1">>, maps:get(sessions, get_state(Guild))),
?assertEqual(true, maps:get(pending_connect, Pending)),
await_tracking(#{?USER_A => 1, ?USER_B => 1}, Guild),
kill_session(<<"b1">>, Sessions),
await_tracking(#{?USER_A => 1}, Guild)
end)
end
).
fresh_pending_down_keeps_reconnecting_owner_tracked() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"a2">>, ?USER_A}, {<<"c1">>, ?USER_C}],
fun(Sessions) ->
with_guild(base_state(without(<<"a1">>, Sessions)), fun(Guild) ->
hold_connect_workers(Guild, 16),
send_connect(Guild, <<"a2">>, Sessions, 1),
send_connect(Guild, <<"a1">>, Sessions, 1),
kill_session(<<"a1">>, Sessions),
await_tracking(#{?USER_A => 1, ?USER_C => 1}, Guild),
release_connect_workers(Guild),
?assertMatch({ok, _, _}, await_result(<<"a2">>, 1)),
await_tracking(#{?USER_A => 1, ?USER_C => 1}, Guild),
kill_session(<<"a2">>, Sessions),
await_tracking(#{?USER_C => 1}, Guild)
end)
end
).
unavailable_removal_of_fresh_pending_keeps_owner_tracked() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"a2">>, ?USER_A}],
fun(Sessions) ->
with_guild(base_state(without(<<"a1">>, Sessions)), fun(Guild) ->
hold_connect_workers(Guild, 16),
send_connect(Guild, <<"a1">>, Sessions, 1),
sys:replace_state(Guild, fun(S) ->
guild_sessions:remove_session(<<"a1">>, S)
end),
State = get_state(Guild),
?assertNot(maps:is_key(<<"a1">>, maps:get(sessions, State))),
assert_tracking(#{?USER_A => 1}, State)
end)
end
).
fresh_pending_sibling_keeps_mutual_members() ->
with_sessions(
[{<<"a1">>, ?USER_A}, {<<"a2">>, ?USER_A}, {<<"c1">>, ?USER_C}],
fun(Sessions) ->
with_guild(base_state(without(<<"a1">>, Sessions)), fun(Guild) ->
send_connect(Guild, <<"a2">>, Sessions, 1),
?assertMatch({ok, _, _}, await_result(<<"a2">>, 1)),
hold_connect_workers(Guild, 16),
send_connect(Guild, <<"a1">>, Sessions, 1),
State = get_state(Guild),
SessionMap = maps:get(<<"a2">>, maps:get(sessions, State)),
?assertEqual(
#{?CHANNEL_ID => true}, maps:get(viewable_channels, SessionMap)
),
?assertEqual(
true,
maps:get(pending_connect, maps:get(<<"a1">>, maps:get(sessions, State)))
),
?assertEqual(
#{?CHANNEL_ID => true},
guild_visibility_channels:get_cached_viewable_channel_map(?USER_A, State)
),
?assertEqual(
[?USER_C],
guild_subscription_mutual_channels:filter_member_ids(
?USER_A, [?USER_C], State
)
)
end)
end
).
await_tracking(Expected, Guild) ->
await_tracking(Expected, Guild, 100).
await_tracking(Expected, Guild, 0) ->
assert_tracking(Expected, get_state(Guild));
await_tracking(Expected, Guild, Tries) ->
case tracking(get_state(Guild)) =:= expected_tracking(Expected) of
true ->
ok;
false ->
timer:sleep(20),
await_tracking(Expected, Guild, Tries - 1)
end.
assert_tracking(Expected, State) ->
?assertEqual(expected_tracking(Expected), tracking(State)).
expected_tracking(Expected) ->
{Expected, lists:sort(maps:keys(Expected)), Expected}.
tracking(State) ->
{
maps:get(user_session_counts, State),
lists:sort(sets:to_list(maps:get(connected_user_ids, State))),
maps:get(presence_subscriptions, State)
}.
with_sessions(Specs, Fun) ->
Parent = self(),
Sessions = [
{Sid, UserId, spawn(fun() -> session_loop(Parent, Sid) end)}
|| {Sid, UserId} <- Specs
],
try
Fun(Sessions)
after
[exit(Pid, kill) || {_, _, Pid} <- Sessions],
flush()
end.
session_loop(Parent, Sid) ->
receive
{guild_connect_result, _GuildId, Attempt, Reply} ->
Parent ! {connect_result, Sid, Attempt, Reply},
session_loop(Parent, Sid);
_ ->
session_loop(Parent, Sid)
end.
without(Sid, Sessions) ->
lists:keydelete(Sid, 1, Sessions).
with_guild(State, Fun) ->
{ok, Pid} = gen_server:start(guild, State, []),
try
Fun(Pid)
after
catch gen_server:call(Pid, {terminate}, 5000)
end.
get_state(Guild) ->
gen_server:call(Guild, {get_sessions}, 10000).
hold_connect_workers(Guild, MaxQueue) ->
sys:replace_state(Guild, fun(S) ->
S#{session_connect_inflight => 8, session_connect_max_queue => MaxQueue}
end),
ok.
release_connect_workers(Guild) ->
sys:replace_state(Guild, fun(S) ->
guild_connect_async:maybe_start_session_connect_workers(
S#{session_connect_inflight => 0, session_connect_max_queue => 16}
)
end),
ok.
reconnect_all(Guild, Sessions, Attempt) ->
[send_connect(Guild, Sid, Sessions, Attempt) || {Sid, _, _} <- Sessions],
[?assertMatch({ok, _, _}, await_result(Sid, Attempt)) || {Sid, _, _} <- Sessions],
ok.
send_connect(Guild, Sid, Sessions, Attempt) ->
{Sid, UserId, Pid} = lists:keyfind(Sid, 1, Sessions),
Request = #{
session_id => Sid,
user_id => UserId,
session_pid => Pid,
bot => false,
is_staff => false,
initial_guild_id => ?GUILD_ID,
active_guilds => sets:from_list([?GUILD_ID])
},
gen_server:cast(
Guild,
{session_connect_async, #{
guild_id => ?GUILD_ID, attempt => Attempt, request => Request
}}
).
await_result(Sid, Attempt) ->
receive
{connect_result, Sid, Attempt, Reply} -> Reply
after 10000 ->
error({no_connect_result, Sid, Attempt})
end.
kill_session(Sid, Sessions) ->
{Sid, _UserId, Pid} = lists:keyfind(Sid, 1, Sessions),
Ref = monitor(process, Pid),
exit(Pid, kill),
receive
{'DOWN', Ref, process, Pid, _} -> ok
end.
flush() ->
receive
{connect_result, _, _, _} -> flush()
after 0 ->
ok
end.
transferred_state(Exported) ->
guild_manager_shard_lifecycle:normalize_transferred_guild_state(?GUILD_ID, Exported).
base_state(Sessions) ->
Members = [member(UserId) || UserId <- [?USER_A, ?USER_B, ?USER_C]],
#{
id => ?GUILD_ID,
member_count => length(Members),
sessions => maps:from_list([
{Sid, #{
session_id => Sid,
user_id => UserId,
pid => Pid,
active_guilds => sets:from_list([?GUILD_ID])
}}
|| {Sid, UserId, Pid} <- Sessions
]),
data => #{
<<"guild">> => #{
<<"id">> => ?GUILD_ID,
<<"owner_id">> => ?USER_A,
<<"features">> => [],
<<"member_count">> => length(Members)
},
<<"roles">> => [
#{
<<"id">> => ?GUILD_ID,
<<"name">> => <<"everyone">>,
<<"permissions">> =>
constants:view_channel_permission() bor
constants:view_channel_members_permission(),
<<"hoist">> => false,
<<"position">> => 0
}
],
<<"channels">> => [
#{
<<"id">> => ?CHANNEL_ID,
<<"name">> => <<"general">>,
<<"type">> => 0,
<<"permission_overwrites">> => []
}
],
<<"members">> => Members
}
}.
member(UserId) ->
Name = integer_to_binary(UserId),
#{
<<"user">> => #{
<<"id">> => UserId,
<<"username">> => <<"u", Name/binary>>,
<<"global_name">> => <<"U", Name/binary>>,
<<"bot">> => false
},
<<"nick">> => null,
<<"roles">> => [],
<<"joined_at">> => <<"2026-01-01T00:00:00.000000+00:00">>
}.