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
@@ -54,6 +54,21 @@ is_positive_int "${FLUXER_ERLANG_SCHEDULERS_MIN:-}" || FLUXER_ERLANG_SCHEDULERS_
is_positive_int "${FLUXER_ERLANG_SCHEDULERS_MAX:-}" || FLUXER_ERLANG_SCHEDULERS_MAX=16
: "${FLUXER_ERLANG_NODE_NAME:[email protected]}"
: "${FLUXER_ERLANG_DIST_PORT:=8081}"
node_name_file=/opt/fluxer_gateway/node_name
if [ -n "${FLUXER_ERLANG_NODE_NAME_PREFIX:-}" ]; then
case "$FLUXER_ERLANG_NODE_NAME_PREFIX" in
*[!A-Za-z0-9_]*)
echo 'FLUXER_ERLANG_NODE_NAME_PREFIX may only contain letters, digits and underscores.' >&2
exit 1
;;
esac
node_nonce="$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"
FLUXER_ERLANG_NODE_NAME="${FLUXER_ERLANG_NODE_NAME_PREFIX}_${node_nonce}@${FLUXER_ERLANG_NODE_NAME#*@}"
printf '%s\n' "$FLUXER_ERLANG_NODE_NAME" >"$node_name_file"
else
rm -f "$node_name_file"
fi
if [ -z "${FLUXER_ERLANG_COOKIE:-}" ]; then
echo 'FLUXER_ERLANG_COOKIE is required.' >&2
@@ -13,4 +13,9 @@ eval | console | remote_console | remote | remsh | console_clean | console_boot
esac
script_dir="$(CDPATH='' cd "$(dirname "$0")" && pwd -P)"
node_name_file="$script_dir/../node_name"
if [ -r "$node_name_file" ]; then
FLUXER_ERLANG_NODE_NAME="$(cat "$node_name_file")"
export FLUXER_ERLANG_NODE_NAME
fi
exec "$script_dir/fluxer_gateway.real" "$@"
@@ -16,6 +16,7 @@ start(_StartType, _StartArgs) ->
-spec init_subsystems() -> ok.
init_subsystems() ->
_ = fluxer_gateway_env:load(),
ok = gateway_guild_pin_keeper:apply_boot_pins(),
gateway_compress:init(),
gateway_cluster_metrics:init(),
process_registry:init(),
@@ -114,6 +114,17 @@ env_gateway_base_config() ->
<<"gateway_nats_rpc_max_handlers">> => env_int(
"FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS", 512
),
<<"nats_rpc_enabled">> => env_bool("FLUXER_GATEWAY_NATS_RPC_ENABLED", true),
<<"pinned_guild_ids">> => env_optional_binary("FLUXER_GATEWAY_PINNED_GUILD_IDS"),
<<"guild_pin_keeper_beam">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM"
),
<<"guild_pin_keeper_beam_md5">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BEAM_MD5"
),
<<"guild_pin_keeper_base_md5s">> => env_optional_binary(
"FLUXER_GATEWAY_GUILD_PIN_KEEPER_BASE_MD5S"
),
<<"gateway_http_failure_threshold">> => env_int(
"FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD", 6
),
@@ -142,6 +153,7 @@ build_config(RawConfig) ->
build_sharding_config(Service),
build_http_config(Service),
build_cluster_config(Service, Public),
build_pinned_node_config(Service),
build_misc_config(Service)
]).
@@ -195,7 +207,7 @@ build_sharding_config(Service) ->
guild_counts_cache_shards => get_optional_int(Service, <<"guild_counts_cache_shards">>),
guild_shards => get_optional_int(Service, <<"guild_shards">>),
session_shards => get_optional_int(Service, <<"session_shards">>),
session_connect_max_queue => get_int(Service, <<"session_connect_max_queue">>, 1024)
session_connect_max_queue => get_int(Service, <<"session_connect_max_queue">>, 8192)
}.
-spec build_http_config(map()) -> config().
@@ -233,6 +245,39 @@ build_cluster_config(Service, Public) ->
)
}.
-spec build_pinned_node_config(map()) -> config().
build_pinned_node_config(Service) ->
#{
nats_rpc_enabled => get_bool(Service, <<"nats_rpc_enabled">>, true),
pinned_guild_ids => parse_guild_id_list(
get_optional_binary(Service, <<"pinned_guild_ids">>)
),
guild_pin_keeper_beam => optional_string(
get_optional_binary(Service, <<"guild_pin_keeper_beam">>)
),
guild_pin_keeper_beam_md5 => get_optional_binary(
Service, <<"guild_pin_keeper_beam_md5">>
),
guild_pin_keeper_base_md5s => get_optional_binary(
Service, <<"guild_pin_keeper_base_md5s">>
)
}.
-spec parse_guild_id_list(binary() | undefined) -> [pos_integer()].
parse_guild_id_list(undefined) ->
[];
parse_guild_id_list(Bin) when is_binary(Bin) ->
lists:usort([parse_guild_id(Token) || Token <- string:lexemes(binary_to_list(Bin), ", ")]).
-spec parse_guild_id(string()) -> pos_integer().
parse_guild_id(Token) ->
try list_to_integer(Token) of
Id when Id > 0 -> Id;
_ -> erlang:error({invalid_pinned_guild_id, Token})
catch
error:badarg -> erlang:error({invalid_pinned_guild_id, Token})
end.
-spec build_misc_config(map()) -> config().
build_misc_config(Service) ->
#{
@@ -43,7 +43,8 @@ common_children() ->
[
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
child_spec(gateway_periodic_gc, gateway_periodic_gc),
child_spec(process_health_watchdog, process_health_watchdog)
child_spec(process_health_watchdog, process_health_watchdog),
child_spec(gateway_stall_monitor, gateway_stall_monitor)
].
-spec role_children(atom()) -> [supervisor:child_spec()].
@@ -95,7 +96,7 @@ role_specs(guilds, _Role) ->
child_spec(guild_counts_cache, guild_counts_cache),
child_spec(guild_manager, guild_manager),
child_spec(voice_state_counts_sync, voice_state_counts_sync)
];
] ++ gateway_guild_pin_keeper:child_specs();
role_specs(calls, Role) ->
[child_spec(call_manager, call_manager)] ++ calls_voice_state_counts_sync_children(Role);
role_specs(push, _Role) ->
File diff suppressed because it is too large Load Diff
@@ -112,13 +112,17 @@ init([]) ->
slots => #{},
monitors => #{},
connecting => #{},
rpc_enabled => true,
rpc_enabled => nats_rpc_enabled(),
subs => #{},
handler_count => 0,
handler_refs => #{},
max_handlers => gateway_nats_pool_conn:max_handlers()
}}.
-spec nats_rpc_enabled() -> boolean().
nats_rpc_enabled() ->
fluxer_gateway_env:get(nats_rpc_enabled) =/= false.
-spec handle_call(term(), gen_server:from(), map()) -> {reply, term(), map()}.
handle_call(get_pool_status, _From, State) ->
{reply, build_pool_status(State), State};
@@ -571,6 +575,24 @@ legacy_connect_timeout_ignores_tokened_slot_worker_test() ->
exit(Pid, kill)
end.
pool_keeps_rpc_unsubscribed_when_switched_off_test() ->
persistent_term:put({fluxer_gateway, runtime_config}, #{nats_rpc_enabled => false}),
Parent = self(),
try
Pid = spawn(fun() -> Parent ! {self(), init([])} end),
{ok, State} =
receive
{Pid, Reply} -> Reply
after 5000 -> timeout
end,
?assertEqual(false, maps:get(rpc_enabled, State)),
?assertEqual(State, handle_ready(self(), false, #{0 => self()}, State))
after
persistent_term:erase(?PERSISTENT_TERM_KEY),
persistent_term:erase(?REPLY_FAILURE_KEY),
persistent_term:erase({fluxer_gateway, runtime_config})
end.
wait_forever() ->
receive
stop -> ok
@@ -169,7 +169,7 @@ do_subscribe(#{conn := Conn, rpc_enabled := RpcEnabled} = State) when
false ->
logger:info(
"Gateway NATS RPC connected but subscription disabled"
" (GATEWAY_NATS_RPC_ENABLED=false)"
" (FLUXER_GATEWAY_NATS_RPC_ENABLED=false)"
),
State;
_ ->
@@ -7,6 +7,8 @@
owner_node/1, owner_node/2,
owner_node_result/2,
select_owner_node/2,
select_guild_owner_node/2,
pinned_guild_owner/1,
active_nodes/0, active_nodes/1,
is_ready/0,
is_draining/0
@@ -30,11 +32,50 @@ owner_node(Key, Role) ->
-spec owner_node_result(term(), atom()) -> {ok, node()} | {error, {no_active_nodes, atom()}}.
owner_node_result(Key, Role) ->
case pinned_owner(Key, Role) of
{ok, Pinned} -> {ok, Pinned};
none -> hashed_owner_node_result(Key, Role)
end.
-spec hashed_owner_node_result(term(), atom()) ->
{ok, node()} | {error, {no_active_nodes, atom()}}.
hashed_owner_node_result(Key, Role) ->
case active_nodes(Role) of
[] -> fallback_owner_for_role(Role);
Nodes -> {ok, select_owner_node(Key, Nodes)}
end.
-spec select_guild_owner_node(term(), [node()]) -> node().
select_guild_owner_node(GuildId, Nodes) ->
case pinned_guild_owner(GuildId) of
{ok, Pinned} -> Pinned;
none -> select_owner_node(GuildId, Nodes)
end.
-spec pinned_owner(term(), atom()) -> {ok, node()} | none.
pinned_owner(Key, guilds) ->
pinned_guild_owner(Key);
pinned_owner(_Key, _Role) ->
none.
-spec pinned_guild_owner(term()) -> {ok, node()} | none.
pinned_guild_owner(GuildId) when is_integer(GuildId) ->
case application:get_env(fluxer_gateway, guild_owner_pins) of
{ok, #{GuildId := Node}} when is_atom(Node) -> live_pin(Node, nodes());
_ -> none
end;
pinned_guild_owner(_GuildId) ->
none.
-spec live_pin(node(), [node()]) -> {ok, node()} | none.
live_pin(Node, _Connected) when Node =:= node() ->
{ok, Node};
live_pin(Node, Connected) ->
case lists:member(Node, Connected) of
true -> {ok, Node};
false -> none
end.
-spec select_owner_node(term(), [node()]) -> node().
select_owner_node(_Key, []) ->
node();
@@ -67,13 +108,27 @@ role_active_nodes(Role, RoleMembers) when is_map(RoleMembers) ->
Monoliths0 = maps:get(all, RoleMembers, []),
Specific = filter_atoms(Specific0),
Monoliths = filter_atoms(Monoliths0),
intersect_current_members(lists:usort(Specific ++ Monoliths));
Active = intersect_current_members(lists:usort(Specific ++ Monoliths)),
exclude_pinned_only_nodes(Role, Active);
role_active_nodes(Role, _RoleMembers) ->
exclude_pinned_only_nodes(Role, local_role_nodes(Role)).
-spec local_role_nodes(atom()) -> [node()].
local_role_nodes(Role) ->
case fluxer_gateway_sup:role_enabled(Role) of
true -> [node()];
false -> []
end.
-spec exclude_pinned_only_nodes(atom(), [node()]) -> [node()].
exclude_pinned_only_nodes(guilds, Nodes) ->
case application:get_env(fluxer_gateway, guild_pinned_only_nodes) of
{ok, [_ | _] = Excluded} -> [Node || Node <- Nodes, not lists:member(Node, Excluded)];
_ -> Nodes
end;
exclude_pinned_only_nodes(_Role, Nodes) ->
Nodes.
-spec filter_atoms(term()) -> [atom()].
filter_atoms(List) when is_list(List) ->
[X || X <- List, is_atom(X)];
@@ -91,9 +146,9 @@ intersect_current_members(RoleNodes) ->
-spec fallback_owner_for_role(atom()) -> {ok, node()} | {error, {no_active_nodes, atom()}}.
fallback_owner_for_role(Role) ->
case fluxer_gateway_sup:role_enabled(Role) of
true -> {ok, node()};
false -> {error, {no_active_nodes, Role}}
case exclude_pinned_only_nodes(Role, local_role_nodes(Role)) of
[Self] -> {ok, Self};
_ -> {error, {no_active_nodes, Role}}
end.
-spec is_ready() -> boolean().
@@ -246,6 +301,99 @@ is_draining_reads_persistent_term_test() ->
?assert(is_draining()),
persistent_term:erase({fluxer_gateway, draining}).
-define(PINNED_GUILD, 1100000000000000001).
with_guild_members(Fun) ->
persistent_term:put(
{gateway_cluster_membership, members}, [node(), 'g1@h', 'g2@h', 'g3@h']
),
persistent_term:put({gateway_cluster_membership, members_by_role}, #{
guilds => [node(), 'g1@h', 'g2@h', 'g3@h'],
calls => ['g1@h', 'g2@h']
}),
try
Fun()
after
application:unset_env(fluxer_gateway, guild_owner_pins),
application:unset_env(fluxer_gateway, guild_pinned_only_nodes),
persistent_term:erase({gateway_cluster_membership, members}),
persistent_term:erase({gateway_cluster_membership, members_by_role})
end.
hashed_owners(Keys, Role) ->
Nodes = active_nodes(Role),
[{ok, select_owner_node(Key, Nodes)} || Key <- Keys].
unset_pins_keep_hashed_owners_test() ->
with_guild_members(fun() ->
Keys = [?PINNED_GUILD | lists:seq(1, 500)],
?assertEqual(hashed_owners(Keys, guilds), [owner_node_result(K, guilds) || K <- Keys]),
?assertEqual(hashed_owners(Keys, calls), [owner_node_result(K, calls) || K <- Keys])
end).
pin_to_self_applies_to_guilds_role_only_test() ->
with_guild_members(fun() ->
[Hashed] = hashed_owners([?PINNED_GUILD], calls),
Nodes = active_nodes(guilds),
Release = select_owner_node(?PINNED_GUILD, Nodes),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
?assertEqual({ok, node()}, owner_node_result(?PINNED_GUILD, guilds)),
?assertEqual(Hashed, owner_node_result(?PINNED_GUILD, calls)),
?assertEqual(Release, select_owner_node(?PINNED_GUILD, Nodes)),
?assertEqual(hashed_owners([7], guilds), [owner_node_result(7, guilds)])
end).
pin_to_disconnected_node_falls_back_to_hashing_test() ->
with_guild_members(fun() ->
[Hashed] = hashed_owners([?PINNED_GUILD], guilds),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => 'pin_x@h'}),
?assertEqual(Hashed, owner_node_result(?PINNED_GUILD, guilds)),
?assertEqual(none, pinned_guild_owner(?PINNED_GUILD))
end).
live_pin_requires_a_connected_node_test() ->
?assertEqual({ok, 'pin_x@h'}, live_pin('pin_x@h', ['g1@h', 'pin_x@h'])),
?assertEqual(none, live_pin('pin_x@h', ['g1@h'])),
?assertEqual({ok, node()}, live_pin(node(), [])).
pinned_only_node_owns_no_hashed_guilds_test() ->
with_guild_members(fun() ->
Keys = lists:seq(1, 2000),
Before = [K || K <- Keys, owner_node_result(K, guilds) =:= {ok, node()}],
application:set_env(fluxer_gateway, guild_pinned_only_nodes, [node()]),
After = [K || K <- Keys, owner_node_result(K, guilds) =:= {ok, node()}],
?assertNotEqual([], Before),
?assertEqual([], After),
?assertEqual(['g1@h', 'g2@h', 'g3@h'], active_nodes(guilds)),
?assertEqual(['g1@h', 'g2@h'], active_nodes(calls))
end).
pinned_only_node_without_peers_owns_only_its_pins_test() ->
persistent_term:put({fluxer_gateway, runtime_config}, #{gateway_role => guilds}),
persistent_term:erase({gateway_cluster_membership, members_by_role}),
application:set_env(fluxer_gateway, guild_pinned_only_nodes, [node()]),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
try
?assertEqual([], active_nodes(guilds)),
?assertEqual({error, {no_active_nodes, guilds}}, owner_node_result(5, guilds)),
?assertEqual({ok, node()}, owner_node_result(?PINNED_GUILD, guilds))
after
application:unset_env(fluxer_gateway, guild_owner_pins),
application:unset_env(fluxer_gateway, guild_pinned_only_nodes),
persistent_term:erase({fluxer_gateway, runtime_config})
end.
select_guild_owner_node_honours_live_pins_test() ->
with_guild_members(fun() ->
Targets = ['g1@h', 'g2@h'],
Hashed = select_owner_node(?PINNED_GUILD, Targets),
?assertEqual(Hashed, select_guild_owner_node(?PINNED_GUILD, Targets)),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => node()}),
?assertEqual(node(), select_guild_owner_node(?PINNED_GUILD, Targets)),
application:set_env(fluxer_gateway, guild_owner_pins, #{?PINNED_GUILD => 'pin_x@h'}),
?assertEqual(Hashed, select_guild_owner_node(?PINNED_GUILD, Targets))
end).
is_ready_inverts_is_draining_test() ->
persistent_term:erase({fluxer_gateway, draining}),
?assert(is_ready()),
@@ -89,9 +89,9 @@ gc_if_large_and_idle(Pid) ->
-spec gc_if_known_type(pid(), non_neg_integer()) -> ok.
gc_if_known_type(Pid, Memory) ->
try erlang:process_info(Pid, dictionary) of
{dictionary, Dict} ->
force_gc_by_type(Pid, Memory, proplists:get_value('$initial_call', Dict));
try erlang:process_info(Pid, {dictionary, '$initial_call'}) of
{{dictionary, '$initial_call'}, InitialCall} ->
force_gc_by_type(Pid, Memory, InitialCall);
_ ->
ok
catch
@@ -67,7 +67,7 @@ with_guild_unchecked(GuildId, Fun) ->
-spec ensure_responsive(pid()) -> ok.
ensure_responsive(Pid) ->
case guild_health:is_degraded(Pid) of
case guild_health:is_overloaded(Pid) of
true -> gateway_rpc_error:raise(<<"guild_overloaded">>);
false -> ok
end.
@@ -433,7 +433,8 @@ overloaded_guild_is_rejected_before_enqueuing_work_test() ->
stop -> ok
end
end),
true = ets:insert(guild_health_status, {Pid, 42, true, undefined, undefined}),
Pending = {make_ref(), erlang:monotonic_time(millisecond), 2500},
true = ets:insert(guild_health_status, {Pid, 42, true, undefined, Pending}),
try
?assertError(
{gateway_rpc_error, <<"guild_overloaded">>},
+10
View File
@@ -49,18 +49,24 @@ init(GuildState) ->
-spec handle_call(term(), gen_server:from(), guild_state()) -> call_reply().
handle_call(Msg, From, State) ->
ok = guild_mailbox_age:note(),
Result = handle_call_internal(Msg, From, State),
ok = publish_read_model(Result, State),
Result.
-spec handle_cast(term(), guild_state()) -> cast_reply().
handle_cast(Msg, State) ->
ok = guild_mailbox_age:note(),
Result = handle_cast_internal(Msg, State),
ok = publish_read_model(Result, State),
Result.
-spec handle_info(term(), guild_state()) -> info_reply().
handle_info({guild_mailbox_age, Seq}, State) when is_integer(Seq), Seq >= 0 ->
ok = guild_mailbox_age:handle_mark(Seq),
{noreply, State};
handle_info(Msg, State) ->
ok = guild_mailbox_age:note(),
Result = handle_info_internal(Msg, State),
ok = publish_read_model(Result, State),
Result.
@@ -188,6 +194,10 @@ handle_cast_internal(
{session_connect_worker_done, SessionId, Attempt, Result0, Computed}, State
) ->
handle_session_connect_worker_done_cast(SessionId, Attempt, Result0, Computed, State);
handle_cast_internal({session_connect_worker_batch_done, Results}, State) when
is_list(Results)
->
{noreply, guild_connect_async:finalize_session_connect_batch(Results, State)};
handle_cast_internal({set_session_active, SessionId}, State) ->
handle_set_session_active_cast(SessionId, State);
handle_cast_internal({set_session_passive, SessionId}, State) ->
+209 -85
View File
@@ -6,6 +6,7 @@
-export([
enqueue_session_connect_async/5,
finalize_session_connect_async/5,
finalize_session_connect_batch/2,
maybe_start_session_connect_workers/1,
decrement_session_connect_inflight/1,
ensure_session_connect_queue/1
@@ -13,22 +14,23 @@
-type session_id() :: binary().
-type guild_id() :: integer().
-export_type([session_id/0]).
-type user_id() :: integer().
-type connect_result() :: {session_id() | undefined, non_neg_integer(), term(), map()}.
-type joined() :: {#{user_id() => map()}, [user_id()]}.
-export_type([session_id/0, connect_result/0]).
-define(SESSION_CONNECT_MAX_WORKERS, 8).
-define(SESSION_CONNECT_DEFAULT_MAX_QUEUE, 1024).
-define(SESSION_CONNECT_MAX_BATCH, 8).
-define(SESSION_CONNECT_DEFAULT_MAX_QUEUE, 8192).
-spec ensure_session_connect_queue(term()) -> queue:queue().
ensure_session_connect_queue(Value) when is_list(Value) ->
queue:from_list(Value);
ensure_session_connect_queue(Value) ->
safe_queue_cast(Value).
-spec safe_queue_cast(term()) -> queue:queue().
safe_queue_cast({R, F} = _Value) when is_list(R), is_list(F) ->
queue:from_list(F ++ lists:reverse(R));
safe_queue_cast(_) ->
queue:new().
case queue:is_queue(Value) of
true -> Value;
false -> queue:new()
end.
-spec enqueue_session_connect_async(integer(), non_neg_integer(), map(), map(), map()) -> map().
enqueue_session_connect_async(GuildId, Attempt, Request, Msg, State) ->
@@ -70,60 +72,114 @@ enqueue_session_connect_async_for_session(GuildId, Attempt, Request, Msg, Sessio
map(),
map()
) -> map().
finalize_session_connect_async(undefined, _Attempt, _Result0, _Computed, State) ->
maybe_start_session_connect_workers(decrement_session_connect_inflight(State));
finalize_session_connect_async(SessionId, Attempt, Result0, Computed, State) ->
State1 = decrement_session_connect_inflight(State),
Pending0 = maps:get(session_connect_pending, State1, #{}),
case maps:find(SessionId, Pending0) of
{ok, Attempt} ->
State2 = State1#{session_connect_pending => maps:remove(SessionId, Pending0)},
finalize_matched(SessionId, Attempt, Result0, Computed, State2);
_ ->
maybe_start_session_connect_workers(State1)
end.
finalize_session_connect_batch([{SessionId, Attempt, Result0, Computed}], State).
-spec finalize_matched(session_id(), non_neg_integer(), term(), map(), map()) -> map().
finalize_matched(SessionId, Attempt, Result0, Computed, State) ->
-spec finalize_session_connect_batch([connect_result()], map()) -> map().
finalize_session_connect_batch(Results, State) ->
State1 = decrement_session_connect_inflight(State),
{State2, Joined} = lists:foldl(fun finalize_result/2, {State1, {#{}, []}}, Results),
maybe_start_session_connect_workers(finish_joined_users(Joined, State1, State2)).
-spec finalize_result(term(), {map(), joined()}) -> {map(), joined()}.
finalize_result({SessionId, Attempt, Result0, Computed}, {State, Joined}) when
is_binary(SessionId), is_integer(Attempt), is_map(Computed)
->
Pending0 = maps:get(session_connect_pending, State, #{}),
case {maps:find(SessionId, Pending0), valid_result(Result0)} of
{{ok, Attempt}, true} ->
State1 = State#{session_connect_pending => maps:remove(SessionId, Pending0)},
finalize_matched(SessionId, Attempt, Result0, Computed, State1, Joined);
_ ->
{State, Joined}
end;
finalize_result(_Result, Acc) ->
Acc.
-spec valid_result(term()) -> boolean().
valid_result({ok, Result}) -> is_map(Result);
valid_result({ok_unavailable, Result}) -> is_map(Result);
valid_result({error, _Reason}) -> true;
valid_result(_) -> false.
-spec finalize_matched(session_id(), non_neg_integer(), term(), map(), map(), joined()) ->
{map(), joined()}.
finalize_matched(SessionId, Attempt, Result0, Computed, State, Joined) ->
Request = maps:get(request, Computed, #{}),
SessionPid = maps:get(session_pid, Request, undefined),
case is_pid(SessionPid) of
false ->
maybe_start_session_connect_workers(State);
{State, Joined};
true ->
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State)
finalize_with_pid(
SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined
)
end.
-spec finalize_with_pid(session_id(), non_neg_integer(), term(), map(), map(), pid(), map()) ->
map().
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State) ->
-spec finalize_with_pid(
session_id(), non_neg_integer(), term(), map(), map(), pid(), map(), joined()
) -> {map(), joined()}.
finalize_with_pid(SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined) ->
case resolve_guild_id(State, Computed) of
GuildId when is_integer(GuildId), GuildId > 0 ->
finalize_resolved(
GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State
GuildId,
SessionId,
Attempt,
Result0,
Computed,
Request,
SessionPid,
State,
Joined
);
_ ->
maybe_start_session_connect_workers(State)
{State, Joined}
end.
-spec finalize_resolved(
integer(), session_id(), non_neg_integer(), term(), map(), map(), pid(), map()
) -> map().
integer(), session_id(), non_neg_integer(), term(), map(), map(), pid(), map(), joined()
) -> {map(), joined()}.
finalize_resolved(
GuildId, SessionId, Attempt, {error, _} = Result0, _Computed, _Request, SessionPid, State
GuildId,
SessionId,
Attempt,
{error, Reason} = Result0,
_Computed,
_Request,
SessionPid,
State,
Joined
) ->
State1 = discard_pending_session(SessionId, State),
State1 = discard_pending_session(SessionId, Reason, State),
send_result(GuildId, Attempt, Result0, SessionPid),
maybe_start_session_connect_workers(State1);
finalize_resolved(GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State) ->
State1 = upsert_session(SessionId, SessionPid, Request, Computed, State),
{State1, Joined};
finalize_resolved(
GuildId, SessionId, Attempt, Result0, Computed, Request, SessionPid, State, Joined
) ->
Acc = upsert_session(SessionId, SessionPid, Request, Computed, State, Joined),
send_result(GuildId, Attempt, Result0, SessionPid),
maybe_start_session_connect_workers(State1).
Acc.
-spec discard_pending_session(session_id() | undefined, map()) -> map().
discard_pending_session(SessionId, State) when is_binary(SessionId) ->
-spec finish_joined_users(joined(), map(), map()) -> map().
finish_joined_users({Joined, Fresh}, StartState, State) ->
State1 = guild_presence:apply_connect_presences(lists:reverse(Fresh), State),
maps:fold(
fun(UserId, PresenceBefore, Acc) ->
guild_sessions_connect:resection_connected_user(
UserId, PresenceBefore, StartState, Acc
)
end,
State1,
Joined
).
-spec discard_pending_session(session_id(), term(), map()) -> map().
discard_pending_session(SessionId, Reason, State) ->
Sessions0 = maps:get(sessions, State, #{}),
case maps:find(SessionId, Sessions0) of
{ok, #{pending_connect := true, owns_connected_tracking := true} = Entry} ->
discard_reconnecting_session(SessionId, Reason, Entry, State);
{ok, #{pending_connect := true} = Entry} ->
demonitor_pending_session(Entry),
guild_sessions_connect:remove_session_ref(
@@ -132,8 +188,18 @@ discard_pending_session(SessionId, State) when is_binary(SessionId) ->
);
_ ->
State
end;
discard_pending_session(_SessionId, State) ->
end.
-spec discard_reconnecting_session(session_id(), term(), map(), map()) -> map().
discard_reconnecting_session(SessionId, not_member, #{mref := MRef} = Entry, State) when
is_reference(MRef)
->
demonitor(MRef, [flush]),
{noreply, State1} = guild_sessions_connect:handle_session_down(
MRef, {SessionId, Entry}, State
),
State1;
discard_reconnecting_session(_SessionId, _Reason, _Entry, State) ->
State.
-spec demonitor_pending_session(map()) -> ok.
@@ -158,19 +224,31 @@ maybe_start_session_connect_workers(State) ->
-spec dequeue_and_start(queue:queue(), non_neg_integer(), map()) -> map().
dequeue_and_start(Queue0, Inflight0, State) ->
case queue:out(Queue0) of
{{value, Next}, Rest} when is_map(Next) ->
case take_batch(batch_size(Queue0, Inflight0), Queue0, []) of
{[], Rest} ->
State#{session_connect_queue => Rest};
{Items, Rest} ->
State1 = State#{
session_connect_queue => Rest,
session_connect_inflight => Inflight0 + 1
},
maybe_start_session_connect_workers(start_worker(Next, State1));
{{value, _Next}, Rest} ->
maybe_start_session_connect_workers(
State#{session_connect_queue => Rest}
);
{empty, _} ->
State#{session_connect_queue => Queue0}
maybe_start_session_connect_workers(start_worker(Items, State1))
end.
-spec batch_size(queue:queue(), non_neg_integer()) -> pos_integer().
batch_size(Queue, Inflight) ->
FreeWorkers = max(1, ?SESSION_CONNECT_MAX_WORKERS - Inflight),
Spread = (queue:len(Queue) + FreeWorkers - 1) div FreeWorkers,
max(1, min(?SESSION_CONNECT_MAX_BATCH, Spread)).
-spec take_batch(non_neg_integer(), queue:queue(), [map()]) -> {[map()], queue:queue()}.
take_batch(0, Queue, Acc) ->
{lists:reverse(Acc), Queue};
take_batch(N, Queue, Acc) ->
case queue:out(Queue) of
{{value, Item}, Rest} when is_map(Item) -> take_batch(N - 1, Rest, [Item | Acc]);
{{value, _Item}, Rest} -> take_batch(N, Rest, Acc);
{empty, Rest} -> {lists:reverse(Acc), Rest}
end.
-spec decrement_session_connect_inflight(map()) -> map().
@@ -280,7 +358,7 @@ cleanup_dropped_session_connect(Item, State) ->
case queued_session_id(Item) of
SessionId when is_binary(SessionId) ->
State1 = remove_pending_session_connect(SessionId, State),
discard_pending_session(SessionId, State1);
discard_pending_session(SessionId, overloaded, State1);
_ ->
State
end.
@@ -316,14 +394,22 @@ upsert_pending_session(S, U, P, Request, State) ->
active_guilds => maps:get(active_guilds, Request, sets:new()),
bot => maps:get(bot, Request, false),
is_staff => maps:get(is_staff, Request, false),
pending_connect => true,
viewable_channels => #{}
pending_connect => true
},
guild_sessions_connect:put_session_ref(S, MRef, State#{
sessions => Sessions0#{S => Entry}
});
{ok, Existing} ->
State#{sessions => Sessions0#{S => Existing#{pending_connect => true}}}
State#{sessions => Sessions0#{S => mark_pending(Existing)}}
end.
-spec mark_pending(map()) -> map().
mark_pending(#{pending_connect := true} = Existing) ->
Existing;
mark_pending(Existing) ->
case connected_user_id_from_existing(Existing) of
undefined -> Existing#{pending_connect => true};
_UserId -> Existing#{pending_connect => true, owns_connected_tracking => true}
end.
-spec drop_queued(session_id(), queue:queue()) -> queue:queue().
@@ -364,24 +450,30 @@ queued_session_pid(#{reply_via_pid := SessionPid}) when is_pid(SessionPid) ->
queued_session_pid(_) ->
undefined.
-spec start_worker(map(), map()) -> map().
start_worker(Item, State) ->
-spec start_worker([map()], map()) -> map().
start_worker(Items, State) ->
Self = self(),
Snapshot = guild_data:build_connect_snapshot(Item, State),
{_Pid, Ref} = spawn_monitor(fun() -> compute_and_send_done(Item, Self, Snapshot) end),
Snapshot = guild_data:build_connect_snapshot(Items, State),
{_Pid, Ref} = spawn_monitor(fun() -> compute_and_send_done(Items, Self, Snapshot) end),
WorkerRefs = maps:get(session_connect_worker_refs, State, #{}),
State#{session_connect_worker_refs => WorkerRefs#{Ref => true}}.
-spec compute_and_send_done(map(), pid(), map()) -> ok.
compute_and_send_done(Item, GuildPid, Snapshot) ->
-spec compute_and_send_done([map()], pid(), map()) -> ok.
compute_and_send_done(Items, GuildPid, Snapshot) ->
Results = [compute_item(Item, Snapshot) || Item <- Items],
gen_server:cast(GuildPid, {session_connect_worker_batch_done, Results}),
ok.
-spec compute_item(map(), map()) -> connect_result().
compute_item(Item, Snapshot) ->
Request = maps:get(request, Item, #{}),
{Result0, Computed0} = compute_connect_result(Snapshot, Item, Request),
gen_server:cast(
GuildPid,
{session_connect_worker_done, maps:get(session_id, Request, undefined),
maps:get(attempt, Item, 0), Result0, maps:merge(Item, Computed0)}
),
ok.
{
maps:get(session_id, Request, undefined),
maps:get(attempt, Item, 0),
Result0,
maps:merge(Item, Computed0)
}.
-spec compute_connect_result(map(), map(), map()) ->
{{ok, map()} | {ok_unavailable, map()} | {error, term()}, map()}.
@@ -471,16 +563,21 @@ resolve_guild_id(State, Fallback) ->
snowflake_id:parse_optional(maps:get(guild_id, Fallback, undefined))
end.
-spec upsert_session(session_id(), pid(), map(), map(), map()) -> map().
upsert_session(SessionId, SessionPid, Request, Computed, State) ->
-spec upsert_session(session_id(), pid(), map(), map(), map(), joined()) -> {map(), joined()}.
upsert_session(SessionId, SessionPid, Request, Computed, State, Joined) ->
UserId = maps:get(user_id, Request, undefined),
case is_integer(UserId) of
false -> State;
true -> upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State)
false ->
{State, Joined};
true ->
upsert_session_valid(
SessionId, SessionPid, UserId, Request, Computed, State, Joined
)
end.
-spec upsert_session_valid(session_id(), pid(), integer(), map(), map(), map()) -> map().
upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State) ->
-spec upsert_session_valid(session_id(), pid(), integer(), map(), map(), map(), joined()) ->
{map(), joined()}.
upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State, Joined) ->
Sessions0 = maps:get(sessions, State, #{}),
Existing = maps:get(SessionId, Sessions0, undefined),
{MRef, Existing1} = resolve_monitor(Existing, SessionPid),
@@ -491,9 +588,21 @@ upsert_session_valid(SessionId, SessionPid, UserId, Request, Computed, State) ->
Sessions = merge_session(SessionId, FinalSD, Existing1, Sessions0),
State1 = reindex_session_ref(SessionId, Existing, MRef, State#{sessions => Sessions}),
State2 = update_connected_tracking(UserId, Existing, State1),
PresenceBefore = guild_member_list_connected:resolve_presence_for_user(State2, UserId),
State3 = update_presence_subscription(UserId, Existing, State2),
guild_sessions_connect:resection_connected_user(UserId, PresenceBefore, State, State3).
update_presence_subscription(
UserId, Existing, State2, note_joined_user(UserId, State2, Joined)
).
-spec note_joined_user(integer(), map(), joined()) -> joined().
note_joined_user(UserId, State, {Joined, Fresh}) ->
case maps:is_key(UserId, Joined) of
true ->
{Joined, Fresh};
false ->
PresenceBefore = guild_member_list_connected:resolve_presence_for_user(
State, UserId
),
{Joined#{UserId => PresenceBefore}, Fresh}
end.
-spec build_session_data(session_id(), integer(), pid(), reference(), map(), map()) -> map().
build_session_data(SessionId, UserId, SessionPid, MRef, Request, Computed) ->
@@ -530,7 +639,9 @@ maybe_mark_synced(GuildId, Computed, SessionData) ->
merge_session(SessionId, FinalSD, undefined, Sessions0) ->
Sessions0#{SessionId => FinalSD};
merge_session(SessionId, FinalSD, Existing, Sessions0) ->
Sessions0#{SessionId => maps:merge(Existing, FinalSD)}.
Sessions0#{
SessionId => maps:merge(maps:remove(owns_connected_tracking, Existing), FinalSD)
}.
-spec reindex_session_ref(session_id(), map() | undefined, reference(), map()) -> map().
reindex_session_ref(SessionId, #{mref := OldRef}, MRef, State) when OldRef =/= MRef ->
@@ -563,26 +674,37 @@ update_connected_tracking(UserId, Existing, State) ->
add_connected_user(UserId, remove_connected_user(ExistingUserId, State))
end.
-spec update_presence_subscription(integer(), map() | undefined, map()) -> map().
update_presence_subscription(UserId, Existing, State) ->
-spec update_presence_subscription(integer(), map() | undefined, map(), joined()) ->
{map(), joined()}.
update_presence_subscription(UserId, Existing, State, Joined) ->
case connected_user_id_from_existing(Existing) of
UserId ->
State;
{State, Joined};
undefined ->
guild_sessions:subscribe_connected_user_presence(UserId, State);
subscribe_joined_user(UserId, State, Joined);
ExistingUserId ->
State1 = guild_sessions:unsubscribe_from_user_presence(ExistingUserId, State),
guild_sessions:subscribe_connected_user_presence(UserId, State1)
subscribe_joined_user(UserId, State1, Joined)
end.
-spec subscribe_joined_user(integer(), map(), joined()) -> {map(), joined()}.
subscribe_joined_user(UserId, State, {Joined, Fresh}) ->
case guild_sessions_presence:subscribe_without_cached_presence(UserId, State) of
{fresh, State1} -> {State1, {Joined, [UserId | Fresh]}};
{existing, State1} -> {State1, {Joined, Fresh}}
end.
-spec connected_user_id_from_existing(map() | undefined) -> integer() | undefined.
connected_user_id_from_existing(undefined) ->
undefined;
connected_user_id_from_existing(#{pending_connect := true}) ->
undefined;
connected_user_id_from_existing(Existing) ->
case maps:get(user_id, Existing, undefined) of
UserId when is_integer(UserId), UserId > 0 ->
case
{
guild_sessions_connect:counts_as_connected(Existing),
maps:get(user_id, Existing, undefined)
}
of
{true, UserId} when is_integer(UserId), UserId > 0 ->
UserId;
_ ->
undefined
@@ -703,6 +825,7 @@ enqueue_marks_existing_session_pending_connect_test() ->
SessionId, UserId, pending_connect_state(#{SessionId => Existing})
),
?assertEqual(true, maps:get(pending_connect, Entry)),
?assertEqual(true, maps:get(owns_connected_tracking, Entry)),
?assertEqual(MRef, maps:get(mref, Entry)),
?assertEqual(UserId, maps:get(user_id, Entry)).
@@ -712,6 +835,7 @@ enqueue_creates_pending_session_entry_test() ->
Entry = enqueued_session_entry(SessionId, UserId, pending_connect_state(#{})),
demonitor(maps:get(mref, Entry), [flush]),
?assertEqual(true, maps:get(pending_connect, Entry)),
?assertNot(maps:is_key(owns_connected_tracking, Entry)),
?assertEqual(UserId, maps:get(user_id, Entry)).
-endif.
+342
View File
@@ -0,0 +1,342 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_cutover).
-feature(maybe_expr, enable).
-typing([eqwalizer]).
-export([run/1, reverse/1, move/4, routes/2]).
-define(PIN_KEY, guild_owner_pins).
-define(DEFAULT_ROUTE_TIMEOUT, 5000).
-define(DEFAULT_RELOAD_TIMEOUT, 120000).
-define(DEFAULT_MAX_MAILBOX, 100).
-define(PROBE_KEY, 0).
-define(KEEPER_KEYS, [guild_pin_keeper_armed, guild_pin_keeper_ever_armed]).
-type guild_id() :: integer().
-type saved_pins() :: #{node() => {ok, term()} | undefined}.
-type report() :: map().
-export_type([report/0]).
-spec run(map()) -> {ok, report()} | {error, report()}.
run(#{guild_id := GuildId, source_node := SourceNode} = Opts) when
is_integer(GuildId), is_atom(SourceNode)
->
move(GuildId, SourceNode, node(), maps:merge(#{transfer_sessions => true}, Opts)).
-spec reverse(map()) -> {ok, report()} | {error, report()}.
reverse(#{guild_id := GuildId, target_node := TargetNode} = Opts) when
is_integer(GuildId), is_atom(TargetNode)
->
move(GuildId, node(), TargetNode, maps:merge(#{transfer_sessions => false}, Opts)).
-spec routes(guild_id(), [node()]) -> #{node() => term()}.
routes(GuildId, Nodes) ->
Args = [GuildId, guilds],
Results = erpc:multicall(
Nodes, gateway_node_router, owner_node_result, Args, ?DEFAULT_ROUTE_TIMEOUT
),
maps:from_list(lists:zip(Nodes, Results)).
-spec move(guild_id(), node(), node(), map()) -> {ok, report()} | {error, report()}.
move(GuildId, From, To, Opts) ->
Nodes = route_nodes(From, To, Opts),
case preflight(GuildId, From, To, Nodes, Opts) of
{ok, SrcPid, SrcShard, Saved} ->
TransferOpts = transfer_opts(GuildId, From, To, Nodes, Saved, Opts),
Result = guild_handoff_freeze:transfer(GuildId, SrcPid, SrcShard, To, TransferOpts),
finish(GuildId, SrcPid, {From, To, Nodes}, Result, Opts);
{error, Reason} ->
{error, #{phase => preflight, reason => Reason, from => From, to => To}}
end.
-spec route_nodes(node(), node(), map()) -> [node()].
route_nodes(From, To, Opts) ->
Nodes = maps:get(route_nodes, Opts, [node() | nodes()]),
lists:usort([From, To | [N || N <- Nodes, is_atom(N)]]).
-spec preflight(guild_id(), node(), node(), [node()], map()) ->
{ok, pid(), pid(), saved_pins()} | {error, term()}.
preflight(_GuildId, Node, Node, _Nodes, _Opts) ->
{error, same_node};
preflight(GuildId, From, To, Nodes, Opts) ->
maybe
ok ?= keeper_idle(lists:usort([node(), From, To])),
ok ?= connected([From, To]),
ok ?= expect_routes(GuildId, Nodes, From),
{ok, SrcShard} ?= guild_handoff_freeze:shard_pid(GuildId, From),
{ok, SrcPid} ?= lookup(SrcShard, GuildId),
ok ?= absent_on(GuildId, To),
ok ?= mailbox_below(SrcPid, maps:get(max_mailbox, Opts, ?DEFAULT_MAX_MAILBOX)),
false ?= guild_handoff_freeze:is_frozen(SrcPid),
{ok, Saved} ?= read_pins(Nodes),
ok ?= probe_pins(Nodes, To, Saved),
{ok, SrcPid, SrcShard, Saved}
else
true -> {error, source_already_frozen};
{error, _} = Error -> Error
end.
-spec transfer_opts(guild_id(), node(), node(), [node()], saved_pins(), map()) ->
guild_handoff_freeze:opts().
transfer_opts(GuildId, From, To, Nodes, Saved, Opts) ->
Base = maps:with(
[
suspend_timeout,
export_timeout,
start_timeout,
stop_timeout,
barrier_timeout,
rpc_timeout,
guard_timeout,
freeze_budget,
commit_timeout,
forward_rounds,
transfer_sessions,
max_heap_words,
measure
],
Opts
),
Others = [N || N <- Nodes, N =/= To],
Base#{
before_start => fun() -> pin_and_verify([To], GuildId, To, Saved) end,
after_start => fun(_NewPid) -> pin_and_verify(Others, GuildId, To, Saved) end,
on_abort => fun() -> restore_and_verify(Nodes, GuildId, From, Saved) end
}.
-spec finish(
guild_id(), pid(), {node(), node(), [node()]}, guild_handoff_freeze:result(), map()
) ->
{ok, report()} | {error, report()}.
finish(GuildId, _SrcPid, {From, To, Nodes}, {ok, Report}, Opts) ->
Reload = maybe_reload(GuildId, To, Opts),
{ok, Report#{
from => From,
to => To,
reload => Reload,
routes_after => routes(GuildId, Nodes)
}};
finish(GuildId, SrcPid, {From, To, Nodes}, {error, Report}, Opts) ->
Repair = repair_source(GuildId, SrcPid, Report, Opts),
{error, Report#{
from => From,
to => To,
source_reload => Repair,
routes_after => routes(GuildId, Nodes)
}}.
-spec repair_source(guild_id(), pid(), report(), map()) -> skipped | term().
repair_source(GuildId, SrcPid, #{abort := #{routes := Routes} = Abort}, Opts) when
Routes =/= skipped
->
case maps:get(exposed, Abort, true) of
true -> guild_handoff_freeze:repair(GuildId, SrcPid, repair_opts(Opts));
false -> skipped
end;
repair_source(_GuildId, _SrcPid, _Report, _Opts) ->
skipped.
-spec repair_opts(map()) -> guild_handoff_freeze:opts().
repair_opts(Opts) ->
maps:with([suspend_timeout, guard_timeout, repair_timeout], Opts).
-spec maybe_reload(guild_id(), node(), map()) -> skipped | term().
maybe_reload(GuildId, To, #{reload := true} = Opts) ->
reload(GuildId, To, Opts);
maybe_reload(_GuildId, _To, _Opts) ->
skipped.
-spec reload(guild_id(), node(), map()) -> term().
reload(GuildId, Node, Opts) ->
Timeout = maps:get(reload_timeout, Opts, ?DEFAULT_RELOAD_TIMEOUT),
Args = [guild_manager, {reload_guild, GuildId}, Timeout],
try erpc:call(Node, gen_server, call, Args, Timeout + 5000) of
Reply -> Reply
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec keeper_idle([node()]) -> ok | {error, term()}.
keeper_idle(Nodes) ->
Answers = [
{Node, Key, Answer}
|| Key <- ?KEEPER_KEYS,
{Node, Answer} <- lists:zip(
Nodes,
erpc:multicall(
Nodes, application, get_env, [fluxer_gateway, Key], ?DEFAULT_ROUTE_TIMEOUT
)
)
],
Active = [{Node, Key} || {Node, Key, {ok, {ok, true}}} <- Answers],
Unreadable = [{Node, Key, A} || {Node, Key, A} <- Answers, not keeper_answer(A)],
case {Active, Unreadable} of
{[], []} -> ok;
{[], _} -> {error, {pin_keeper_unreadable, Unreadable}};
_ -> {error, {pin_keeper_active, Active}}
end.
-spec keeper_answer(term()) -> boolean().
keeper_answer({ok, undefined}) -> true;
keeper_answer({ok, {ok, _Value}}) -> true;
keeper_answer(_Answer) -> false.
-spec connected([node()]) -> ok | {error, term()}.
connected(Nodes) ->
case [N || N <- Nodes, N =/= node(), not lists:member(N, nodes())] of
[] -> ok;
Missing -> {error, {not_connected, Missing}}
end.
-spec expect_routes(guild_id(), [node()], node()) -> ok | {error, term()}.
expect_routes(GuildId, Nodes, Owner) ->
case [{N, R} || {N, R} <- maps:to_list(routes(GuildId, Nodes)), R =/= {ok, {ok, Owner}}] of
[] -> ok;
Wrong -> {error, {routes_disagree, Owner, Wrong}}
end.
-spec lookup(pid(), guild_id()) -> {ok, pid()} | {error, term()}.
lookup(Shard, GuildId) ->
try gen_server:call(Shard, {lookup, GuildId}, ?DEFAULT_ROUTE_TIMEOUT) of
{ok, Pid} when is_pid(Pid) -> {ok, Pid};
Other -> {error, {source_lookup, Other}}
catch
exit:Reason -> {error, {source_lookup, Reason}}
end.
-spec absent_on(guild_id(), node()) -> ok | {error, term()}.
absent_on(GuildId, Node) ->
case guild_handoff_freeze:shard_pid(GuildId, Node) of
{ok, Shard} ->
try gen_server:call(Shard, {lookup, GuildId}, ?DEFAULT_ROUTE_TIMEOUT) of
{error, not_found} -> ok;
Other -> {error, {target_not_empty, Other}}
catch
exit:Reason -> {error, {target_lookup, Reason}}
end;
{error, _} = Error ->
Error
end.
-spec mailbox_below(pid(), non_neg_integer()) -> ok | {error, term()}.
mailbox_below(Pid, Max) ->
Args = [Pid, message_queue_len],
try erpc:call(node(Pid), erlang, process_info, Args, ?DEFAULT_ROUTE_TIMEOUT) of
{message_queue_len, Len} when Len =< Max -> ok;
Other -> {error, {source_mailbox, Other}}
catch
Class:Reason -> {error, {source_mailbox, Class, Reason}}
end.
-spec read_pins([node()]) -> {ok, saved_pins()} | {error, term()}.
read_pins(Nodes) ->
Results = erpc:multicall(
Nodes, application, get_env, [fluxer_gateway, ?PIN_KEY], ?DEFAULT_ROUTE_TIMEOUT
),
case [{N, R} || {N, R} <- lists:zip(Nodes, Results), not readable_pin(R)] of
[] -> {ok, maps:from_list([{N, V} || {N, {ok, V}} <- lists:zip(Nodes, Results)])};
Failed -> {error, {pins_unreadable, Failed}}
end.
-spec readable_pin(term()) -> boolean().
readable_pin({ok, undefined}) -> true;
readable_pin({ok, {ok, Pins}}) when is_map(Pins) -> true;
readable_pin(_) -> false.
-spec probe_pins([node()], node(), saved_pins()) -> ok | {error, term()}.
probe_pins(Nodes, Owner, Saved) ->
Probe = pin_and_verify(Nodes, ?PROBE_KEY, Owner, Saved),
Restore = write_pins([{N, maps:get(N, Saved, undefined)} || N <- Nodes]),
case {Probe, Restore} of
{ok, ok} -> ok;
_ -> {error, {pin_probe_failed, Probe, Restore}}
end.
-spec pin_and_verify([node()], guild_id(), node(), saved_pins()) -> ok | {error, term()}.
pin_and_verify([], _GuildId, _Owner, _Saved) ->
ok;
pin_and_verify(Nodes, GuildId, Owner, Saved) ->
Values = [{N, {ok, pinned(maps:get(N, Saved, undefined), GuildId, Owner)}} || N <- Nodes],
maybe
ok ?= write_pins(Values),
expect_routes(GuildId, Nodes, Owner)
end.
-spec restore_and_verify([node()], guild_id(), node(), saved_pins()) -> ok | {error, term()}.
restore_and_verify(Nodes, GuildId, Owner, Saved) ->
Values = [{N, maps:get(N, Saved, undefined)} || N <- Nodes],
maybe
ok ?= write_pins(Values),
expect_routes(GuildId, Nodes, Owner)
end.
-spec pinned({ok, term()} | undefined, guild_id(), node()) -> map().
pinned({ok, Pins}, GuildId, Owner) when is_map(Pins) ->
Pins#{GuildId => Owner};
pinned(_Previous, GuildId, Owner) ->
#{GuildId => Owner}.
-spec write_pins([{node(), {ok, term()} | undefined}]) -> ok | {error, term()}.
write_pins(Values) ->
Groups = maps:groups_from_list(fun({_N, V}) -> V end, fun({N, _V}) -> N end, Values),
Failed = maps:fold(fun write_group/3, [], Groups),
case Failed of
[] -> ok;
_ -> {error, {pin_write_failed, Failed}}
end.
-spec write_group({ok, term()} | undefined, [node()], list()) -> list().
write_group({ok, Value}, Nodes, Acc) ->
Args = [fluxer_gateway, ?PIN_KEY, Value],
collect_failures(
Nodes, erpc:multicall(Nodes, application, set_env, Args, ?DEFAULT_ROUTE_TIMEOUT), Acc
);
write_group(undefined, Nodes, Acc) ->
Args = [fluxer_gateway, ?PIN_KEY],
collect_failures(
Nodes, erpc:multicall(Nodes, application, unset_env, Args, ?DEFAULT_ROUTE_TIMEOUT), Acc
).
-spec collect_failures([node()], list(), list()) -> list().
collect_failures(Nodes, Results, Acc) ->
[{N, R} || {N, R} <- lists:zip(Nodes, Results), R =/= {ok, ok}] ++ Acc.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
cutover_refuses_once_the_pin_keeper_has_armed_test() ->
Opts = #{guild_id => 42, target_node => '[email protected]'},
application:set_env(fluxer_gateway, guild_pin_keeper_ever_armed, true),
try
?assertMatch(
{error, #{
phase := preflight,
reason := {pin_keeper_active, [{_, guild_pin_keeper_ever_armed}]}
}},
reverse(Opts)
)
after
application:unset_env(fluxer_gateway, guild_pin_keeper_ever_armed)
end,
?assertMatch(
{error, #{phase := preflight, reason := {pin_keeper_unreadable, _}}}, reverse(Opts)
).
exposed_abort_repairs_the_source_test() ->
Gone = spawn(fun() -> ok end),
Ref = erlang:monitor(process, Gone),
receive
{'DOWN', Ref, process, Gone, _} -> ok
end,
Exposed = #{abort => #{routes => ok, exposed => true}},
?assertEqual({error, noproc}, repair_source(42, Gone, Exposed, #{})),
Crashed = #{abort => #{routes => ok, target => ok}},
?assertEqual({error, noproc}, repair_source(42, Gone, Crashed, #{})),
Unexposed = #{abort => #{routes => ok, exposed => false}},
?assertEqual(skipped, repair_source(42, Gone, Unexposed, #{})),
Unrouted = #{abort => #{routes => skipped, exposed => true}},
?assertEqual(skipped, repair_source(42, Gone, Unrouted, #{})).
-endif.
+50 -19
View File
@@ -157,8 +157,10 @@ get_guild_state(UserId, State) ->
JoinedAt
).
-spec build_connect_snapshot(map(), guild_state()) -> map().
build_connect_snapshot(Item, State) ->
-spec build_connect_snapshot(map() | [map()], guild_state()) -> map().
build_connect_snapshot(Item, State) when is_map(Item) ->
build_connect_snapshot([Item], State);
build_connect_snapshot(Items, State) ->
Base = maps:with(
[
id,
@@ -172,28 +174,38 @@ build_connect_snapshot(Item, State) ->
],
State
),
project_snapshot_sessions(maybe_trim_connect_snapshot(Item, Base, State)).
project_snapshot_sessions(maybe_trim_connect_snapshot(Items, Base, State)).
-spec project_snapshot_sessions(map()) -> map().
project_snapshot_sessions(#{sessions := Sessions} = Snapshot) when is_map(Sessions) ->
Projected = maps:map(
fun(_SessionId, SessionData) -> project_snapshot_session(SessionData) end,
Sessions
),
Snapshot#{sessions => Projected};
case snapshot_reads_sessions(Snapshot) of
true ->
Projected = maps:map(
fun(_SessionId, SessionData) -> project_snapshot_session(SessionData) end,
Sessions
),
Snapshot#{sessions => Projected};
false ->
Snapshot#{sessions => #{}}
end;
project_snapshot_sessions(Snapshot) ->
Snapshot.
-spec snapshot_reads_sessions(map()) -> boolean().
snapshot_reads_sessions(Snapshot) ->
guild_availability_check:get_unavailability_mode_from_state(Snapshot) =:=
unavailable_for_everyone_but_staff.
-spec project_snapshot_session(term()) -> term().
project_snapshot_session(SessionData) when is_map(SessionData) ->
maps:without(?CONNECT_SNAPSHOT_HEAVY_SESSION_KEYS, SessionData);
project_snapshot_session(SessionData) ->
SessionData.
-spec maybe_trim_connect_snapshot(map(), map(), guild_state()) -> map().
maybe_trim_connect_snapshot(Item, Base, State) ->
-spec maybe_trim_connect_snapshot([map()], map(), guild_state()) -> map().
maybe_trim_connect_snapshot(Items, Base, State) ->
case should_trim_connect_snapshot(State) of
true -> trim_connect_snapshot(Item, Base);
true -> trim_connect_snapshot(Items, Base);
false -> Base
end.
@@ -205,9 +217,9 @@ should_trim_connect_snapshot(State) ->
has_members_ets(#{data := #{members_ets := Tab}}) -> is_reference(Tab);
has_members_ets(_) -> false.
-spec trim_connect_snapshot(map(), map()) -> map().
trim_connect_snapshot(Item, #{data := Data} = Base) when is_map(Data) ->
Retained = retained_member_map(Item, Base, Data),
-spec trim_connect_snapshot([map()], map()) -> map().
trim_connect_snapshot(Items, #{data := Data} = Base) when is_map(Data) ->
Retained = retained_member_map(Items, Base, Data),
Trimmed = maps:without(?CONNECT_SNAPSHOT_HEAVY_MEMBER_KEYS, Data),
Base#{
data => Trimmed#{
@@ -217,12 +229,12 @@ trim_connect_snapshot(Item, #{data := Data} = Base) when is_map(Data) ->
guild_data_index_members:build_member_role_index(Retained)
}
};
trim_connect_snapshot(_Item, Base) ->
trim_connect_snapshot(_Items, Base) ->
Base.
-spec retained_member_map(map(), map(), map()) -> #{integer() => map()}.
retained_member_map(Item, Base, Data) ->
UserIds = [connect_user_id(Item) | voice_state_user_ids(Base)],
-spec retained_member_map([map()], map(), map()) -> #{integer() => map()}.
retained_member_map(Items, Base, Data) ->
UserIds = [connect_user_id(Item) || Item <- Items] ++ voice_state_user_ids(Base),
lists:foldl(
fun(UserId, Acc) -> retain_member(UserId, Data, Acc) end,
#{},
@@ -480,8 +492,12 @@ guild_id_wire_value(GuildId) ->
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
staff_only_data() ->
#{<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF">>]}}.
projection_snapshot() ->
#{
data => staff_only_data(),
sessions => #{
<<"s1">> => #{
session_id => <<"s1">>,
@@ -520,9 +536,24 @@ projection_leaves_snapshot_data_untouched_test() ->
?assertEqual(Data, maps:get(data, project_snapshot_sessions(Snapshot))).
projection_keeps_non_map_session_entries_test() ->
Snapshot = #{sessions => #{<<"broken">> => not_a_map}},
Snapshot = #{data => staff_only_data(), sessions => #{<<"broken">> => not_a_map}},
?assertEqual(Snapshot, project_snapshot_sessions(Snapshot)).
projection_drops_sessions_unless_staff_only_test() ->
Snapshot = projection_snapshot(),
lists:foreach(
fun(Data) ->
Projected = project_snapshot_sessions(Snapshot#{data => Data}),
?assertEqual(#{}, maps:get(sessions, Projected)),
?assertEqual(Data, maps:get(data, Projected))
end,
[
#{},
#{<<"guild">> => #{<<"features">> => []}},
#{<<"guild">> => #{<<"features">> => [<<"UNAVAILABLE_FOR_EVERYONE">>]}}
]
).
projection_keeps_snapshot_without_sessions_test() ->
Snapshot = #{id => 42, member_count => 3},
?assertEqual(Snapshot, project_snapshot_sessions(Snapshot)).
+40 -14
View File
@@ -21,6 +21,8 @@
-define(MAX_PARTNERS, 1000).
-define(PRUNE_SLACK, 64).
-define(VIEW_MEMO_KEY, dm_partner_view_memo).
-define(VIEW_MEMO_LIMIT, 4096).
-spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}.
handle_cast({update_dm_partners, SessionId, PartnerIds}, State) when
@@ -84,21 +86,21 @@ channel_inputs(Channels) ->
-spec update(session_id(), [term()], guild_state()) -> guild_state().
update(SessionId, PartnerIds, State) ->
Registrations = registrations(State),
Next =
{Next, State1} =
case session_owner(SessionId, State) of
{ok, UserId, Pid} ->
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State);
error ->
maps:remove(SessionId, Registrations)
{maps:remove(SessionId, Registrations), State}
end,
State#{dm_partners => maybe_prune(Next, State)}.
State1#{dm_partners => maybe_prune(Next, State1)}.
-spec update_owned(session_id(), user_id(), pid(), [term()], registrations(), guild_state()) ->
registrations().
{registrations(), guild_state()}.
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State) ->
case presence_targets:dm_partner_presence_enabled(UserId) of
false ->
maps:remove(SessionId, Registrations);
{maps:remove(SessionId, Registrations), State};
true ->
Entry = #{
user_id => UserId,
@@ -108,14 +110,16 @@ update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State) ->
maps:get(SessionId, Registrations, undefined), Pid
)
},
put_all(evaluate(#{SessionId => Entry}, State), Registrations)
{Evaluated, State1} = evaluate(#{SessionId => Entry}, State),
{put_all(Evaluated, Registrations), State1}
end.
-spec reevaluate(scope(), registrations(), guild_state()) -> guild_state().
reevaluate(none, _Registrations, State) ->
State;
reevaluate(all, Registrations, State) ->
State#{dm_partners => evaluate(live_registrations(Registrations, State), State)};
{Evaluated, State1} = evaluate(live_registrations(Registrations, State), State),
State1#{dm_partners => Evaluated};
reevaluate({user, UserId}, Registrations, State) ->
Affected = maps:fold(
fun(SessionId, Entry, Acc) ->
@@ -135,7 +139,8 @@ reevaluate({user, UserId}, Registrations, State) ->
Kept = maps:without(
maps:keys(maps:without(maps:keys(Live), Affected)), Registrations
),
State#{dm_partners => maybe_prune(put_all(evaluate(Live, State), Kept), State)}
{Evaluated, State1} = evaluate(Live, State),
State1#{dm_partners => maybe_prune(put_all(Evaluated, Kept), State1)}
end.
-spec put_all(registrations(), registrations()) -> registrations().
@@ -156,22 +161,43 @@ maybe_prune(Registrations, State) ->
Registrations
end.
-spec evaluate(registrations(), guild_state()) -> registrations().
evaluate(Entries, _State) when map_size(Entries) =:= 0 ->
Entries;
-spec evaluate(registrations(), guild_state()) -> {registrations(), guild_state()}.
evaluate(Entries, State) when map_size(Entries) =:= 0 ->
{Entries, State};
evaluate(Entries, State) ->
Requests = [
{SessionId, UserId, maps:keys(Partners)}
|| {SessionId, #{user_id := UserId, partners := Partners}} <- maps:to_list(Entries)
],
Results = guild_subscription_mutual_channels:filter_session_member_ids(Requests, State),
{Memo0, Inputs} = view_memo(State),
{Results, Memo} = guild_subscription_mutual_channels:filter_session_member_ids(
Requests, Memo0, State
),
GuildId = maps:get(id, State),
maps:map(
Evaluated = maps:map(
fun(SessionId, Entry) ->
apply_result(GuildId, maps:get(SessionId, Results, []), Entry)
end,
Entries
).
),
{Evaluated, store_view_memo(Inputs, Memo, State)}.
-spec view_memo(guild_state()) ->
{guild_subscription_mutual_channels:view_memo(), term()}.
view_memo(State) ->
Inputs = visibility_inputs(State),
case maps:get(?VIEW_MEMO_KEY, State, undefined) of
#{inputs := Inputs, memo := Memo} when is_map(Memo) -> {Memo, Inputs};
_ -> {#{}, Inputs}
end.
-spec store_view_memo(term(), guild_subscription_mutual_channels:view_memo(), guild_state()) ->
guild_state().
store_view_memo(Inputs, Memo, State) ->
case map_size(maps:get(views, Memo, #{})) > ?VIEW_MEMO_LIMIT of
true -> maps:remove(?VIEW_MEMO_KEY, State);
false -> State#{?VIEW_MEMO_KEY => #{inputs => Inputs, memo => Memo}}
end.
-spec apply_result(integer(), [user_id()], registration()) -> registration().
apply_result(GuildId, EligibleIds, #{pid := Pid, eligible := Previous} = Entry) ->
+23 -5
View File
@@ -5,6 +5,7 @@
-export([
export_handoff_state/1,
derived_data_keys/0,
validate_handoff_state/1,
remonitor_transferred_sessions/1,
restore_transferred_session_state/1
@@ -18,7 +19,7 @@
export_handoff_state(State) ->
#{
id => maps:get(id, State),
data => maps:get(data, State, #{}),
data => maps:without(derived_data_keys(), maps:get(data, State, #{})),
sessions => export_handoff_sessions(maps:get(sessions, State, #{})),
voice_states => maps:get(voice_states, State, #{}),
virtual_channel_access => maps:get(virtual_channel_access, State, #{}),
@@ -30,6 +31,19 @@ export_handoff_state(State) ->
maps:get(virtual_channel_access_move_pending, State, #{})
}.
-spec derived_data_keys() -> [atom() | binary()].
derived_data_keys() ->
[
members_normalized,
members_sorted_ids,
member_list_revision,
members_ets,
role_perms_cache,
overwrite_perms_cache,
<<"role_index">>,
<<"member_role_index">>
].
-spec validate_handoff_state(term()) -> ok | {error, [atom()]}.
validate_handoff_state(Exported) when is_map(Exported) ->
RequiredKeys = [id, data, sessions, voice_states],
@@ -140,11 +154,15 @@ restore_transferred_session(_SessionId, _SessionData, State) ->
State.
-spec active_session_user_id(map()) -> integer() | undefined.
active_session_user_id(#{pending_connect := true}) ->
undefined;
active_session_user_id(SessionData) ->
case {maps:get(user_id, SessionData, undefined), maps:get(pid, SessionData, undefined)} of
{UserId, Pid} when is_integer(UserId), UserId > 0, is_pid(Pid) ->
case
{
guild_sessions_connect:counts_as_connected(SessionData),
maps:get(user_id, SessionData, undefined),
maps:get(pid, SessionData, undefined)
}
of
{true, UserId, Pid} when is_integer(UserId), UserId > 0, is_pid(Pid) ->
UserId;
_ ->
undefined
@@ -0,0 +1,941 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_handoff_freeze).
-typing([eqwalizer]).
-export([
transfer/5,
repair/3,
default_opts/0,
is_frozen/1,
shard_pid/2,
ensure_absent/3
]).
-define(SHARD_TABLE, guild_manager_shard_table).
-define(GUARD_EXPRS,
"R = erlang:monitor(process, C),"
" C ! {freeze_guard_ready, self()},"
" receive"
" {freeze_guard_release, C} -> erlang:demonitor(R, [flush]), released;"
" {'DOWN', R, process, C, _} ->"
" try sys:resume(P, 60000) of ok -> resumed catch _:Reason -> {resume_failed, Reason} end"
" end."
).
-define(CAPTURE_EXPRS,
"F = fun({'$gen_call', _, _}) -> true;"
" ({'$gen_cast', _}) -> true;"
" ({presence, U, _}) when is_integer(U) -> true;"
" ({reconcile_user_presence, _}) -> true;"
" ({clear_stale_cached_voice_states, _}) -> true;"
" (_) -> false end,"
" case erlang:process_info(P, messages) of"
" {messages, Ms} ->"
" R = [M || M <- Ms, F(M)], L = length(R),"
" {L, case L >= S of true -> lists:nthtail(S, R); false -> [] end};"
" undefined -> undefined"
" end."
).
-define(DRAIN_POLL_MS, 10).
-define(REPAIR_EXPRS,
"D = erlang:monotonic_time(millisecond) + T,"
" case M:fetch_guild_data(G) of"
" {ok, Data} ->"
" case erlang:monotonic_time(millisecond) < D of"
" true ->"
" R = fun(S) -> case A:handle_call({reload, Data}, {self(), make_ref()}, S) of"
" {reply, _, S1} -> S1;"
" {reply, _, S1, _} -> S1"
" end end,"
" _ = sys:replace_state(P, R, max(1, D - erlang:monotonic_time(millisecond))),"
" reloaded;"
" false -> late"
" end;"
" Other -> {fetch_failed, Other}"
" end."
).
-define(EXPORT_EXPRS,
"maps:merge(maps:update_with(data, fun(D) -> maps:without(K, D) end,"
" guild_handoff:export_handoff_state(sys:get_state(P, T))), X)."
).
-type guild_id() :: integer().
-type hook() :: fun(() -> ok | {error, term()}).
-type started_hook() :: fun((pid()) -> ok | {error, term()}).
-type opts() :: #{
suspend_timeout => pos_integer(),
export_timeout => pos_integer(),
start_timeout => pos_integer(),
stop_timeout => pos_integer(),
barrier_timeout => pos_integer(),
rpc_timeout => pos_integer(),
guard_timeout => pos_integer(),
freeze_budget => pos_integer(),
commit_timeout => pos_integer(),
repair_timeout => pos_integer(),
forward_rounds => non_neg_integer(),
transfer_sessions => boolean(),
max_heap_words => non_neg_integer(),
measure => boolean(),
before_start => hook(),
after_start => started_hook(),
on_abort => hook()
}.
-type report() :: map().
-type result() :: {ok, report()} | {error, report()}.
-type freeze() :: #{pid := pid(), guard := pid(), guard_ref := reference()}.
-export_type([opts/0, report/0, result/0]).
-spec default_opts() -> opts().
default_opts() ->
#{
suspend_timeout => 30000,
export_timeout => 120000,
start_timeout => 120000,
stop_timeout => 60000,
barrier_timeout => 30000,
rpc_timeout => 10000,
guard_timeout => 10000,
freeze_budget => 90000,
commit_timeout => 300000,
repair_timeout => 30000,
forward_rounds => 3,
transfer_sessions => true,
max_heap_words => 0,
measure => false,
before_start => fun() -> ok end,
after_start => fun(_Pid) -> ok end,
on_abort => fun() -> ok end
}.
-spec transfer(guild_id(), pid(), pid(), node(), opts()) -> result().
transfer(GuildId, SrcPid, SrcShard, TargetNode, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
Parent = self(),
Tag = make_ref(),
Notify = fun(Event) ->
Parent ! {Tag, Event},
ok
end,
{Pid, Ref} = spawn_monitor(fun() ->
process_flag(trap_exit, true),
ok = maybe_limit_heap(maps:get(max_heap_words, Opts)),
Parent ! {Tag, run(GuildId, SrcPid, SrcShard, TargetNode, Opts, Notify)}
end),
await(GuildId, TargetNode, {Pid, Ref, Tag}, false, Opts).
-spec await(guild_id(), node(), {pid(), reference(), reference()}, boolean(), opts()) ->
result().
await(GuildId, TargetNode, {Pid, Ref, Tag} = Run, Committed, Opts) ->
receive
{Tag, committed} ->
await(GuildId, TargetNode, Run, true, Opts);
{Tag, Result} ->
erlang:demonitor(Ref, [flush]),
Result;
{'DOWN', Ref, process, Pid, Reason} ->
crashed(GuildId, TargetNode, Reason, Committed, Opts)
end.
-spec crashed(guild_id(), node(), term(), boolean(), opts()) -> result().
crashed(_GuildId, _TargetNode, Reason, true, _Opts) ->
{error, #{phase => crashed, reason => Reason, committed => true}};
crashed(GuildId, TargetNode, Reason, false, Opts) ->
Routes = safe_hook(maps:get(on_abort, Opts)),
Target = ensure_absent(GuildId, TargetNode, Opts),
{error, #{
phase => crashed, reason => Reason, abort => #{routes => Routes, target => Target}
}}.
-spec repair(guild_id(), pid(), opts()) -> ok | {error, term()}.
repair(GuildId, SrcPid, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
maybe
ok ?= await_thawed(SrcPid, now_ms() + maps:get(guard_timeout, Opts)),
{ok, Freeze} ?= freeze(SrcPid, Opts),
Reload = reload_frozen(GuildId, SrcPid, maps:get(repair_timeout, Opts)),
repaired(Reload, thaw(Freeze, Opts))
end.
-spec await_thawed(pid(), integer()) -> ok | {error, term()}.
await_thawed(SrcPid, Deadline) ->
case is_frozen(SrcPid) of
false ->
ok;
true ->
case now_ms() < Deadline of
true ->
timer:sleep(?DRAIN_POLL_MS),
await_thawed(SrcPid, Deadline);
false ->
{error, still_frozen}
end;
{error, _} = Error ->
Error
end.
-spec reload_frozen(guild_id(), pid(), pos_integer()) -> ok | {error, term()}.
reload_frozen(GuildId, SrcPid, Timeout) ->
Bindings = bindings([
{'G', GuildId},
{'P', SrcPid},
{'T', Timeout},
{'M', guild_manager_shard_fetch},
{'A', guild}
]),
Args = [parse(?REPAIR_EXPRS), Bindings],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, Timeout + 5000) of
{ok, {value, reloaded, _}} -> ok;
{ok, {value, Other, _}} -> {error, Other};
{error, _} = Error -> Error
end.
-spec repaired(ok | {error, term()}, ok | {error, term()}) -> ok | {error, term()}.
repaired(ok, ok) ->
ok;
repaired(Reload, Thaw) ->
{error, #{reload => Reload, thaw => Thaw}}.
-spec is_frozen(pid()) -> boolean() | {error, term()}.
is_frozen(Pid) ->
case rpc_call(node(Pid), erlang, process_info, [Pid, current_function], 10000) of
{ok, {current_function, {sys, _, _}}} -> true;
{ok, {current_function, _}} -> false;
{ok, undefined} -> {error, noproc};
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec shard_pid(guild_id(), node()) -> {ok, pid()} | {error, term()}.
shard_pid(GuildId, Node) ->
case rpc_call(Node, ets, lookup, [?SHARD_TABLE, shard_count], 10000) of
{ok, [{shard_count, Count}]} when is_integer(Count), Count > 0 ->
Index = guild_manager_shards:select_shard(GuildId, Count),
indexed_shard_pid(Node, Index);
{ok, _} ->
{error, shard_table_unavailable};
{error, _} = Error ->
Error
end.
-spec ensure_absent(guild_id(), node(), opts()) -> ok | {error, term()}.
ensure_absent(GuildId, Node, Opts0) ->
Opts = maps:merge(default_opts(), Opts0),
StopTimeout = maps:get(start_timeout, Opts) + maps:get(stop_timeout, Opts),
case shard_pid(GuildId, Node) of
{ok, Shard} ->
_ = safe_call(Shard, {stop_guild, GuildId}, StopTimeout),
case safe_call(Shard, {lookup, GuildId}, maps:get(rpc_timeout, Opts)) of
{error, not_found} -> ok;
{ok, Pid} when is_pid(Pid) -> {error, {still_running, Pid}};
Other -> {error, {unverified, Other}}
end;
{error, _} = Error ->
Error
end.
-spec run(guild_id(), pid(), pid(), node(), opts(), fun((committed) -> ok)) -> result().
run(GuildId, SrcPid, SrcShard, TargetNode, Opts, Notify) ->
Ctx = #{
notify => Notify,
guild_id => GuildId,
src => SrcPid,
src_shard => SrcShard,
target => TargetNode,
opts => Opts,
t0 => now_ms(),
report => #{source => SrcPid, target_node => TargetNode}
},
case is_frozen(SrcPid) of
false -> run_freeze(Ctx);
true -> fail(preflight, already_frozen, Ctx);
{error, Reason} -> fail(preflight, Reason, Ctx)
end.
-spec run_freeze(map()) -> result().
run_freeze(#{src := SrcPid, opts := Opts} = Ctx) ->
T0 = now_ms(),
case freeze(SrcPid, Opts) of
{ok, Freeze} ->
Ctx1 = put_ms(suspend_ms, T0, Ctx#{freeze => Freeze, frozen_at => now_ms()}),
run_export(Ctx1);
{error, Reason} ->
fail(freeze, Reason, Ctx)
end.
-spec run_export(map()) -> result().
run_export(#{src := SrcPid, opts := Opts} = Ctx) ->
T0 = now_ms(),
case export(SrcPid, Opts) of
{ok, Export} ->
Ctx1 = put_ms(export_ms, T0, Ctx),
Ctx2 = put_report(export_measure, measure_export(Export, Opts), Ctx1),
run_before_start(Export, Ctx2);
{error, Reason} ->
abort(export, Reason, Ctx)
end.
-spec run_before_start(map(), map()) -> result().
run_before_start(Export, #{opts := Opts} = Ctx) ->
case {within_budget(Ctx), guard_held(Ctx)} of
{true, ok} -> run_hook(before_start, maps:get(before_start, Opts), Export, Ctx);
{false, _} -> abort(before_start, freeze_budget_exceeded, Ctx);
{true, {error, Reason}} -> abort(before_start, Reason, Ctx)
end.
-spec run_hook(before_start, hook(), map(), map()) -> result().
run_hook(Phase, Hook, Export, Ctx) ->
case safe_hook(Hook) of
ok -> run_start(Export, Ctx#{routed => true});
{error, Reason} -> abort(Phase, Reason, Ctx#{routed => true})
end.
-spec run_start(map(), map()) -> result().
run_start(Export, #{guild_id := GuildId, target := Target, opts := Opts} = Ctx) ->
T0 = now_ms(),
Ctx1 = Ctx#{start_sent => true},
case start_on(GuildId, Target, Export, maps:get(start_timeout, Opts)) of
{ok, NewPid} ->
run_after_start(NewPid, put_ms(start_ms, T0, Ctx1#{new_pid => NewPid}));
{error, Reason} ->
abort(start, Reason, put_ms(start_ms, T0, Ctx1))
end.
-spec run_after_start(pid(), map()) -> result().
run_after_start(NewPid, #{opts := Opts} = Ctx) ->
case guard_held(Ctx) of
ok -> run_route(NewPid, maps:get(after_start, Opts), Ctx#{exposed => true});
{error, Reason} -> abort(after_start, Reason, Ctx)
end.
-spec run_route(pid(), started_hook(), map()) -> result().
run_route(NewPid, Hook, Ctx) ->
T0 = now_ms(),
case safe_started_hook(Hook, NewPid) of
ok -> run_commit(NewPid, put_ms(route_ms, T0, Ctx));
{error, Reason} -> abort(after_start, Reason, put_ms(route_ms, T0, Ctx))
end.
-spec run_commit(pid(), map()) -> result().
run_commit(NewPid, #{src := SrcPid, opts := Opts} = Ctx) ->
case forward_rounds(SrcPid, NewPid, maps:get(forward_rounds, Opts), 0, [], Opts) of
{ok, Seen, Rounds} ->
commit_final(NewPid, Seen, rounds_report(Rounds, Ctx));
{error, {capture_failed, noproc}, _Seen, Rounds} ->
source_gone(NewPid, rounds_report(Rounds, Ctx));
{error, {capture_failed, _} = Reason, Seen, Rounds} ->
Ctx1 = put_report(rounds_stopped, Reason, rounds_report(Rounds, Ctx)),
commit_final(NewPid, Seen, Ctx1);
{error, Reason, _Seen, Rounds} ->
abort(forward, Reason, rounds_report(Rounds, Ctx))
end.
-spec rounds_report([map()], map()) -> map().
rounds_report(Rounds, Ctx) ->
Forwarded = lists:foldl(fun(Round, Acc) -> Acc + forwarded_count(Round) end, 0, Rounds),
add_forwarded(Forwarded, put_report(rounds, lists:reverse(Rounds), Ctx)).
-spec add_forwarded(non_neg_integer(), map()) -> map().
add_forwarded(Count, Ctx) ->
Ctx#{forwarded => maps:get(forwarded, Ctx, 0) + Count}.
-spec forwarded_count(map()) -> non_neg_integer().
forwarded_count(Stats) ->
lists:foldl(
fun(Kind, Acc) ->
case maps:get(Kind, Stats, 0) of
Count when is_integer(Count), Count > 0 -> Acc + Count;
_ -> Acc
end
end,
0,
[call, cast, presence_repair, voice_cleanup]
).
-spec commit_final(pid(), non_neg_integer(), map()) -> result().
commit_final(NewPid, Seen, #{src := SrcPid} = Ctx) ->
case {target_exit(NewPid), guard_held(Ctx), is_frozen(SrcPid)} of
{{exited, Reason}, _, _} ->
abort(target_died, Reason, Ctx);
{alive, {error, Reason}, _} ->
abort(final_capture, Reason, Ctx);
{alive, ok, true} ->
stop_and_drain(NewPid, Seen, Ctx);
{alive, ok, {error, noproc}} ->
source_gone(NewPid, Ctx);
{alive, ok, Frozen} ->
abort(final_capture, {not_frozen, Frozen}, Ctx)
end.
-spec guard_held(map()) -> ok | {error, term()}.
guard_held(#{freeze := #{guard := Guard, guard_ref := Ref}}) ->
receive
{'DOWN', Ref, process, Guard, Reason} -> {error, {guard_lost, Reason}}
after 0 -> ok
end.
-spec target_exit(pid()) -> alive | {exited, term()}.
target_exit(NewPid) ->
receive
{'EXIT', NewPid, Reason} -> {exited, Reason}
after 0 -> alive
end.
-spec source_gone(pid(), map()) -> result().
source_gone(NewPid, #{src := SrcPid, opts := Opts} = Ctx) ->
case {target_exit(NewPid), source_alive(SrcPid, Opts)} of
{alive, false} -> finish(NewPid, put_report(source_gone, true, Ctx));
Other -> abort(source_gone, {source_state, Other}, Ctx)
end.
-spec stop_and_drain(pid(), non_neg_integer(), map()) -> result().
stop_and_drain(NewPid, Seen, #{src := SrcPid, opts := Opts} = Ctx) ->
case capture(SrcPid, Seen, Opts) of
{ok, _Len, New} ->
Stats = forward(New, NewPid, final),
ok = commit(NewPid, Ctx),
Parent = self(),
Tag = make_ref(),
{Pid, Ref} = spawn_monitor(fun() -> Parent ! {Tag, stop_source(Ctx)} end),
Drain = #{
stopper => {Pid, Ref, Tag},
stop => pending,
stats => Stats,
captures => 1,
t0 => now_ms()
},
drain(NewPid, Seen + length(New), Drain, Ctx);
{error, noproc} ->
source_gone(NewPid, Ctx);
{error, Reason} ->
abort(final_capture, Reason, Ctx)
end.
-spec commit(pid(), map()) -> ok.
commit(NewPid, #{freeze := Freeze, notify := Notify}) ->
release_guard(Freeze),
unlink(NewPid),
receive
{'EXIT', NewPid, _Reason} -> ok
after 0 -> ok
end,
Notify(committed).
-spec drain(pid(), non_neg_integer(), map(), map()) -> result().
drain(NewPid, Seen, Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case capture(SrcPid, Seen, Opts) of
{ok, _Len, New} ->
Stats = merge_counts(maps:get(stats, Drain), forward(New, NewPid, final)),
Drain1 = Drain#{stats => Stats, captures => maps:get(captures, Drain) + 1},
drain_alive(NewPid, Seen + length(New), poll_stopper(Drain1, ?DRAIN_POLL_MS), Ctx);
{error, noproc} ->
drained(NewPid, poll_stopper(Drain, maps:get(stop_timeout, Opts)), Ctx);
{error, Reason} ->
Polled = poll_stopper(Drain, maps:get(stop_timeout, Opts)),
drain_unknown(NewPid, Seen, Reason, Polled, Ctx)
end.
-spec drain_alive(pid(), non_neg_integer(), map(), map()) -> result().
drain_alive(NewPid, Seen, #{stop := pending} = Drain, Ctx) ->
drain(NewPid, Seen, Drain, Ctx);
drain_alive(NewPid, Seen, #{stop := ok} = Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case now_ms() - maps:get(t0, Drain) > maps:get(stop_timeout, Opts) of
true -> exit(SrcPid, kill);
false -> timer:sleep(?DRAIN_POLL_MS)
end,
drain(NewPid, Seen, Drain, Ctx);
drain_alive(NewPid, Seen, #{stop := Reason} = Drain, Ctx) ->
await_source(NewPid, Seen, Reason, Drain, Ctx).
-spec drain_unknown(pid(), non_neg_integer(), term(), map(), map()) -> result().
drain_unknown(NewPid, Seen, Reason, Drain, #{src := SrcPid, opts := Opts} = Ctx) ->
case source_alive(SrcPid, Opts) of
false ->
drained(NewPid, Drain, Ctx);
_ ->
await_source(
NewPid, Seen, {capture_failed, Reason, maps:get(stop, Drain)}, Drain, Ctx
)
end.
-spec await_source(pid(), non_neg_integer(), term(), map(), map()) -> result().
await_source(NewPid, Seen, Reason, Drain, #{opts := Opts} = Ctx) ->
case now_ms() - maps:get(t0, Drain) > maps:get(commit_timeout, Opts) of
true ->
stuck(NewPid, Reason, Drain, Ctx);
false ->
timer:sleep(?DRAIN_POLL_MS),
drain(NewPid, Seen, Drain, Ctx)
end.
-spec stuck(pid(), term(), map(), map()) -> result().
stuck(NewPid, Reason, Drain, Ctx) ->
Forwarded = maps:get(forwarded, Ctx, 0) + forwarded_count(maps:get(stats, Drain)),
Report = maps:get(report, Ctx),
{error, Report#{
phase => stop,
reason => Reason,
committed => true,
new_pid => NewPid,
forwarded => Forwarded,
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec drained(pid(), map(), map()) -> result().
drained(NewPid, Drain, Ctx) ->
Final = (maps:get(stats, Drain))#{
captures => maps:get(captures, Drain),
stop => maps:get(stop, Drain)
},
Ctx1 = put_report(stop_ms, now_ms() - maps:get(t0, Drain), Ctx),
finish(NewPid, put_report(final, Final, Ctx1)).
-spec poll_stopper(map(), non_neg_integer()) -> map().
poll_stopper(#{stop := pending, stopper := {Pid, Ref, Tag}} = Drain, Timeout) ->
receive
{Tag, Result} ->
erlang:demonitor(Ref, [flush]),
Drain#{stop => Result};
{'DOWN', Ref, process, Pid, Reason} ->
Drain#{stop => {error, {stopper_crashed, Reason}}}
after Timeout ->
Drain
end;
poll_stopper(Drain, _Timeout) ->
Drain.
-spec merge_counts(map(), map()) -> map().
merge_counts(Left, Right) ->
maps:merge_with(fun(_Key, A, B) -> A + B end, Left, Right).
-spec finish(pid(), map()) -> result().
finish(NewPid, #{freeze := Freeze, frozen_at := FrozenAt} = Ctx) ->
release_guard(Freeze),
unlink(NewPid),
Report = maps:get(report, Ctx),
{ok, Report#{
new_pid => NewPid,
frozen_ms => now_ms() - FrozenAt,
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec fail(atom(), term(), map()) -> result().
fail(Phase, Reason, Ctx) ->
Report = maps:get(report, Ctx),
{error, Report#{phase => Phase, reason => Reason, total_ms => now_ms() - maps:get(t0, Ctx)}}.
-spec abort(atom(), term(), map()) -> result().
abort(Phase, Reason, #{opts := Opts} = Ctx) ->
Routes = abort_routes(Ctx, maps:get(on_abort, Opts)),
Target = abort_target(Ctx),
Thaw = thaw(maps:get(freeze, Ctx), Opts),
Report = maps:get(report, Ctx),
{error, Report#{
phase => Phase,
reason => Reason,
abort => #{
routes => Routes,
target => Target,
thaw => Thaw,
exposed => maps:get(exposed, Ctx, false),
forwarded => maps:get(forwarded, Ctx, 0)
},
total_ms => now_ms() - maps:get(t0, Ctx)
}}.
-spec abort_routes(map(), hook()) -> ok | {error, term()} | skipped.
abort_routes(#{routed := true}, Hook) ->
safe_hook(Hook);
abort_routes(_Ctx, _Hook) ->
skipped.
-spec abort_target(map()) -> ok | {error, term()} | skipped.
abort_target(#{start_sent := true, guild_id := GuildId, target := Target, opts := Opts} = Ctx) ->
Result = ensure_absent(GuildId, Target, Opts),
case maps:get(new_pid, Ctx, undefined) of
Pid when is_pid(Pid) -> unlink_and_kill_if(Result, Pid);
_ -> Result
end;
abort_target(_Ctx) ->
skipped.
-spec unlink_and_kill_if(ok | {error, term()}, pid()) -> ok | {error, term()}.
unlink_and_kill_if(ok, Pid) ->
unlink(Pid),
ok;
unlink_and_kill_if({error, _} = Error, Pid) ->
unlink(Pid),
exit(Pid, kill),
Error.
-spec freeze(pid(), opts()) -> {ok, freeze()} | {error, term()}.
freeze(SrcPid, Opts) ->
case spawn_guard(SrcPid, maps:get(guard_timeout, Opts)) of
{ok, Guard, GuardRef} ->
Freeze = #{pid => SrcPid, guard => Guard, guard_ref => GuardRef},
case suspend(SrcPid, maps:get(suspend_timeout, Opts)) of
ok ->
{ok, Freeze};
{error, Reason} ->
_ = thaw(Freeze, Opts),
{error, {suspend_failed, Reason}}
end;
{error, Reason} ->
{error, {guard_failed, Reason}}
end.
-spec thaw(freeze(), opts()) -> ok | {error, term()}.
thaw(#{pid := SrcPid} = Freeze, Opts) ->
Result =
try sys:resume(SrcPid, maps:get(suspend_timeout, Opts)) of
ok -> ok
catch
exit:Reason -> {error, Reason}
end,
release_guard(Freeze),
Result.
-spec suspend(pid(), pos_integer()) -> ok | {error, term()}.
suspend(SrcPid, Timeout) ->
try sys:suspend(SrcPid, Timeout) of
ok -> ok
catch
exit:Reason -> {error, Reason}
end.
-spec spawn_guard(pid(), pos_integer()) -> {ok, pid(), reference()} | {error, term()}.
spawn_guard(SrcPid, Timeout) ->
Bindings = bindings([{'C', self()}, {'P', SrcPid}]),
Guard = erlang:spawn(node(SrcPid), erl_eval, exprs, [parse(?GUARD_EXPRS), Bindings]),
Ref = erlang:monitor(process, Guard),
receive
{freeze_guard_ready, Guard} -> {ok, Guard, Ref};
{'DOWN', Ref, process, Guard, Reason} -> {error, Reason}
after Timeout ->
erlang:demonitor(Ref, [flush]),
exit(Guard, kill),
{error, guard_timeout}
end.
-spec release_guard(freeze()) -> ok.
release_guard(#{guard := Guard, guard_ref := Ref}) ->
erlang:demonitor(Ref, [flush]),
Guard ! {freeze_guard_release, self()},
ok.
-spec export(pid(), opts()) -> {ok, map()} | {error, term()}.
export(SrcPid, Opts) ->
Timeout = maps:get(export_timeout, Opts),
Bindings = bindings([
{'P', SrcPid},
{'T', Timeout},
{'K', guild_handoff:derived_data_keys()},
{'X', export_overrides(Opts)}
]),
Args = [parse(?EXPORT_EXPRS), Bindings],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, Timeout + 5000) of
{ok, {value, Export, _}} -> validate_export(Export);
{ok, Other} -> {error, {unexpected_export, Other}};
{error, _} = Error -> Error
end.
-spec export_overrides(opts()) -> map().
export_overrides(#{transfer_sessions := false}) ->
#{sessions => #{}};
export_overrides(_Opts) ->
#{}.
-spec validate_export(term()) -> {ok, map()} | {error, term()}.
validate_export(Export) when is_map(Export) ->
case guild_handoff:validate_handoff_state(Export) of
ok -> {ok, Export};
{error, Missing} -> {error, {invalid_export, Missing}}
end;
validate_export(Other) ->
{error, {invalid_export, Other}}.
-spec measure_export(map(), opts()) -> map() | skipped.
measure_export(Export, #{measure := true}) ->
T0 = now_ms(),
Bytes = erlang:external_size(Export),
#{external_bytes => Bytes, measure_ms => now_ms() - T0};
measure_export(_Export, _Opts) ->
skipped.
-spec start_on(guild_id(), node(), map(), pos_integer()) -> {ok, pid()} | {error, term()}.
start_on(GuildId, Target, Export, Timeout) ->
case shard_pid(GuildId, Target) of
{ok, Shard} ->
case safe_call(Shard, {start_transferred, GuildId, Export}, Timeout) of
{ok, NewPid} when is_pid(NewPid) ->
link(NewPid),
{ok, NewPid};
{error, Reason} ->
{error, Reason};
Other ->
{error, {unexpected_start_reply, Other}}
end;
{error, _} = Error ->
Error
end.
-spec forward_rounds(pid(), pid(), non_neg_integer(), non_neg_integer(), [map()], opts()) ->
{ok, non_neg_integer(), [map()]} | {error, term(), non_neg_integer(), [map()]}.
forward_rounds(_SrcPid, _NewPid, 0, Seen, Rounds, _Opts) ->
{ok, Seen, Rounds};
forward_rounds(SrcPid, NewPid, Left, Seen, Rounds, Opts) ->
case capture(SrcPid, Seen, Opts) of
{ok, Len, _New} when Len < Seen ->
{error, mailbox_shrank, Seen, Rounds};
{ok, _Len, []} ->
{ok, Seen, Rounds};
{ok, _Len, New} ->
Stats = forward(New, NewPid, rounds),
T0 = now_ms(),
Barrier = barrier(NewPid, maps:get(barrier_timeout, Opts)),
Round = Stats#{
captured => length(New), barrier => Barrier, barrier_ms => now_ms() - T0
},
forward_rounds(
SrcPid, NewPid, Left - 1, Seen + length(New), [Round | Rounds], Opts
);
{error, Reason} ->
{error, {capture_failed, Reason}, Seen, Rounds}
end.
-spec forward([term()], pid(), rounds | final) -> map().
forward(Msgs, NewPid, Phase) ->
lists:foldl(
fun(Msg, Acc) ->
Kind = forward_msg(Msg, NewPid, Phase),
maps:update_with(Kind, fun(N) -> N + 1 end, 1, Acc)
end,
#{},
Msgs
).
-spec forward_msg(term(), pid(), rounds | final) -> atom().
forward_msg({'$gen_call', _From, _Request} = Msg, NewPid, rounds) ->
NewPid ! Msg,
call;
forward_msg({'$gen_call', _From, _Request}, _NewPid, final) ->
dropped_call;
forward_msg({'$gen_cast', {session_connect_worker_done, _, _, _, _}}, _NewPid, _Phase) ->
dropped_worker_result;
forward_msg({'$gen_cast', {session_connect_worker_batch_done, _}}, _NewPid, _Phase) ->
dropped_worker_result;
forward_msg({'$gen_cast', _Request} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
cast;
forward_msg({presence, UserId, _Payload}, NewPid, _Phase) ->
NewPid ! {reconcile_user_presence, UserId},
presence_repair;
forward_msg({reconcile_user_presence, _UserId} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
presence_repair;
forward_msg({clear_stale_cached_voice_states, _Ids} = Msg, NewPid, _Phase) ->
NewPid ! Msg,
voice_cleanup.
-spec barrier(pid(), pos_integer()) -> ok | {error, term()}.
barrier(NewPid, Timeout) ->
case safe_call(NewPid, {get_guild_id}, Timeout) of
Id when is_integer(Id) -> ok;
Other -> {error, Other}
end.
-spec capture(pid(), non_neg_integer(), opts()) ->
{ok, non_neg_integer(), [term()]} | {error, term()}.
capture(SrcPid, Seen, Opts) ->
Args = [parse(?CAPTURE_EXPRS), bindings([{'P', SrcPid}, {'S', Seen}])],
case rpc_call(node(SrcPid), erl_eval, exprs, Args, maps:get(rpc_timeout, Opts)) of
{ok, {value, {Len, New}, _}} when is_integer(Len), is_list(New) -> {ok, Len, New};
{ok, {value, undefined, _}} -> {error, noproc};
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec stop_source(map()) -> ok | {error, term()}.
stop_source(#{guild_id := GuildId, src_shard := Shard, opts := Opts}) ->
Request = {stop_guild, GuildId, {shutdown, handoff}},
case safe_call(Shard, Request, maps:get(stop_timeout, Opts)) of
ok -> ok;
Other -> {error, Other}
end.
-spec source_alive(pid(), opts()) -> boolean() | {error, term()}.
source_alive(SrcPid, Opts) ->
Args = [SrcPid],
case rpc_call(node(SrcPid), erlang, is_process_alive, Args, maps:get(rpc_timeout, Opts)) of
{ok, Alive} when is_boolean(Alive) -> Alive;
{ok, Other} -> {error, {unexpected, Other}};
{error, _} = Error -> Error
end.
-spec within_budget(map()) -> boolean().
within_budget(#{frozen_at := FrozenAt, opts := Opts}) ->
now_ms() - FrozenAt =< maps:get(freeze_budget, Opts).
-spec indexed_shard_pid(node(), non_neg_integer()) -> {ok, pid()} | {error, term()}.
indexed_shard_pid(Node, Index) ->
case rpc_call(Node, ets, lookup, [?SHARD_TABLE, {shard_pid, Index}], 10000) of
{ok, [{{shard_pid, Index}, Pid}]} when is_pid(Pid) -> {ok, Pid};
{ok, _} -> {error, shard_unavailable};
{error, _} = Error -> Error
end.
-spec safe_hook(hook()) -> ok | {error, term()}.
safe_hook(Hook) ->
try Hook() of
ok -> ok;
{error, _} = Error -> Error
catch
Class:Reason -> {error, {hook_crashed, Class, Reason}}
end.
-spec safe_started_hook(started_hook(), pid()) -> ok | {error, term()}.
safe_started_hook(Hook, NewPid) ->
try Hook(NewPid) of
ok -> ok;
{error, _} = Error -> Error
catch
Class:Reason -> {error, {hook_crashed, Class, Reason}}
end.
-spec safe_call(pid(), term(), pos_integer()) -> term().
safe_call(Server, Request, Timeout) ->
try
gen_server:call(Server, Request, Timeout)
catch
exit:{timeout, _} -> {error, timeout};
exit:Reason -> {error, {exit, Reason}}
end.
-spec rpc_call(node(), module(), atom(), [term()], pos_integer()) ->
{ok, term()} | {error, term()}.
rpc_call(Node, Module, Function, Args, Timeout) ->
try erpc:call(Node, Module, Function, Args, Timeout) of
Result -> {ok, Result}
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec parse(string()) -> [erl_parse:abstract_expr()].
parse(Source) ->
{ok, Tokens, _} = erl_scan:string(Source),
{ok, Exprs} = erl_parse:parse_exprs(Tokens),
Exprs.
-spec bindings([{atom(), term()}]) -> erl_eval:binding_struct().
bindings(Pairs) ->
lists:foldl(
fun({Name, Value}, Acc) -> erl_eval:add_binding(Name, Value, Acc) end,
erl_eval:new_bindings(),
Pairs
).
-spec maybe_limit_heap(non_neg_integer()) -> ok.
maybe_limit_heap(0) ->
ok;
maybe_limit_heap(Words) ->
_ = process_flag(max_heap_size, #{size => Words, kill => true, error_logger => true}),
ok.
-spec put_ms(atom(), integer(), map()) -> map().
put_ms(Key, T0, Ctx) ->
put_report(Key, now_ms() - T0, Ctx).
-spec put_report(atom(), term(), map()) -> map().
put_report(Key, Value, #{report := Report} = Ctx) ->
Ctx#{report => Report#{Key => Value}}.
-spec now_ms() -> integer().
now_ms() ->
erlang:monotonic_time(millisecond).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
capture_returns_only_new_forwardable_messages_test() ->
Pid = spawn(fun() ->
receive
stop -> ok
end
end),
Msgs = [
{'$gen_cast', a},
{'DOWN', make_ref(), process, self(), normal},
{'$gen_call', {self(), make_ref()}, b},
{presence, not_a_user, x},
{presence, 7, x},
{reconcile_user_presence, 7},
{clear_stale_cached_voice_states, []}
],
[Pid ! Msg || Msg <- Msgs],
Opts = default_opts(),
try
?assertMatch({ok, 5, [_, _, _, _, _]}, capture(Pid, 0, Opts)),
?assertEqual(
{ok, 5, [
{presence, 7, x},
{reconcile_user_presence, 7},
{clear_stale_cached_voice_states, []}
]},
capture(Pid, 2, Opts)
),
?assertEqual({ok, 5, []}, capture(Pid, 5, Opts)),
?assertEqual({ok, 5, []}, capture(Pid, 9, Opts))
after
Pid ! stop
end,
ok = wait_exit(Pid),
?assertEqual({error, noproc}, capture(Pid, 0, Opts)).
repair_applies_the_reload_before_dispatches_queued_during_the_fetch_test() ->
Test = self(),
meck:new(guild_repair_stub, [non_strict]),
meck:expect(guild_repair_stub, init, fun(State) -> {ok, State} end),
meck:expect(guild_repair_stub, handle_cast, fun({set, Value}, _State) ->
{noreply, Value}
end),
meck:new(guild_manager_shard_fetch, [passthrough]),
meck:expect(guild_manager_shard_fetch, fetch_guild_data, fun(42) ->
Test ! {fetching, self()},
receive
go -> {ok, fetched}
end
end),
meck:new(guild, [passthrough]),
meck:expect(guild, handle_call, fun({reload, Data}, _From, _State) -> {reply, ok, Data} end),
{ok, Pid} = gen_server:start(guild_repair_stub, before, []),
try
spawn(fun() -> Test ! {repaired, repair(42, Pid, #{})} end),
receive
{fetching, Fetcher} ->
?assert(is_frozen(Pid)),
gen_server:cast(Pid, {set, dispatched}),
Fetcher ! go
after 5000 -> error(no_fetch)
end,
?assertEqual(
ok,
receive
{repaired, Result} -> Result
after 5000 -> timeout
end
),
?assertEqual(dispatched, sys:get_state(Pid))
after
exit(Pid, kill),
meck:unload([guild, guild_manager_shard_fetch, guild_repair_stub])
end.
wait_exit(Pid) ->
Ref = erlang:monitor(process, Pid),
receive
{'DOWN', Ref, process, Pid, _} -> ok
after 5000 -> timeout
end.
-endif.
+111 -34
View File
@@ -6,7 +6,7 @@
-export([
start_link/0,
is_degraded/1,
is_overloaded/1,
register_guild/1,
put_session/2,
remove_session/2,
@@ -19,19 +19,23 @@
-define(DEGRADED_MS, 2000).
-define(RECOVERED_MS, 500).
-define(REMOTE_LOOKUP_TIMEOUT_MS, 250).
-define(PROBE_TIMEOUT_MS, 5000).
-define(STALE_PROBE_MS, (?PROBE_TIMEOUT_MS + 4 * ?INTERVAL_MS)).
-spec start_link() -> gen_server:start_ret().
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec is_degraded(pid()) -> boolean().
is_degraded(Pid) when node(Pid) =:= node() ->
-spec is_overloaded(pid()) -> boolean().
is_overloaded(Pid) when node(Pid) =:= node() ->
case lookup(Pid) of
{Pid, _GuildId, Degraded, _Targets, _Pending} -> Degraded;
undefined -> false
{Pid, _GuildId, _Degraded, _Targets, Pending} ->
overloaded(Pending, erlang:monotonic_time(millisecond));
undefined ->
false
end;
is_degraded(Pid) ->
try erpc:call(node(Pid), ?MODULE, is_degraded, [Pid], ?REMOTE_LOOKUP_TIMEOUT_MS) of
is_overloaded(Pid) ->
try erpc:call(node(Pid), ?MODULE, is_overloaded, [Pid], ?REMOTE_LOOKUP_TIMEOUT_MS) of
true -> true;
_ -> false
catch
@@ -119,14 +123,8 @@ handle_call(_, _, State) ->
handle_cast({register, Pid}, State) when is_pid(Pid), node(Pid) =:= node() ->
{noreply, track_guild(Pid, State)};
handle_cast({pong, Pid, Ref, HandledAt}, State) when is_pid(Pid), is_integer(HandledAt) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, {Ref, SentAt}} ->
NewDegraded = next_degraded(Degraded, max(0, HandledAt - SentAt)),
update({Pid, GuildId, Degraded, Targets, undefined}, NewDegraded),
{noreply, State};
_ ->
{noreply, State}
end;
ok = pong(Pid, Ref, HandledAt, erlang:monotonic_time(millisecond)),
{noreply, State};
handle_cast({current, Pid, SessionPid}, State) when is_pid(Pid), is_pid(SessionPid) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, _Targets, _Pending} ->
@@ -186,20 +184,49 @@ check_guild(Pid, Now) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, undefined} ->
case should_probe(Pid, Degraded) of
true ->
Ref = make_ref(),
ets:insert(?TABLE, {Pid, GuildId, Degraded, Targets, {Ref, Now}}),
Pid ! {guild_health_probe, Ref},
ok;
false ->
ok
true -> update({Pid, GuildId, Degraded, Targets, probe(Pid, Now, 0)}, Degraded);
false -> ok
end;
{Pid, _, Degraded, _, {_Ref, SentAt}} = Entry ->
{Pid, GuildId, Degraded, Targets, {_Ref, SentAt, Lag}} when
Now - SentAt >= ?PROBE_TIMEOUT_MS
->
Pending = probe(Pid, Now, max(Lag, Now - SentAt)),
update({Pid, GuildId, Degraded, Targets, Pending}, true);
{Pid, _, Degraded, _, {_Ref, SentAt, _Lag}} = Entry ->
update(Entry, Degraded orelse Now - SentAt > ?DEGRADED_MS);
_ ->
ok
end.
-spec pong(pid(), reference(), integer(), integer()) -> ok.
pong(Pid, Ref, HandledAt, Now) ->
case lookup(Pid) of
{Pid, GuildId, Degraded, Targets, {Ref, SentAt, _Lag}} ->
Delay = max(0, HandledAt - SentAt),
NewDegraded = next_degraded(Degraded, Delay),
Pending = next_pending(Pid, NewDegraded, Delay, Now),
update({Pid, GuildId, Degraded, Targets, Pending}, NewDegraded);
_ ->
ok
end.
-spec next_pending(pid(), boolean(), non_neg_integer(), integer()) ->
{reference(), integer(), non_neg_integer()} | undefined.
next_pending(Pid, true, Delay, Now) -> probe(Pid, Now, Delay);
next_pending(_Pid, false, _Delay, _Now) -> undefined.
-spec probe(pid(), integer(), non_neg_integer()) -> {reference(), integer(), non_neg_integer()}.
probe(Pid, Now, Lag) ->
Ref = make_ref(),
Pid ! {guild_health_probe, Ref},
{Ref, Now, Lag}.
-spec overloaded(term(), integer()) -> boolean().
overloaded({_Ref, SentAt, Lag}, Now) when Now - SentAt =< ?STALE_PROBE_MS ->
max(Lag, Now - SentAt) > ?DEGRADED_MS;
overloaded(_Pending, _Now) ->
false.
-spec should_probe(pid(), boolean()) -> boolean().
should_probe(_Pid, true) ->
true;
@@ -273,7 +300,7 @@ hysteresis_test() ->
unreachable_remote_pid_is_unknown_test() ->
Pid = binary_to_term(<<131, 88, 119, 12, "fake@nowhere", 1:32, 0:32, 1:32>>),
?assertNotEqual(node(), node(Pid)),
{ElapsedUs, Result} = timer:tc(?MODULE, is_degraded, [Pid]),
{ElapsedUs, Result} = timer:tc(?MODULE, is_overloaded, [Pid]),
?assertNot(Result),
?assert(ElapsedUs < 1000000).
@@ -291,23 +318,22 @@ pending_probe_is_bounded_and_recovers_only_after_fresh_reply_test() ->
try
Pid ! queued_work,
check_guild(Pid, 100),
{Pid, 42, false, Targets, {Ref, 100}} = lookup(Pid),
{Pid, 42, false, Targets, {Ref, 100, 0}} = lookup(Pid),
check_guild(Pid, 2201),
?assert(is_degraded(Pid)),
{Pid, 42, true, Targets, {Ref, 100, 0}} = lookup(Pid),
receive
{'$gen_cast', {guild_health, 42, Pid, true}} -> ok
after 100 -> ?assert(false)
end,
check_guild(Pid, 5000),
?assertEqual({message_queue_len, 2}, process_info(Pid, message_queue_len)),
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5000}, #{}),
?assert(is_degraded(Pid)),
check_guild(Pid, 5100),
{Pid, 42, true, Targets, {Ref2, 5100}} = lookup(Pid),
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5110}, #{}),
?assert(is_degraded(Pid)),
{noreply, #{}} = handle_cast({pong, Pid, Ref2, 5110}, #{}),
?assertNot(is_degraded(Pid)),
ok = pong(Pid, Ref, 5000, 5000),
{Pid, 42, true, Targets, {Ref2, 5000, 4900}} = lookup(Pid),
?assertEqual({message_queue_len, 3}, process_info(Pid, message_queue_len)),
ok = pong(Pid, Ref, 5110, 5110),
{Pid, 42, true, Targets, {Ref2, 5000, 4900}} = lookup(Pid),
ok = pong(Pid, Ref2, 5110, 5110),
?assertEqual({Pid, 42, false, Targets, undefined}, lookup(Pid)),
receive
{'$gen_cast', {guild_health, 42, Pid, false}} -> ok
after 100 -> ?assert(false)
@@ -318,6 +344,57 @@ pending_probe_is_bounded_and_recovers_only_after_fresh_reply_test() ->
ets:delete(Targets)
end.
lost_pong_is_reprobed_and_the_guild_recovers_test() ->
ok = guild_ets_owner:ensure_table(?TABLE, [named_table, public, set]),
Targets = ets:new(health_lost_pong_targets, [set]),
ets:insert(Targets, {<<"session">>, self()}),
Pid = spawn(fun() ->
receive
stop -> ok
end
end),
ets:insert(?TABLE, {Pid, 44, false, Targets, undefined}),
try
Pid ! queued_work,
check_guild(Pid, 0),
{Pid, 44, false, Targets, {Lost, 0, 0}} = lookup(Pid),
check_guild(Pid, 2001),
receive
{'$gen_cast', {guild_health, 44, Pid, true}} -> ok
after 100 -> ?assert(false)
end,
check_guild(Pid, 4999),
?assertEqual({message_queue_len, 2}, process_info(Pid, message_queue_len)),
check_guild(Pid, 5000),
{Pid, 44, true, Targets, {Fresh, 5000, 5000} = Pending} = lookup(Pid),
?assertNotEqual(Lost, Fresh),
?assertEqual({message_queue_len, 3}, process_info(Pid, message_queue_len)),
?assert(overloaded(Pending, 5001)),
ok = pong(Pid, Fresh, 5020, 5020),
?assertEqual({Pid, 44, false, Targets, undefined}, lookup(Pid)),
?assertNot(is_overloaded(Pid)),
receive
{'$gen_cast', {guild_health, 44, Pid, false}} -> ok
after 100 -> ?assert(false)
end,
ok = pong(Pid, Lost, 5030, 5030),
?assertEqual({Pid, 44, false, Targets, undefined}, lookup(Pid))
after
Pid ! stop,
ets:delete(?TABLE, Pid),
ets:delete(Targets)
end.
shedding_needs_fresh_evidence_of_lag_test() ->
Ref = make_ref(),
?assertNot(overloaded(undefined, 10000)),
?assertNot(overloaded({Ref, 10000, 0}, 12000)),
?assert(overloaded({Ref, 10000, 0}, 12001)),
?assertNot(overloaded({Ref, 10000, 1500}, 10100)),
?assert(overloaded({Ref, 10000, 2500}, 10100)),
?assert(overloaded({Ref, 10000, 5000}, 10000 + ?STALE_PROBE_MS)),
?assertNot(overloaded({Ref, 10000, 5000}, 10001 + ?STALE_PROBE_MS)).
session_targets_follow_replacement_and_removal_test() ->
Tab = ets:new(health_test_targets, [set]),
State = #{guild_health_sessions => Tab, sessions => #{<<"s">> => #{pid => self()}}},
@@ -0,0 +1,168 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_mailbox_age).
-typing([eqwalizer]).
-export([note/0, handle_mark/1, min_age_ms/0]).
-define(KEY, {?MODULE, marks}).
-define(MARK_INTERVAL_MS, 50).
-type seq() :: non_neg_integer().
-type marks() :: {seq(), integer() | undefined, queue:queue({seq(), integer()})}.
-spec note() -> ok.
note() ->
Now = erlang:monotonic_time(millisecond),
{Seq, Checked, Pending} = marks(),
case is_integer(Checked) andalso Now - Checked < ?MARK_INTERVAL_MS of
true -> ok;
false -> maybe_mark(Now, Seq, Pending)
end.
-spec maybe_mark(integer(), seq(), queue:queue({seq(), integer()})) -> ok.
maybe_mark(Now, Seq, Pending) ->
case erlang:process_info(self(), message_queue_len) of
{message_queue_len, Len} when Len > 0 ->
self() ! {?MODULE, Seq},
SentBy = erlang:monotonic_time(millisecond) + 1,
put_marks({Seq + 1, Now, queue:in({Seq, SentBy}, Pending)});
_ ->
put_marks({Seq, Now, Pending})
end.
-spec handle_mark(seq()) -> ok.
handle_mark(Seq) ->
{Next, Checked, Pending} = marks(),
put_marks({Next, Checked, drop_through(Seq, Pending)}).
-spec min_age_ms() -> non_neg_integer() | undefined.
min_age_ms() ->
{_, _, Pending} = marks(),
case queue:peek(Pending) of
{value, {_, SentBy}} when is_integer(SentBy) ->
max(0, erlang:monotonic_time(millisecond) - SentBy);
_ ->
undefined
end.
-spec drop_through(seq(), queue:queue({seq(), integer()})) -> queue:queue({seq(), integer()}).
drop_through(Seq, Pending) ->
case queue:peek(Pending) of
{value, {S, _}} when S =< Seq -> drop_through(Seq, queue:drop(Pending));
_ -> Pending
end.
-spec marks() -> marks().
marks() ->
case erlang:get(?KEY) of
{Seq, _, _} = Marks when is_integer(Seq) ->
Marks;
_ ->
{0, undefined, queue:new()}
end.
-spec put_marks(marks()) -> ok.
put_marks(Marks) ->
_ = erlang:put(?KEY, Marks),
ok.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
in_fresh_process(Fun) ->
Self = self(),
Ref = make_ref(),
{Pid, MRef} = spawn_monitor(fun() -> Self ! {Ref, Fun()} end),
receive
{Ref, Result} ->
erlang:demonitor(MRef, [flush]),
Result;
{'DOWN', MRef, process, Pid, Reason} ->
erlang:error(Reason)
after 5000 ->
erlang:error(timeout)
end.
next_mark() ->
receive
{?MODULE, Seq} -> Seq
after 0 -> none
end.
no_age_without_a_backlog_test() ->
?assertEqual(
{undefined, none},
in_fresh_process(fun() ->
ok = note(),
{min_age_ms(), next_mark()}
end)
).
a_message_queued_before_a_mark_is_at_least_as_old_as_the_mark_test() ->
{Age, Seq} = in_fresh_process(fun() ->
self() ! queued_request,
ok = note(),
timer:sleep(120),
receive
queued_request -> ok
end,
{min_age_ms(), next_mark()}
end),
?assertEqual(0, Seq),
?assert(Age >= 119),
?assert(Age =< 1000).
handled_marks_stop_bounding_later_messages_test() ->
?assertEqual(
undefined,
in_fresh_process(fun() ->
self() ! queued_request,
ok = note(),
receive
queued_request -> ok
end,
ok = handle_mark(next_mark()),
min_age_ms()
end)
).
marks_are_rate_limited_while_busy_test() ->
Seqs = in_fresh_process(fun() ->
self() ! queued_request,
ok = note(),
ok = note(),
timer:sleep(60),
ok = note(),
[next_mark(), next_mark(), next_mark()]
end),
?assertEqual([0, 1, none], Seqs).
the_oldest_unhandled_mark_bounds_the_age_test() ->
{Age, Seqs} = in_fresh_process(fun() ->
self() ! queued_request,
ok = note(),
timer:sleep(200),
ok = note(),
receive
queued_request -> ok
end,
First = next_mark(),
Second = next_mark(),
{min_age_ms(), [First, Second]}
end),
?assertEqual([0, 1], Seqs),
?assert(Age >= 199).
a_forged_mark_only_loosens_the_bound_test() ->
?assertEqual(
undefined,
in_fresh_process(fun() ->
self() ! queued_request,
ok = note(),
ok = handle_mark(1000),
min_age_ms()
end)
).
-endif.
@@ -151,12 +151,9 @@ continue_handoff_to_target(TargetNode, GuildIds, State, Iteration, AccResult) ->
continue_handoff_to_topology(_TargetNodes, [], State, _Iteration, AccResult) ->
{AccResult, State};
continue_handoff_to_topology(TargetNodes, GuildIds, State, Iteration, AccResult) ->
Resolver = fun(GId) ->
gateway_node_router:select_owner_node(GId, TargetNodes)
end,
run_and_continue(
GuildIds,
Resolver,
topology_owner_resolver(),
AccResult,
State,
fun(S1, I, M, B) ->
@@ -165,6 +162,12 @@ continue_handoff_to_topology(TargetNodes, GuildIds, State, Iteration, AccResult)
Iteration
).
-spec topology_owner_resolver() -> fun((guild_id()) -> term()).
topology_owner_resolver() ->
fun(GId) ->
gateway_node_router:owner_node_result(GId, guilds)
end.
-spec run_and_continue(
[guild_id()],
fun((guild_id()) -> term()),
@@ -291,3 +294,25 @@ mark_handoff_attempt(#{attempted := Attempted, handed_off := HandedOff}) ->
-spec mark_handoff_success(handoff_result()) -> handoff_result().
mark_handoff_success(#{attempted := Attempted, handed_off := HandedOff}) ->
#{attempted => Attempted + 1, handed_off => HandedOff + 1}.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
topology_handoff_keeps_a_guild_pinned_to_this_node_test() ->
GuildId = 1100000000000000001,
Peers = ['guilds_a@h', 'guilds_b@h'],
Resolver = topology_owner_resolver(),
persistent_term:put({gateway_cluster_membership, members}, [node() | Peers]),
persistent_term:put({gateway_cluster_membership, members_by_role}, #{guilds => Peers}),
try
?assertMatch({handoff, _}, resolve_handoff_target(GuildId, node(), Resolver)),
application:set_env(fluxer_gateway, guild_owner_pins, #{GuildId => node()}),
?assertEqual(skip, resolve_handoff_target(GuildId, node(), Resolver)),
?assertMatch({handoff, _}, resolve_handoff_target(GuildId + 1, node(), Resolver))
after
application:unset_env(fluxer_gateway, guild_owner_pins),
persistent_term:erase({gateway_cluster_membership, members}),
persistent_term:erase({gateway_cluster_membership, members_by_role})
end.
-endif.
@@ -13,103 +13,34 @@
-export_type([guild_id/0, state/0]).
-define(SHARD_TABLE, guild_manager_shard_table).
-spec handoff_guild_to_owner(guild_id(), node(), state()) -> {boolean(), state()}.
handoff_guild_to_owner(GuildId, TargetNode, State) ->
case export_local_guild_state(GuildId, State) of
{ok, TransferState} ->
start_transferred_guild_on_owner(
GuildId, TargetNode, TransferState, State
);
{error, _Reason} ->
{false, State}
end.
-spec start_transferred_guild_on_owner(guild_id(), node(), map(), state()) ->
{boolean(), state()}.
start_transferred_guild_on_owner(GuildId, TargetNode, TransferState, State) ->
Request = {start_transferred, GuildId, TransferState},
case call_target_shard(GuildId, TargetNode, Request) of
{ok, Pid} when is_pid(Pid) -> stop_local_guild_after_handoff(GuildId, State);
_Other -> {false, State}
end.
-spec call_target_shard(guild_id(), node(), term()) -> term().
call_target_shard(GuildId, TargetNode, Request) ->
case target_shard_pid(GuildId, TargetNode) of
{ok, Pid} ->
shard_utils:safe_gen_call_remote(Pid, Request, ?DEFAULT_GEN_SERVER_TIMEOUT);
{error, _Reason} = Error ->
Error
end.
-spec target_shard_pid(guild_id(), node()) -> {ok, pid()} | {error, term()}.
target_shard_pid(GuildId, TargetNode) ->
case target_shard_count(TargetNode) of
{ok, Count} -> lookup_target_shard_pid(GuildId, TargetNode, Count);
{error, _Reason} = Error -> Error
end.
-spec target_shard_count(node()) -> {ok, pos_integer()} | {error, term()}.
target_shard_count(TargetNode) ->
Request = [?SHARD_TABLE, shard_count],
case rpc:call(TargetNode, ets, lookup, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
[{shard_count, Count}] when is_integer(Count), Count > 0 -> {ok, Count};
{badrpc, Reason} -> {error, {badrpc, Reason}};
_Other -> {error, shard_table_unavailable}
end.
-spec lookup_target_shard_pid(guild_id(), node(), pos_integer()) ->
{ok, pid()} | {error, term()}.
lookup_target_shard_pid(GuildId, TargetNode, Count) ->
Index = guild_manager_shards:select_shard(GuildId, Count),
Request = [?SHARD_TABLE, {shard_pid, Index}],
case rpc:call(TargetNode, ets, lookup, Request, ?DEFAULT_GEN_SERVER_TIMEOUT) of
[{{shard_pid, Index}, Pid}] when is_pid(Pid) -> {ok, Pid};
{badrpc, Reason} -> {error, {badrpc, Reason}};
_Other -> {error, shard_unavailable}
end.
-spec export_local_guild_state(guild_id(), state()) -> {ok, map()} | {error, term()}.
export_local_guild_state(GuildId, State) ->
case local_guild_pid(GuildId, State) of
{ok, Pid} -> export_guild_state(Pid);
{error, _Reason} = Error -> Error
end.
-spec local_guild_pid(guild_id(), state()) -> {ok, pid()} | {error, term()}.
local_guild_pid(GuildId, State) ->
{Index, State1} = guild_manager_shards:ensure_shard(GuildId, State),
Shards = maps:get(shards, State1),
#{pid := Pid} = maps:get(Index, Shards),
#{pid := Shard} = maps:get(Index, maps:get(shards, State1)),
case local_guild_pid(Shard, GuildId) of
{ok, GuildPid} ->
Result = guild_handoff_freeze:transfer(GuildId, GuildPid, Shard, TargetNode, #{}),
{handed_off(GuildId, TargetNode, Result), State1};
{error, _Reason} ->
{false, State1}
end.
-spec local_guild_pid(pid(), guild_id()) -> {ok, pid()} | {error, term()}.
local_guild_pid(Shard, GuildId) ->
case
shard_utils:safe_gen_call_remote(Pid, {lookup, GuildId}, ?DEFAULT_GEN_SERVER_TIMEOUT)
shard_utils:safe_gen_call_remote(Shard, {lookup, GuildId}, ?DEFAULT_GEN_SERVER_TIMEOUT)
of
{ok, GuildPid} when is_pid(GuildPid) -> {ok, GuildPid};
{error, Reason} -> {error, Reason};
Other -> {error, {unexpected_lookup_reply, Other}}
end.
-spec stop_local_guild_after_handoff(guild_id(), state()) -> {boolean(), state()}.
stop_local_guild_after_handoff(GuildId, State) ->
{Index, State1} = guild_manager_shards:ensure_shard(GuildId, State),
Shards = maps:get(shards, State1),
#{pid := Pid} = maps:get(Index, Shards),
Reply = shard_utils:safe_gen_call_remote(
Pid, {stop_guild, GuildId, {shutdown, handoff}}, ?DEFAULT_GEN_SERVER_TIMEOUT
-spec handed_off(guild_id(), node(), guild_handoff_freeze:result()) -> boolean().
handed_off(_GuildId, _TargetNode, {ok, _Report}) ->
true;
handed_off(GuildId, TargetNode, {error, Report}) ->
logger:warning(
"guild_handoff_failed: guild_id=~p target=~p report=~p",
[GuildId, TargetNode, Report]
),
case Reply of
ok -> {true, State1};
_Other -> {false, State1}
end.
-spec export_guild_state(pid()) -> {ok, map()} | {error, term()}.
export_guild_state(Pid) ->
case
shard_utils:safe_gen_call_remote(Pid, export_handoff_state, ?DEFAULT_GEN_SERVER_TIMEOUT)
of
{ok, TransferState} when is_map(TransferState) -> {ok, TransferState};
{error, Reason} -> {error, Reason};
Other -> {error, {unexpected_export_reply, Other}}
end.
false.
@@ -230,7 +230,7 @@ build(ListId, State) ->
undefined ->
State;
ChannelId ->
Ref = load_engine(ChannelId, State),
Ref = load_or_clone_engine(ListId, ChannelId, State),
guild_member_list_engine_inputs:record(
ListId,
ChannelId,
@@ -239,6 +239,14 @@ build(ListId, State) ->
)
end.
-spec load_or_clone_engine(list_id(), pos_integer(), guild_state()) -> engine_ref().
load_or_clone_engine(ListId, ChannelId, State) ->
Engines = maps:remove(ListId, engines(State)),
case guild_member_list_engine_inputs:current_twin(ChannelId, maps:keys(Engines), State) of
{ok, TwinListId} -> guild_member_list_engine:clone(maps:get(TwinListId, Engines));
none -> load_engine(ChannelId, State)
end.
-spec load_engine(pos_integer(), guild_state()) -> engine_ref().
load_engine(ChannelId, State) ->
Ref = guild_member_list_engine:new(),
@@ -5,6 +5,7 @@
-export([
new/0,
clone/1,
destroy/1,
bulk_load/3,
add_member/5,
@@ -49,6 +50,17 @@ new() ->
]),
Ref.
-spec clone(ets:table()) -> ets:table().
clone(Source) ->
{SourceOSet, SourceITab} = lookup_tabs(Source),
Ref = new(),
{OSet, ITab} = lookup_tabs(Ref),
true = ets:insert(ITab, ets:tab2list(SourceITab)),
ok = guild_member_list_oset:from_sorted(OSet, guild_member_list_oset:to_list(SourceOSet)),
true = ets:insert(Ref, [Row || Row <- ets:tab2list(Source), element(1, Row) =/= tabs]),
ok = bump_version(Ref),
Ref.
-spec destroy(term()) -> ok.
destroy(Ref) ->
try ets:lookup(eqwalizer:dynamic_cast(Ref), tabs) of
@@ -5,6 +5,7 @@
-export([
is_stale/2,
current_twin/3,
record/4,
forget/2,
forget_all/1,
@@ -30,6 +31,32 @@ is_stale(ListId, State) ->
true
end.
-spec current_twin(channel_id(), [list_id()], guild_state()) -> {ok, list_id()} | none.
current_twin(ChannelId, ListIds, State) ->
AccessByChannel = virtual_access_by_channel(State),
find_current_twin(
inputs(ChannelId, State, AccessByChannel),
ListIds,
recorded(State),
AccessByChannel,
State
).
-spec find_current_twin(inputs(), [list_id()], map(), #{term() => [term()]}, guild_state()) ->
{ok, list_id()} | none.
find_current_twin(_Inputs, [], _Recorded, _AccessByChannel, _State) ->
none;
find_current_twin(Inputs, [ListId | Rest], Recorded, AccessByChannel, State) ->
case {maps:find(ListId, Recorded), channel_id(ListId)} of
{{ok, Inputs}, TwinChannelId} when is_integer(TwinChannelId) ->
case inputs(TwinChannelId, State, AccessByChannel) of
Inputs -> {ok, ListId};
_ -> find_current_twin(Inputs, Rest, Recorded, AccessByChannel, State)
end;
_ ->
find_current_twin(Inputs, Rest, Recorded, AccessByChannel, State)
end.
-spec record(list_id(), channel_id(), guild_state(), guild_state()) -> guild_state().
record(ListId, ChannelId, BuiltFrom, State) ->
put_recorded(maps:put(ListId, inputs(ChannelId, BuiltFrom), recorded(State)), State).
@@ -6,6 +6,7 @@
-export([
broadcast_member_list_updates/3,
broadcast_member_list_updates/5,
queue_member_list_updates/5,
broadcast_all_member_list_updates/1,
broadcast_member_list_updates_for_channel/2,
broadcast_channel_engine_connection_change/2,
@@ -25,6 +26,7 @@
-type engine_ref() :: ets:table().
-type absence() :: {absent, engine_ref()} | present.
-type pending_mark() :: true | synced.
-type list_sync() :: immediate | deferred.
-define(MAX_MEMBER_LIST_SYNC_SKIPPED_ABSENT, 1000000000).
@@ -53,11 +55,34 @@ broadcast_member_list_updates(
) ->
{ok, UpdatedState};
broadcast_member_list_updates(UserId, OldState, UpdatedState, OldPresence, NewPresence) ->
member_list_updates(immediate, UserId, OldState, UpdatedState, OldPresence, NewPresence).
-spec queue_member_list_updates(
user_id() | undefined,
guild_state(),
guild_state(),
map() | undefined,
map() | undefined
) -> {ok, guild_state()}.
queue_member_list_updates(undefined, _OldState, UpdatedState, _OldPresence, _NewPresence) ->
{ok, UpdatedState};
queue_member_list_updates(UserId, OldState, UpdatedState, OldPresence, NewPresence) ->
member_list_updates(deferred, UserId, OldState, UpdatedState, OldPresence, NewPresence).
-spec member_list_updates(
list_sync(),
user_id(),
guild_state(),
guild_state(),
map() | undefined,
map() | undefined
) -> {ok, guild_state()}.
member_list_updates(ListSync, UserId, OldState, UpdatedState, OldPresence, NewPresence) ->
guild_member_list_write_context:with_guild_id(UpdatedState, fun(_GuildId) ->
OldMember = find_member_in_state_data(UserId, OldState),
NewMember = find_member_in_state_data(UserId, UpdatedState),
State1 = dispatch_presence_delta(
UserId, OldMember, NewMember, OldPresence, NewPresence, UpdatedState
UserId, OldMember, NewMember, OldPresence, NewPresence, ListSync, UpdatedState
),
{ok, State1}
end).
@@ -68,16 +93,19 @@ broadcast_member_list_updates(UserId, OldState, UpdatedState, OldPresence, NewPr
map() | undefined,
map() | undefined,
map() | undefined,
list_sync(),
guild_state()
) -> guild_state().
dispatch_presence_delta(UserId, OldMember, NewMember, OldPresence, NewPresence, State) ->
dispatch_presence_delta(
UserId, OldMember, NewMember, OldPresence, NewPresence, ListSync, State
) ->
case presence_delta_is_inert(OldPresence, NewPresence, OldMember, NewMember) of
true ->
invalidate_synced_lists(UserId, State);
false ->
SubsTab = maps:get(member_list_subscriptions, State),
dispatch_user_change_to_subscribed_lists(
UserId, OldMember, NewMember, SubsTab, State
UserId, OldMember, NewMember, SubsTab, ListSync, State
)
end.
@@ -216,16 +244,17 @@ fold_connection_change_lists(GuildId, UserId, Mark, State, SubsTab) ->
map() | undefined,
map() | undefined,
ets:table(),
list_sync(),
guild_state()
) -> guild_state().
dispatch_user_change_to_subscribed_lists(
UserId, OldMember, NewMember, SubsTab, State
UserId, OldMember, NewMember, SubsTab, ListSync, State
) ->
guild_member_list_read:with_member_item_memo(fun() ->
lists:foldl(
fun(ListId, AccState) ->
dispatch_user_change_to_subscribed_list(
UserId, OldMember, NewMember, ListId, AccState
UserId, OldMember, NewMember, ListId, ListSync, AccState
)
end,
State,
@@ -238,16 +267,23 @@ dispatch_user_change_to_subscribed_lists(
map() | undefined,
map() | undefined,
list_id(),
list_sync(),
guild_state()
) -> guild_state().
dispatch_user_change_to_subscribed_list(UserId, OldMember, NewMember, ListId, State) ->
dispatch_user_change_to_subscribed_list(UserId, OldMember, NewMember, ListId, ListSync, State) ->
Absence = user_change_absence(UserId, ListId, OldMember, NewMember, State),
State1 = apply_user_change_to_channel_store(UserId, ListId, OldMember, NewMember, State),
case sync_body_unchanged(UserId, ListId, Absence, State1) of
true -> record_member_list_sync_skipped_absent(State1);
false -> guild_member_list_sync_batch:queue_list_sync(ListId, State1)
false -> queue_user_change_sync(ListSync, ListId, State1)
end.
-spec queue_user_change_sync(list_sync(), list_id(), guild_state()) -> guild_state().
queue_user_change_sync(immediate, ListId, State) ->
guild_member_list_sync_batch:queue_list_sync(ListId, State);
queue_user_change_sync(deferred, ListId, State) ->
queue_connection_list_sync(ListId, true, State).
-spec user_change_absence(
user_id(), list_id(), map() | undefined, map() | undefined, guild_state()
) -> absence().
@@ -551,14 +587,18 @@ subscribed_list_sync_is_skipped_only_for_absent_member_test() ->
?assert(
maps:is_key(
pending_member_list_sync_batch,
dispatch_user_change_to_subscribed_list(7, Member, Member, <<"500">>, State)
dispatch_user_change_to_subscribed_list(
7, Member, Member, <<"500">>, immediate, State
)
)
),
ok = guild_member_list_engine:add_member(Ref, 7, <<"seven">>, [], true),
?assertNot(
maps:is_key(
pending_member_list_sync_batch,
dispatch_user_change_to_subscribed_list(7, Member, Member, <<"500">>, State)
dispatch_user_change_to_subscribed_list(
7, Member, Member, <<"500">>, immediate, State
)
)
)
end)
@@ -695,7 +735,9 @@ member_update_sync_stays_immediate_test() ->
?assertNot(
maps:is_key(
pending_member_list_sync_batch,
dispatch_user_change_to_subscribed_list(7, Member, Member, <<"500">>, State)
dispatch_user_change_to_subscribed_list(
7, Member, Member, <<"500">>, immediate, State
)
)
)
after
@@ -703,6 +745,22 @@ member_update_sync_stays_immediate_test() ->
guild_member_list_engine:destroy(Ref)
end.
deferred_user_change_sync_is_debounced_test() ->
Ref = guild_member_list_engine:new(),
Old = #{<<"user">> => #{<<"id">> => <<"7">>}, <<"roles">> => []},
New = Old#{<<"nick">> => <<"seven">>},
try
ok = guild_member_list_engine:add_member(Ref, 7, <<"seven">>, [], true),
Next = dispatch_user_change_to_subscribed_list(
7, Old, New, <<"500">>, deferred, engine_state(Ref)
),
Batch = maps:get(pending_member_list_sync_batch, Next),
?assertEqual(#{<<"500">> => true}, maps:get(pending_list_ids, Batch)),
_ = erlang:cancel_timer(maps:get(timer_ref, Batch))
after
guild_member_list_engine:destroy(Ref)
end.
synced_connection_mark_never_downgrades_pending_sync_test() ->
State1 = queue_connection_list_sync(<<"500">>, synced, #{}),
TimerRef = maps:get(timer_ref, maps:get(pending_member_list_sync_batch, State1)),
@@ -756,7 +814,7 @@ inert_presence_delta_invalidates_synced_lists_test() ->
try
ok = guild_member_list_engine:add_member(Ref, 7, <<"seven">>, [], true),
Next = dispatch_presence_delta(
7, Member, Member, Old, Old#{<<"afk">> => true}, State
7, Member, Member, Old, Old#{<<"afk">> => true}, immediate, State
),
?assertEqual(
#{<<"500">> => true},
+58 -16
View File
@@ -6,12 +6,14 @@
-export([handle_bus_presence/3, send_cached_presence_to_session/3]).
-export([cached_presences/1, send_presence_lookup_to_session/4]).
-export([sync_online_status/2]).
-export([apply_connect_presences/2]).
-export_type([guild_state/0, user_id/0]).
-type guild_state() :: map().
-type member() :: map().
-type user_id() :: integer().
-type list_sync() :: immediate | deferred.
%% members_sorted_ids trims with the member map it indexes: a snapshot that kept it would
%% answer sorted_member_ids/2 with ids for members the snapshot no longer carries.
@@ -40,15 +42,46 @@ handle_user_update(UserId, Payload, State) ->
),
{noreply, NewState}.
-spec apply_connect_presences([user_id()], guild_state()) -> guild_state().
apply_connect_presences([], State) ->
State;
apply_connect_presences(UserIds, State) ->
Found = cached_presences(UserIds),
lists:foldl(
fun(UserId, Acc) ->
apply_connect_presence(UserId, maps:get(UserId, Found, not_found), Acc)
end,
State,
UserIds
).
-spec apply_connect_presence(user_id(), {ok, map()} | not_found, guild_state()) ->
guild_state().
apply_connect_presence(UserId, {ok, Payload}, State) ->
{noreply, NewState} =
case maps:get(<<"user_update">>, Payload, false) of
true -> handle_user_update(UserId, Payload, State);
false -> handle_presence_update(UserId, Payload, deferred, State)
end,
NewState;
apply_connect_presence(_UserId, not_found, State) ->
State.
-spec handle_presence_update(user_id(), map(), guild_state()) -> {noreply, guild_state()}.
handle_presence_update(UserId, Payload, State) ->
handle_presence_update(UserId, Payload, immediate, State).
-spec handle_presence_update(user_id(), map(), list_sync(), guild_state()) ->
{noreply, guild_state()}.
handle_presence_update(UserId, Payload, ListSync, State) ->
case find_member_by_user_id(UserId, State) of
undefined -> {noreply, State};
Member -> process_presence(UserId, Payload, Member, State)
Member -> process_presence(UserId, Payload, Member, ListSync, State)
end.
-spec process_presence(user_id(), map(), member(), guild_state()) -> {noreply, guild_state()}.
process_presence(UserId, Payload, Member, State) ->
-spec process_presence(user_id(), map(), member(), list_sync(), guild_state()) ->
{noreply, guild_state()}.
process_presence(UserId, Payload, Member, ListSync, State) ->
PresenceMap = build_presence_map(Payload, Member),
NormalizedStatus = normalize_presence_status(
maps:get(<<"status">>, Payload, <<"offline">>)
@@ -58,17 +91,17 @@ process_presence(UserId, Payload, Member, State) ->
maps:get(member_presence, State),
UserId
),
process_presence_change(UserId, OldPresence, PresenceMap, Status, State).
process_presence_change(UserId, OldPresence, PresenceMap, Status, ListSync, State).
-spec process_presence_change(user_id(), map(), map(), atom(), guild_state()) ->
-spec process_presence_change(user_id(), map(), map(), atom(), list_sync(), guild_state()) ->
{noreply, guild_state()}.
process_presence_change(UserId, PresenceMap, PresenceMap, Status, State) ->
process_presence_change(UserId, PresenceMap, PresenceMap, Status, _ListSync, State) ->
{noreply, maybe_handle_unchanged_presence(Status, UserId, State)};
process_presence_change(UserId, OldPresence, PresenceMap, Status, State) ->
process_presence_change(UserId, OldPresence, PresenceMap, Status, ListSync, State) ->
StateWithPresence = store_member_presence(UserId, PresenceMap, State),
ok = guild_presence_sync:sync_online_status(UserId, StateWithPresence),
StateAfterBroadcast = spawn_presence_broadcast(
UserId, OldPresence, PresenceMap, State, StateWithPresence
UserId, OldPresence, PresenceMap, State, StateWithPresence, ListSync
),
StateAfterOffline = maybe_handle_offline(Status, UserId, StateAfterBroadcast),
{noreply, StateAfterOffline}.
@@ -104,20 +137,29 @@ sync_online_status(UserId, State) ->
map(),
map(),
guild_state(),
guild_state()
guild_state(),
list_sync()
) -> guild_state().
spawn_presence_broadcast(UserId, OldPresence, PresenceMap, OldState, NewState) ->
{ok, NewState1} = guild_member_list:broadcast_member_list_updates(
UserId,
OldState,
NewState,
OldPresence,
PresenceMap
spawn_presence_broadcast(UserId, OldPresence, PresenceMap, OldState, NewState, ListSync) ->
{ok, NewState1} = member_list_presence_update(
ListSync, UserId, OldState, NewState, OldPresence, PresenceMap
),
{Pid, NewState2} = guild_broadcaster:ensure(NewState1),
ok = cast_presence_update(Pid, UserId, PresenceMap, NewState2),
NewState2.
-spec member_list_presence_update(
list_sync(), user_id(), guild_state(), guild_state(), map(), map()
) -> {ok, guild_state()}.
member_list_presence_update(immediate, UserId, OldState, NewState, OldPresence, PresenceMap) ->
guild_member_list:broadcast_member_list_updates(
UserId, OldState, NewState, OldPresence, PresenceMap
);
member_list_presence_update(deferred, UserId, OldState, NewState, OldPresence, PresenceMap) ->
guild_member_list_write:queue_member_list_updates(
UserId, OldState, NewState, OldPresence, PresenceMap
).
-spec cast_presence_update(pid() | undefined, user_id(), map(), guild_state()) -> ok.
cast_presence_update(BroadcasterPid, UserId, PresenceMap, State) when is_pid(BroadcasterPid) ->
case safe_presence_update_recipients(UserId, presence_view(State)) of
@@ -11,6 +11,7 @@
-endif.
-define(INLINE_MEMBER_QUERY_MAX_IDS, 100).
-define(INFERRED_DEADLINE_MARGIN_MS, 1000).
-type guild_state() :: map().
-type user_id() :: integer().
@@ -36,16 +37,82 @@ handle_call(Msg, From, State) ->
end.
-spec is_expired(term(), gen_server:from()) -> boolean().
is_expired({_Tag, #{deadline_monotonic := Deadline}}, {Caller, _ReplyTag}) when
is_expired(Msg, From) ->
case deadline_expired(Msg, From) of
unknown -> inferred_deadline_expired(Msg);
Expired -> Expired
end.
-spec deadline_expired(term(), gen_server:from()) -> boolean() | unknown.
deadline_expired({_Tag, #{deadline_monotonic := Deadline}}, {Caller, _ReplyTag}) when
is_integer(Deadline)
->
case gateway_clock_offset:offset(node(Caller)) of
undefined -> false;
undefined -> unknown;
Offset -> erlang:monotonic_time(millisecond) > Deadline - Offset
end;
is_expired(_Msg, _From) ->
deadline_expired(_Msg, _From) ->
unknown.
-spec inferred_deadline_expired(term()) -> boolean().
inferred_deadline_expired(Msg) when is_tuple(Msg), tuple_size(Msg) > 0 ->
case caller_timeout_ms(element(1, Msg)) of
undefined -> false;
TimeoutMs -> waited_longer_than(TimeoutMs + ?INFERRED_DEADLINE_MARGIN_MS)
end;
inferred_deadline_expired(_Msg) ->
false.
-spec waited_longer_than(pos_integer()) -> boolean().
waited_longer_than(Ms) ->
case guild_mailbox_age:min_age_ms() of
undefined -> false;
Age when Age > Ms -> inferred_deadlines_enabled();
_ -> false
end.
-spec inferred_deadlines_enabled() -> boolean().
inferred_deadlines_enabled() ->
application:get_env(fluxer_gateway, guild_query_inferred_deadlines, true) =/= false.
-spec caller_timeout_ms(term()) -> pos_integer() | undefined.
caller_timeout_ms(get_large_guild_metadata) -> 200;
caller_timeout_ms(get_user_counts) -> 2000;
caller_timeout_ms(get_viewer_counts) -> 2000;
caller_timeout_ms(get_channel_member_counts) -> 2000;
caller_timeout_ms(check_permission) -> 5000;
caller_timeout_ms(get_guild_members_batch) -> 5000;
caller_timeout_ms(list_guild_members) -> 10000;
caller_timeout_ms(search_guild_members) -> 10000;
caller_timeout_ms(Tag) -> rpc_caller_timeout_ms(Tag).
-spec rpc_caller_timeout_ms(term()) -> pos_integer() | undefined.
rpc_caller_timeout_ms(get_user_permissions) -> 4000;
rpc_caller_timeout_ms(can_manage_roles) -> 4000;
rpc_caller_timeout_ms(can_manage_role) -> 4000;
rpc_caller_timeout_ms(get_assignable_roles) -> 4000;
rpc_caller_timeout_ms(get_user_max_role_position) -> 4000;
rpc_caller_timeout_ms(get_guild_data) -> 4000;
rpc_caller_timeout_ms(get_guild_auth_context) -> 4000;
rpc_caller_timeout_ms(get_guild_member) -> 4000;
rpc_caller_timeout_ms(has_member) -> 4000;
rpc_caller_timeout_ms(get_members_with_role) -> 4000;
rpc_caller_timeout_ms(check_target_member) -> 4000;
rpc_caller_timeout_ms(list_guild_members_cursor) -> 4000;
rpc_caller_timeout_ms(get_viewable_channels) -> 4000;
rpc_caller_timeout_ms(resolve_channel_mentions) -> 4000;
rpc_caller_timeout_ms(get_vanity_url_channel) -> 4000;
rpc_caller_timeout_ms(get_first_viewable_text_channel) -> 4000;
rpc_caller_timeout_ms(get_category_channel_count) -> 4000;
rpc_caller_timeout_ms(get_channel_count) -> 4000;
rpc_caller_timeout_ms(get_users_to_mention_by_roles) -> 4000;
rpc_caller_timeout_ms(get_users_to_mention_by_user_ids) -> 4000;
rpc_caller_timeout_ms(get_all_users_to_mention) -> 4000;
rpc_caller_timeout_ms(resolve_all_mentions) -> 4000;
rpc_caller_timeout_ms(resolve_mention_sources) -> 4000;
rpc_caller_timeout_ms(resolve_mention_sources_page) -> 4000;
rpc_caller_timeout_ms(_Tag) -> undefined.
-spec handle_query(term(), gen_server:from(), guild_state()) ->
{reply, term(), guild_state()}
| {noreply, guild_state()}.
@@ -482,6 +549,46 @@ monotonic_deadline_ignores_the_legacy_wall_clock_test() ->
),
?assertNot(is_expired({get_data, #{deadline => 0}}, From)).
deadline_less_queries_expire_once_every_known_caller_has_given_up_test() ->
Self = self(),
Ref = make_ref(),
spawn(fun() ->
From = {Self, make_ref()},
self() ! queued_request,
ok = guild_mailbox_age:note(),
Fresh = is_expired({get_large_guild_metadata}, From),
timer:sleep(1300),
Future = erlang:monotonic_time(millisecond) + 5000,
Results = #{
fresh => Fresh,
metadata => is_expired({get_large_guild_metadata}, From),
counts => is_expired({get_user_counts, 1}, From),
unknown_tag => is_expired({get_sessions}, From),
explicit_deadline =>
is_expired({get_large_guild_metadata, #{deadline_monotonic => Future}}, From)
},
ok = application:set_env(fluxer_gateway, guild_query_inferred_deadlines, false),
Disabled = is_expired({get_large_guild_metadata}, From),
ok = application:unset_env(fluxer_gateway, guild_query_inferred_deadlines),
Self ! {Ref, Results#{disabled => Disabled}}
end),
Results =
receive
{Ref, R} -> R
after 5000 -> error(timeout)
end,
?assertEqual(
#{
fresh => false,
metadata => true,
counts => false,
unknown_tag => false,
explicit_deadline => false,
disabled => false
},
Results
).
call_keeps_the_legacy_deadline_and_adds_a_monotonic_deadline_test() ->
Guild = spawn(fun() ->
receive
@@ -12,6 +12,7 @@
<<"roles">>,
<<"channels">>,
<<"channel_index">>,
channels_stale,
<<"emojis">>,
<<"stickers">>,
role_perms_cache,
@@ -15,6 +15,7 @@
-define(GUILD_CALL_TIMEOUT_MS, 2000).
-define(BATCH_OVERALL_TIMEOUT_MS, 3000).
-define(MAX_NONCE_BYTES, 64).
-define(LEGACY_NODE_KEY(Node), {?MODULE, legacy_node, Node}).
-type session_state() :: map().
@@ -105,14 +106,33 @@ worker(Parent, Tag, GuildId, GuildPid, UserId) ->
-spec fetch_counts(pid(), integer()) -> {ok, non_neg_integer(), non_neg_integer()} | error.
fetch_counts(GuildPid, UserId) ->
case is_legacy_node(node(GuildPid)) of
true -> fetch_legacy_counts(GuildPid, UserId);
false -> fetch_viewer_counts(GuildPid, UserId)
end.
-spec fetch_viewer_counts(pid(), integer()) ->
{ok, non_neg_integer(), non_neg_integer()} | error.
fetch_viewer_counts(GuildPid, UserId) ->
Request = {get_viewer_counts, #{user_id => UserId}},
try guild_query_handler:call(GuildPid, Request, ?GUILD_CALL_TIMEOUT_MS) of
ok -> fetch_legacy_counts(GuildPid, UserId);
Reply -> counts_result(Reply)
ok ->
ok = remember_legacy_node(node(GuildPid)),
fetch_legacy_counts(GuildPid, UserId);
Reply ->
counts_result(Reply)
catch
_:_ -> error
end.
-spec is_legacy_node(node()) -> boolean().
is_legacy_node(Node) ->
persistent_term:get(?LEGACY_NODE_KEY(Node), false) =:= true.
-spec remember_legacy_node(node()) -> ok.
remember_legacy_node(Node) ->
persistent_term:put(?LEGACY_NODE_KEY(Node), true).
-spec fetch_legacy_counts(pid(), integer()) ->
{ok, non_neg_integer(), non_neg_integer()} | error.
fetch_legacy_counts(GuildPid, UserId) ->
@@ -248,7 +268,12 @@ handle_request_echoes_nonce_test() ->
?assert(false)
end.
forget_legacy_nodes() ->
_ = persistent_term:erase(?LEGACY_NODE_KEY(node())),
ok.
handle_request_fetches_viewer_counts_with_deadline_test() ->
ok = forget_legacy_nodes(),
Self = self(),
Guild = spawn(fun() ->
receive
@@ -299,25 +324,26 @@ request_counts_payload(Guilds) ->
end.
handle_request_falls_back_to_user_counts_on_legacy_guild_test() ->
ok = forget_legacy_nodes(),
Guild = legacy_guild([#{member_count => 50, online_count => 10}]),
Payload = request_counts_payload(#{7 => {Guild, make_ref()}}),
?assertEqual([build_entry(7, 50, 10)], maps:get(<<"counts">>, Payload)).
handle_request_omits_guild_when_legacy_fallback_fails_test() ->
ok = forget_legacy_nodes(),
Guild = legacy_guild([ok]),
Payload = request_counts_payload(#{7 => {Guild, make_ref()}}),
?assertEqual([], maps:get(<<"counts">>, Payload)).
handle_request_mixes_legacy_and_current_guilds_test() ->
ok = forget_legacy_nodes(),
Legacy = legacy_guild([#{member_count => 50, online_count => 10}]),
Current = spawn(fun() ->
receive
{'$gen_call', From, {get_viewer_counts, #{user_id := 100}}} ->
gen_server:reply(From, #{member_count => 80, online_count => 20});
{'$gen_call', From, {get_user_counts, 100}} ->
gen_server:reply(From, #{member_count => 80, online_count => 20})
end,
receive
{'$gen_call', From2, _} -> gen_server:reply(From2, unexpected)
after 500 -> ok
end
end),
Payload = request_counts_payload(#{7 => {Legacy, make_ref()}, 9 => {Current, make_ref()}}),
@@ -327,6 +353,7 @@ handle_request_mixes_legacy_and_current_guilds_test() ->
).
fetch_counts_does_not_fall_back_on_current_guild_test() ->
ok = forget_legacy_nodes(),
Self = self(),
Guild = spawn(fun() ->
receive
@@ -346,7 +373,39 @@ fetch_counts_does_not_fall_back_on_current_guild_test() ->
after 400 -> ok
end.
fetch_counts_goes_straight_to_user_counts_after_a_legacy_answer_test() ->
ok = forget_legacy_nodes(),
Self = self(),
Guild = spawn(fun() -> recording_legacy_guild(Self, 3) end),
?assertEqual({ok, 50, 10}, fetch_counts(Guild, 100)),
?assertEqual({ok, 50, 10}, fetch_counts(Guild, 100)),
Calls = [
receive
{legacy_call, Tag} -> Tag
after 1000 -> none
end
|| _ <- [1, 2, 3]
],
ok = forget_legacy_nodes(),
?assertEqual([get_viewer_counts, get_user_counts, get_user_counts], Calls).
recording_legacy_guild(_Parent, 0) ->
ok;
recording_legacy_guild(Parent, N) ->
receive
{'$gen_call', From, {get_viewer_counts, _}} ->
Parent ! {legacy_call, get_viewer_counts},
gen_server:reply(From, ok);
{'$gen_call', From, {get_user_counts, 100}} ->
Parent ! {legacy_call, get_user_counts},
gen_server:reply(From, #{member_count => 50, online_count => 10})
after 1000 ->
ok
end,
recording_legacy_guild(Parent, N - 1).
fetch_counts_errors_on_malformed_reply_test() ->
ok = forget_legacy_nodes(),
Guild = spawn(fun() ->
receive
{'$gen_call', From, {get_viewer_counts, _}} -> gen_server:reply(From, #{})
+5 -71
View File
@@ -77,44 +77,13 @@ handle_session_down(Ref, State) ->
session_id(), session_data(), reference(), {session_id(), session_data()}, guild_state()
) -> {noreply, guild_state()} | {stop, normal, guild_state()}.
handle_pending_ref_down(SessionId, Session, Ref, Found, State) ->
Sessions = maps:get(sessions, State, #{}),
case pending_session_owns_connected_tracking(Session, Sessions, State) of
true -> guild_sessions_connect:handle_session_down(Ref, Found, State);
false -> handle_pending_session_down(SessionId, Ref, Sessions, State)
case guild_sessions_connect:counts_as_connected(Session) of
true ->
guild_sessions_connect:handle_session_down(Ref, Found, State);
false ->
handle_pending_session_down(SessionId, Ref, maps:get(sessions, State, #{}), State)
end.
-spec pending_session_owns_connected_tracking(session_data(), sessions_map(), guild_state()) ->
boolean().
pending_session_owns_connected_tracking(Session, Sessions, State) ->
UserId = maps:get(user_id, Session, undefined),
Counts = maps:get(user_session_counts, State, #{}),
TrackedCount = non_negative_count(maps:get(UserId, Counts, 0)),
TrackedCount > active_session_count(UserId, Sessions).
-spec non_negative_count(term()) -> non_neg_integer().
non_negative_count(Count) when is_integer(Count), Count >= 0 -> Count;
non_negative_count(_) -> 0.
-spec active_session_count(user_id() | undefined, sessions_map()) -> non_neg_integer().
active_session_count(UserId, Sessions) ->
maps:fold(
fun(_SessionId, Session, Count) ->
count_active_session(UserId, Session, Count)
end,
0,
Sessions
).
-spec count_active_session(user_id() | undefined, session_data(), non_neg_integer()) ->
non_neg_integer().
count_active_session(UserId, #{user_id := UserId} = Session, Count) ->
case maps:get(pending_connect, Session, false) of
true -> Count;
false -> Count + 1
end;
count_active_session(_UserId, _Session, Count) ->
Count.
-spec handle_pending_session_down(session_id(), reference(), sessions_map(), guild_state()) ->
{noreply, guild_state()}.
handle_pending_session_down(SessionId, Ref, Sessions, State) ->
@@ -757,41 +726,6 @@ filter_sessions_for_message_test() ->
filter_sessions_for_message(Sessions, 5, <<"1">>, undefined, State)
).
non_negative_count_test() ->
?assertEqual(3, non_negative_count(3)),
?assertEqual(0, non_negative_count(0)),
?assertEqual(0, non_negative_count(-1)),
?assertEqual(0, non_negative_count(undefined)).
active_session_count_test() ->
Sessions = #{
<<"a">> => #{user_id => 1, pending_connect => true},
<<"b">> => #{user_id => 1, pending_connect => false},
<<"c">> => #{user_id => 1},
<<"d">> => #{user_id => 2}
},
?assertEqual(2, active_session_count(1, Sessions)),
?assertEqual(1, active_session_count(2, Sessions)),
?assertEqual(0, active_session_count(3, Sessions)).
pending_session_owns_connected_tracking_untracked_test() ->
Session = #{user_id => 1, pending_connect => true},
Sessions = #{<<"a">> => Session},
?assertEqual(false, pending_session_owns_connected_tracking(Session, Sessions, #{})).
pending_session_owns_connected_tracking_other_session_owns_test() ->
Session = #{user_id => 1, pending_connect => true},
Active = #{user_id => 1, pending_connect => false},
Sessions = #{<<"a">> => Session, <<"b">> => Active},
State = #{user_session_counts => #{1 => 1}},
?assertEqual(false, pending_session_owns_connected_tracking(Session, Sessions, State)).
pending_session_owns_connected_tracking_true_test() ->
Session = #{user_id => 1, pending_connect => true},
Sessions = #{<<"a">> => Session},
State = #{user_session_counts => #{1 => 1}},
?assertEqual(true, pending_session_owns_connected_tracking(Session, Sessions, State)).
handle_pending_session_down_keeps_tracking_test() ->
Ref = make_ref(),
Pending = #{session_id => <<"a">>, user_id => 1, mref => Ref, pending_connect => true},
@@ -15,6 +15,7 @@
remove_session_ref/2,
build_session_ref_index/1,
remove_session/2,
counts_as_connected/1,
invalidate_viewable_channels_cache/1
]).
@@ -299,14 +300,28 @@ remove_session(SessionId, State) ->
) -> guild_state().
do_remove_session(SessionId, Session, State) ->
maybe_demonitor_session(Session),
UserId = maps:get(user_id, Session, undefined),
StateAfterCleanup = cleanup_disconnecting_session(Session, State),
StateAfterCleanup = cleanup_removed_session(Session, State),
SessionsAfterCleanup = maps:get(sessions, StateAfterCleanup, #{}),
NewSessions = maps:remove(SessionId, SessionsAfterCleanup),
State2 = remove_session_ref(
remove_session_ref(
maps:get(mref, Session, undefined), StateAfterCleanup#{sessions => NewSessions}
),
track_connected_user(UserId, -1, State2).
).
-spec cleanup_removed_session(session_data(), guild_state()) -> guild_state().
cleanup_removed_session(Session, State) ->
case counts_as_connected(Session) of
true ->
UserId = maps:get(user_id, Session, undefined),
track_connected_user(UserId, -1, cleanup_disconnecting_session(Session, State));
false ->
cleanup_session_subscriptions(Session, State)
end.
-spec counts_as_connected(session_data()) -> boolean().
counts_as_connected(#{pending_connect := true} = Session) ->
maps:get(owns_connected_tracking, Session, false) =:= true;
counts_as_connected(_Session) ->
true.
-spec maybe_demonitor_session(session_data()) -> ok.
maybe_demonitor_session(Session) ->
@@ -393,15 +408,19 @@ cleanup_disconnecting_session(undefined, State) ->
State;
cleanup_disconnecting_session(Session, State) ->
UserId = maps:get(user_id, Session),
State1 = guild_sessions_presence:unsubscribe_from_user_presence(UserId, State),
cleanup_session_subscriptions(Session, State1).
-spec cleanup_session_subscriptions(session_data(), guild_state()) -> guild_state().
cleanup_session_subscriptions(Session, State) ->
SessionId = maps:get(session_id, Session),
GuildId = require_guild_id(maps:get(id, State)),
passive_sync_registry:delete(SessionId, GuildId),
State1 = guild_sessions_presence:unsubscribe_from_user_presence(UserId, State),
State2 = guild_member_list:unsubscribe_session(SessionId, State1),
MemberSubs = maps:get(member_subscriptions, State2, guild_subscriptions:init_state()),
State1 = guild_member_list:unsubscribe_session(SessionId, State),
MemberSubs = maps:get(member_subscriptions, State1, guild_subscriptions:init_state()),
NewMemberSubs = guild_subscriptions:unsubscribe_session(SessionId, MemberSubs),
State3 = State2#{member_subscriptions => NewMemberSubs},
guild_sessions_connect_cleanup:cleanup_connect_admission_for_session(SessionId, State3).
State2 = State1#{member_subscriptions => NewMemberSubs},
guild_sessions_connect_cleanup:cleanup_connect_admission_for_session(SessionId, State2).
-spec maybe_resection_disconnected_user(
user_id(), guild_state(), guild_state()
@@ -6,6 +6,7 @@
-export([
subscribe_connected_user_presence/2,
subscribe_to_user_presence/2,
subscribe_without_cached_presence/2,
unsubscribe_from_user_presence/2,
unsubscribe_many_from_user_presence/3,
handle_user_offline/2
@@ -21,17 +22,23 @@ subscribe_connected_user_presence(UserId, State) ->
-spec subscribe_to_user_presence(user_id(), guild_state()) -> guild_state().
subscribe_to_user_presence(UserId, State) ->
case subscribe_without_cached_presence(UserId, State) of
{fresh, StateWithSubs} -> maybe_send_cached_presence(UserId, StateWithSubs);
{existing, StateWithSubs} -> StateWithSubs
end.
-spec subscribe_without_cached_presence(user_id(), guild_state()) ->
{fresh | existing, guild_state()}.
subscribe_without_cached_presence(UserId, State) ->
PresenceSubs = maps:get(presence_subscriptions, State, #{}),
CurrentCount = maps:get(UserId, PresenceSubs, 0),
case CurrentCount of
case maps:get(UserId, PresenceSubs, 0) of
0 ->
presence_bus:subscribe(UserId),
NewSubs = PresenceSubs#{UserId => 1},
StateWithSubs = State#{presence_subscriptions => NewSubs},
maybe_send_cached_presence(UserId, StateWithSubs);
_ ->
NewSubs = PresenceSubs#{UserId => CurrentCount + 1},
State#{presence_subscriptions => NewSubs}
{fresh, State#{presence_subscriptions => PresenceSubs#{UserId => 1}}};
CurrentCount ->
{existing, State#{
presence_subscriptions => PresenceSubs#{UserId => CurrentCount + 1}
}}
end.
-spec unsubscribe_from_user_presence(user_id(), guild_state()) -> guild_state().
@@ -177,13 +177,27 @@ flush_lazy_subscribe_buffer(State) ->
State1 = maps:remove(lazy_subscribe_buffer, State),
State2 = maps:remove(lazy_subscribe_order, State1),
State3 = maps:remove(lazy_subscribe_timer, State2),
lists:foldl(
fun(BufferKey, AccState) ->
process_buffered_lazy_subscribe(BufferKey, Buffer, AccState)
end,
State3,
Order
).
flush_lazy_subscribe_keys(Order, Buffer, State3).
-spec flush_lazy_subscribe_keys([lazy_subscribe_key()], map(), guild_state()) -> guild_state().
flush_lazy_subscribe_keys([], _Buffer, State) ->
State;
flush_lazy_subscribe_keys([BufferKey | Rest], Buffer, State) ->
Engines = map_size(maps:get(?ENGINES_KEY, State, #{})),
State1 = process_buffered_lazy_subscribe(BufferKey, Buffer, State),
case Rest =/= [] andalso map_size(maps:get(?ENGINES_KEY, State1, #{})) > Engines of
true -> defer_lazy_subscribe_keys(Rest, Buffer, State1);
false -> flush_lazy_subscribe_keys(Rest, Buffer, State1)
end.
-spec defer_lazy_subscribe_keys([lazy_subscribe_key()], map(), guild_state()) -> guild_state().
defer_lazy_subscribe_keys(Keys, Buffer, State) ->
Ref = erlang:send_after(0, self(), flush_lazy_subscribe_buffer),
State#{
lazy_subscribe_buffer => maps:with(Keys, Buffer),
lazy_subscribe_order => Keys,
lazy_subscribe_timer => Ref
}.
-spec move_buffer_key_to_tail(lazy_subscribe_key(), [lazy_subscribe_key()]) ->
[lazy_subscribe_key()].
@@ -3,7 +3,7 @@
-module(guild_subscription_mutual_channels).
-typing([eqwalizer]).
-export([filter_member_ids/3, filter_session_member_ids/2]).
-export([filter_member_ids/3, filter_session_member_ids/2, filter_session_member_ids/3]).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
@@ -12,7 +12,9 @@
-type guild_state() :: map().
-type user_id() :: integer().
-type memo() :: #{exceptions := sets:set(user_id()), cache := #{term() => boolean()}}.
-export_type([guild_state/0, user_id/0]).
-type view_cache() :: #{term() => [integer()]}.
-type view_memo() :: #{exceptions => sets:set(user_id()), views => view_cache()}.
-export_type([guild_state/0, user_id/0, view_memo/0]).
-spec filter_member_ids(user_id(), [user_id()], guild_state()) -> [user_id()].
filter_member_ids(_SessionUserId, [], _State) ->
@@ -30,12 +32,23 @@ filter_member_ids(SessionUserId, MemberIds, State) ->
-spec filter_session_member_ids([{term(), user_id(), [term()]}], guild_state()) ->
#{term() => [user_id()]}.
filter_session_member_ids([], _State) ->
#{};
filter_session_member_ids(Requests, State) ->
Exceptions = exceptions(State),
{Results, _Memo} = filter_session_member_ids(Requests, #{}, State),
Results.
-spec filter_session_member_ids([{term(), user_id(), [term()]}], view_memo(), guild_state()) ->
{#{term() => [user_id()]}, view_memo()}.
filter_session_member_ids([], Memo, _State) ->
{#{}, Memo};
filter_session_member_ids(Requests, Memo, State) ->
Exceptions =
case Memo of
#{exceptions := Cached} -> Cached;
_ -> exceptions(State)
end,
Views = maps:get(views, Memo, #{}),
Sessions = maps:get(sessions, State, #{}),
{Results, _Cache} = lists:foldl(
{Results, Cache} = lists:foldl(
fun({SessionId, SessionUserId, MemberIds}, {Acc, Cache}) ->
SessionMap = request_session_map(SessionId, SessionUserId, Sessions, State),
{Kept, Cache1} = keep_session_members(
@@ -43,10 +56,18 @@ filter_session_member_ids(Requests, State) ->
),
{Acc#{SessionId => Kept}, Cache1}
end,
{#{}, #{}},
{#{}, Views},
Requests
),
Results.
{Results, #{exceptions => Exceptions, views => role_views(Cache)}}.
-spec role_views(view_cache()) -> view_cache().
role_views(Cache) ->
maps:filter(fun(Key, _Channels) -> is_role_key(Key) end, Cache).
-spec is_role_key(term()) -> boolean().
is_role_key({roles, _RawRoles}) -> true;
is_role_key(_Key) -> false.
-spec request_session_map(term(), user_id(), map(), guild_state()) -> map().
request_session_map(SessionId, SessionUserId, Sessions, State) ->
@@ -35,11 +35,51 @@ bulk_get_inner(UserIds) ->
-spec bulk_get_map_inner([integer()]) -> #{integer() => map()}.
bulk_get_map_inner(UserIds) ->
UniqueUserIds = normalize_user_ids(UserIds),
PrimaryPresenceMap = fetch_primary_presences(UniqueUserIds),
MissingUserIds = [U || U <- UniqueUserIds, not maps:is_key(U, PrimaryPresenceMap)],
FallbackPresenceMap = fetch_fallback_presences(MissingUserIds),
maps:merge(PrimaryPresenceMap, FallbackPresenceMap).
{Local, Remote} = lists:partition(
fun({OwnerNode, _OwnerUserIds}) -> OwnerNode =:= node() end,
group_user_ids_by_owner(UserIds)
),
LocalMap = lists:foldl(
fun({_OwnerNode, OwnerUserIds}, AccMap) ->
maps:merge(AccMap, local_bulk_presence_map(OwnerUserIds))
end,
#{},
Local
),
Servers = [
{{presence_cache, OwnerNode}, OwnerUserIds}
|| {OwnerNode, OwnerUserIds} <- Remote
],
maps:merge(LocalMap, fetch_remote_groups(Servers)).
-spec fetch_remote_groups([{gen_server:server_ref(), [integer()]}]) -> #{integer() => map()}.
fetch_remote_groups([]) ->
#{};
fetch_remote_groups(Groups) ->
Deadline = erlang:monotonic_time(millisecond) + ?REMOTE_CALL_TIMEOUT_MS,
Requests = [
{Server, Ids, gen_server:send_request(Server, {bulk_get_local_map, Ids})}
|| {Server, Ids} <- Groups
],
lists:foldl(
fun({Server, Ids, ReqId}, AccMap) ->
maps:merge(AccMap, remote_group_reply(Server, Ids, ReqId, Deadline))
end,
#{},
Requests
).
-spec remote_group_reply(
gen_server:server_ref(), [integer()], gen_server:request_id(), integer()
) ->
#{integer() => map()}.
remote_group_reply(Server, Ids, ReqId, Deadline) ->
case gen_server:receive_response(ReqId, {abs, Deadline}) of
{reply, Reply} when is_map(Reply) ->
sanitize_presence_map(Reply);
_ ->
map_from_presence_list(safe_server_call(Server, {bulk_get_local, Ids}, []))
end.
-spec get_from_cluster(integer()) -> {ok, map()} | not_found.
get_from_cluster(UserId) ->
@@ -100,7 +140,15 @@ safe_remote_call(TargetNode, Request, Fallback) ->
safe_remote_call(TargetNode, Request, Fallback, Timeout) when
is_integer(Timeout), Timeout > 0
->
try gen_server:call({presence_cache, TargetNode}, Request, Timeout) of
safe_server_call({presence_cache, TargetNode}, Request, Fallback, Timeout).
-spec safe_server_call(gen_server:server_ref(), term(), term()) -> term().
safe_server_call(Server, Request, Fallback) ->
safe_server_call(Server, Request, Fallback, ?REMOTE_CALL_TIMEOUT_MS).
-spec safe_server_call(gen_server:server_ref(), term(), term(), pos_integer()) -> term().
safe_server_call(Server, Request, Fallback, Timeout) ->
try gen_server:call(Server, Request, Timeout) of
Reply -> Reply
catch
error:_ -> Fallback;
@@ -199,44 +247,6 @@ presence_values(PresenceMap) ->
UserIds = lists:sort(maps:keys(PresenceMap)),
[maps:get(UserId, PresenceMap) || UserId <- UserIds].
-spec fetch_primary_presences([integer()]) -> #{integer() => map()}.
fetch_primary_presences(UniqueUserIds) ->
OwnerGroups = group_user_ids_by_owner(UniqueUserIds),
lists:foldl(
fun({OwnerNode, OwnerUserIds}, AccMap) ->
maps:merge(AccMap, fetch_from_node(OwnerNode, OwnerUserIds))
end,
#{},
OwnerGroups
).
-spec fetch_from_node(node(), [integer()]) -> #{integer() => map()}.
fetch_from_node(OwnerNode, UserIds) ->
case OwnerNode =:= node() of
true -> local_bulk_presence_map(UserIds);
false -> fetch_remote_bulk_presence_map(OwnerNode, UserIds)
end.
-spec fetch_fallback_presences([integer()]) -> #{integer() => map()}.
fetch_fallback_presences([]) ->
#{};
fetch_fallback_presences(MissingUserIds) ->
OwnerGroups = group_user_ids_by_owner(MissingUserIds),
lists:foldl(
fun({OwnerNode, OwnerUserIds}, AccMap) ->
maps:merge(AccMap, fetch_fallback_from_node(OwnerNode, OwnerUserIds))
end,
#{},
OwnerGroups
).
-spec fetch_fallback_from_node(node(), [integer()]) -> #{integer() => map()}.
fetch_fallback_from_node(OwnerNode, UserIds) ->
case OwnerNode =:= node() of
true -> local_bulk_presence_map(UserIds);
false -> fetch_remote_bulk_presence_map(OwnerNode, UserIds)
end.
-spec fetch_from_owner_nodes(integer(), [node()]) -> {ok, map()} | not_found.
fetch_from_owner_nodes(_UserId, []) ->
not_found;
@@ -327,6 +337,63 @@ group_user_ids_by_owner_matches_single_owner_resolution_test() ->
restore_persistent_term(Key, Previous)
end.
remote_groups_are_fetched_in_parallel_test() ->
Test = self(),
First = spawn(fun() ->
fake_presence_server(Test, #{1 => #{<<"status">> => <<"online">>}})
end),
Second = spawn(fun() ->
fake_presence_server(Test, #{2 => #{<<"status">> => <<"idle">>}})
end),
Legacy = spawn(fun() -> fake_presence_server(Test, legacy) end),
Fetcher = spawn(fun() ->
Test ! {fetched, fetch_remote_groups([{First, [1]}, {Second, [2]}, {Legacy, [3]}])}
end),
Received = [receive_request() || _ <- [First, Second, Legacy]],
Expected = [
{First, {bulk_get_local_map, [1]}},
{Second, {bulk_get_local_map, [2]}},
{Legacy, {bulk_get_local_map, [3]}}
],
?assertEqual(lists:sort(Expected), lists:sort(Received)),
[Server ! go || Server <- [First, Second, Legacy]],
?assertMatch({Legacy, {bulk_get_local, [3]}}, receive_request()),
Legacy ! go,
Fetched =
receive
{fetched, Map} -> Map
after 5000 -> timeout
end,
?assertEqual(
#{
1 => #{<<"status">> => <<"online">>},
2 => #{<<"status">> => <<"idle">>},
3 => #{<<"user">> => #{<<"id">> => 3}}
},
Fetched
),
[exit(Pid, kill) || Pid <- [First, Second, Legacy, Fetcher]].
fake_presence_server(Test, Reply) ->
receive
{'$gen_call', From, Request} ->
Test ! {request, self(), Request},
receive
go -> gen_server:reply(From, fake_reply(Reply, Request))
end,
fake_presence_server(Test, Reply)
end.
fake_reply(legacy, {bulk_get_local_map, _Ids}) -> unknown_request;
fake_reply(legacy, {bulk_get_local, Ids}) -> [#{<<"user">> => #{<<"id">> => Id}} || Id <- Ids];
fake_reply(Map, _Request) -> Map.
receive_request() ->
receive
{request, Server, Request} -> {Server, Request}
after 900 -> no_request
end.
restore_persistent_term(Key, undefined) ->
persistent_term:erase(Key);
restore_persistent_term(Key, Value) ->
@@ -0,0 +1,281 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_stall_monitor).
-typing([eqwalizer]).
-behaviour(gen_server).
-export([start_link/0, snapshot/0, reset/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(TICK_MS, 100).
-define(LATE_TICK_MS, 250).
-define(SEVERE_MS, 1000).
-define(RECENT_MAX, 32).
-define(LOG_INTERVAL_MS, 10_000).
-define(DEFAULT_LONG_SCHEDULE_MS, 200).
-define(DEFAULT_LONG_GC_MS, 200).
-define(SNAPSHOT_TIMEOUT_MS, 5000).
-type kind() :: long_schedule | long_gc | busy_dist_port | timer_late.
-type stat() :: #{
count := non_neg_integer(), severe := non_neg_integer(), max_ms := non_neg_integer()
}.
-type event() :: #{
at_ms := integer(),
kind := kind(),
ms := non_neg_integer(),
who := pid() | port() | undefined,
info := term()
}.
-type monitor_status() :: installed | not_owner | disabled.
-type state() :: #{
since_ms := integer(),
status := monitor_status(),
stats := #{kind() => stat()},
recent := [event()],
tick_due := integer(),
last_log_ms := integer(),
suppressed := non_neg_integer()
}.
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
case gen_server:start_link({local, ?MODULE}, ?MODULE, [], []) of
{ok, Pid} -> {ok, Pid};
ignore -> {error, ignore};
{error, E} -> {error, E}
end.
-spec snapshot() -> map().
snapshot() ->
Raw = gen_server:call(?MODULE, snapshot, ?SNAPSHOT_TIMEOUT_MS),
Recent = maps:get(recent, Raw, []),
Raw#{recent => [describe(E) || E <- Recent]}.
-spec reset() -> ok.
reset() ->
gen_server:call(?MODULE, reset, ?SNAPSHOT_TIMEOUT_MS).
-spec init([]) -> {ok, state()}.
init([]) ->
_ = process_flag(priority, high),
Now = now_ms(),
{ok, #{
since_ms => erlang:system_time(millisecond),
status => install(),
stats => #{},
recent => [],
tick_due => schedule_tick(Now),
last_log_ms => Now - ?LOG_INTERVAL_MS,
suppressed => 0
}}.
-spec handle_call(term(), gen_server:from(), state()) -> {reply, term(), state()}.
handle_call(snapshot, _From, State) ->
{reply, view(State), State};
handle_call(reset, _From, State) ->
{reply, ok, State#{
since_ms := erlang:system_time(millisecond), stats := #{}, recent := [], suppressed := 0
}};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()}.
handle_info({monitor, Who, Kind, Info}, State) when
Kind =:= long_schedule; Kind =:= long_gc; Kind =:= busy_dist_port
->
{noreply, record(Kind, event_ms(Kind, Info), Who, compact_info(Kind, Info), State)};
handle_info(tick, #{tick_due := Due} = State) ->
Now = now_ms(),
State1 = State#{tick_due := schedule_tick(Now)},
{noreply, maybe_record_late(Now - Due, State1)};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, #{status := installed}) ->
_ = release(),
ok;
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), state(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
-spec install() -> monitor_status().
install() ->
case enabled() of
false ->
disabled;
true ->
case erlang:system_monitor() of
undefined ->
_ = erlang:system_monitor(self(), [
{long_schedule,
threshold(
stall_monitor_long_schedule_ms, ?DEFAULT_LONG_SCHEDULE_MS
)},
{long_gc, threshold(stall_monitor_long_gc_ms, ?DEFAULT_LONG_GC_MS)},
busy_dist_port
]),
installed;
{Pid, _} when Pid =:= self() ->
installed;
_ ->
not_owner
end
end.
-spec release() -> ok.
release() ->
case erlang:system_monitor() of
{Pid, _} when Pid =:= self() ->
_ = erlang:system_monitor(undefined),
ok;
_ ->
ok
end.
-spec enabled() -> boolean().
enabled() ->
application:get_env(fluxer_gateway, stall_monitor_enabled, true) =/= false.
-spec threshold(atom(), pos_integer()) -> pos_integer().
threshold(Key, Default) ->
case application:get_env(fluxer_gateway, Key) of
{ok, N} when is_integer(N), N > 0 -> N;
_ -> Default
end.
-spec maybe_record_late(integer(), state()) -> state().
maybe_record_late(Late, State) when Late >= ?LATE_TICK_MS ->
record(timer_late, Late, undefined, #{late_ms => Late}, State);
maybe_record_late(_Late, State) ->
State.
-spec record(kind(), non_neg_integer(), pid() | port() | undefined, term(), state()) -> state().
record(Kind, Ms, Who, Info, #{stats := Stats, recent := Recent} = State) ->
Event = #{
at_ms => erlang:system_time(millisecond),
kind => Kind,
ms => Ms,
who => Who,
info => Info
},
State1 = State#{
stats := Stats#{Kind => bump(maps:get(Kind, Stats, empty_stat()), Ms)},
recent := lists:sublist([Event | Recent], ?RECENT_MAX)
},
maybe_log(Event, State1).
-spec empty_stat() -> stat().
empty_stat() ->
#{count => 0, severe => 0, max_ms => 0}.
-spec bump(stat(), non_neg_integer()) -> stat().
bump(#{count := C, severe := S, max_ms := M}, Ms) ->
#{
count => C + 1,
severe => S + severe_inc(Ms),
max_ms => max(M, Ms)
}.
-spec severe_inc(non_neg_integer()) -> 0 | 1.
severe_inc(Ms) when Ms >= ?SEVERE_MS -> 1;
severe_inc(_Ms) -> 0.
-spec maybe_log(event(), state()) -> state().
maybe_log(Event, #{last_log_ms := Last, suppressed := Suppressed} = State) ->
Now = now_ms(),
case Now - Last >= ?LOG_INTERVAL_MS of
true ->
logger:warning("Scheduler stall detected", Event#{suppressed => Suppressed}),
State#{last_log_ms := Now, suppressed := 0};
false ->
State#{suppressed := Suppressed + 1}
end.
-spec event_ms(kind(), term()) -> non_neg_integer().
event_ms(_Kind, Info) when is_list(Info) ->
case proplists:get_value(timeout, Info) of
Ms when is_integer(Ms), Ms >= 0 -> Ms;
_ -> 0
end;
event_ms(_Kind, _Info) ->
0.
-spec compact_info(kind(), term()) -> term().
compact_info(long_schedule, Info) when is_list(Info) ->
maps:from_list([{K, V} || {K, V} <- Info, K =:= in orelse K =:= out]);
compact_info(long_gc, Info) when is_list(Info) ->
maps:from_list([
{K, V}
|| {K, V} <- Info, K =:= heap_size orelse K =:= old_heap_size orelse K =:= mbuf_size
]);
compact_info(_Kind, Info) when is_pid(Info); is_port(Info) ->
#{peer => Info};
compact_info(_Kind, _Info) ->
#{}.
-spec view(state()) -> map().
view(#{since_ms := Since, status := Status, stats := Stats, recent := Recent, suppressed := S}) ->
#{
since_ms => Since,
status => Status,
stats => Stats,
recent => Recent,
suppressed_logs => S
}.
-spec describe(event()) -> map().
describe(#{who := Who} = Event) when is_pid(Who) ->
Event#{who_info => pid_info(Who)};
describe(#{who := Who} = Event) when is_port(Who) ->
Event#{who_info => port_name(Who)};
describe(Event) ->
Event.
-spec pid_info(pid()) -> term().
pid_info(Pid) when node(Pid) =/= node() ->
remote;
pid_info(Pid) ->
Base =
case erlang:process_info(Pid, [registered_name, {dictionary, '$initial_call'}]) of
undefined -> #{alive => false};
Items -> maps:from_list([{item_key(K), V} || {K, V} <- Items])
end,
Base#{guild_id => guild_id_of(Pid)}.
-spec item_key(term()) -> term().
item_key({dictionary, '$initial_call'}) -> initial_call;
item_key(K) -> K.
-spec guild_id_of(pid()) -> integer() | undefined.
guild_id_of(Pid) ->
try ets:select(guild_pid_cache, [{{'$1', '$2'}, [{'=:=', '$2', Pid}], ['$1']}], 1) of
{[GuildId | _], _} when is_integer(GuildId) -> GuildId;
_ -> undefined
catch
error:badarg -> undefined
end.
-spec port_name(port()) -> term().
port_name(Port) ->
case erlang:port_info(Port, name) of
{name, Name} -> Name;
undefined -> closed
end.
-spec schedule_tick(integer()) -> integer().
schedule_tick(Now) ->
_ = erlang:send_after(?TICK_MS, self(), tick),
Now + ?TICK_MS.
-spec now_ms() -> integer().
now_ms() ->
erlang:monotonic_time(millisecond).
@@ -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">>
}.