fix(push): stop pushes for read, silent and muted messages (#3033)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 15:58:46 +02:00
1 parent 9908518f5b
commit b30ea361d3
45 files changed
+3393 -268

No files matched your search

@@ -28,12 +28,18 @@ do_sync_user_guild_settings(#{
<<"user_guild_settings">> := Settings
}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UBin),
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GBin),
GuildId = settings_guild_id(GBin),
fanout_local_cache_mutation(push, sync_user_guild_settings_local, [
UserId, GuildId, Settings
]),
true.
-spec settings_guild_id(term()) -> non_neg_integer().
settings_guild_id(<<"0">>) ->
0;
settings_guild_id(GBin) ->
validation:snowflake_or_throw(<<"guild_id">>, GBin).
-spec do_sync_user_blocked_ids(map()) -> true.
do_sync_user_blocked_ids(#{<<"user_id">> := UBin, <<"blocked_user_ids">> := BlockedRaw}) ->
UserId = validation:snowflake_or_throw(<<"user_id">>, UBin),
@@ -137,6 +143,47 @@ invalidate_subscriptions_accepts_a_user_test() ->
execute_method(<<"push.invalidate_subscriptions">>, #{<<"user_id">> => <<"1001">>})
).
sync_user_guild_settings_caches_the_private_scope_test() ->
push_ets_cache:init(),
Settings = #{<<"channel_overrides">> => #{<<"5">> => #{<<"muted">> => true}}},
try
with_registered_push(fun() ->
?assert(
execute_method(<<"push.sync_user_guild_settings">>, #{
<<"user_id">> => <<"1001">>,
<<"guild_id">> => <<"0">>,
<<"user_guild_settings">> => Settings
})
)
end),
?assertEqual(Settings, push_ets_cache:get_user_guild_settings(1001, 0))
after
push_ets_cache:delete_user_guild_settings(1001, 0)
end.
sync_user_guild_settings_rejects_an_invalid_guild_id_test() ->
?assertError(
{validation, _},
execute_method(<<"push.sync_user_guild_settings">>, #{
<<"user_id">> => <<"1001">>,
<<"guild_id">> => <<"nope">>,
<<"user_guild_settings">> => #{}
})
).
with_registered_push(Fun) ->
case whereis(push) of
undefined ->
register(push, self()),
try
Fun()
after
unregister(push)
end;
_Pid ->
Fun()
end.
invalidate_subscriptions_rejects_an_invalid_snowflake_test() ->
?assertError(
{validation, _},
@@ -302,6 +302,12 @@ render_push_outbox_queue_stats(Stats) ->
<<"counter">>,
<<"Queued recipients skipped because they became active">>,
gate_counter(skipped_active, Stats)
),
format_metric(
<<"fluxer_gateway_push_outbox_followup_clears_total">>,
<<"counter">>,
<<"Clears queued after an in-flight push job whose recipient read the channel">>,
gate_counter(followup_clears, Stats)
)
].
+8 -4
View File
@@ -5,6 +5,7 @@
-export([
call/1,
call/2,
call_with_retry/2,
handle_http_response/2,
rpc_headers/1,
@@ -42,9 +43,13 @@
-spec call(rpc_request()) -> rpc_response().
call(Request) ->
call(Request, request_timeout_ms()).
-spec call(rpc_request(), pos_integer()) -> rpc_response().
call(Request, Timeout) ->
Trace = build_request_trace(Request),
maybe_log_voice_request_start(Trace),
Result = do_request(Request),
Result = do_request(Request, Timeout),
maybe_log_voice_request_response(Trace, Result),
Result.
@@ -132,10 +137,9 @@ backoff_delay(Attempt, {_MaxAttempts, BaseMs, MaxMs, JitterMs}) ->
end,
trunc(CappedDelay + Jitter).
-spec do_request(rpc_request()) -> rpc_response().
do_request(Request) ->
-spec do_request(rpc_request(), pos_integer()) -> rpc_response().
do_request(Request, Timeout) ->
{Url, HostKey, IsHttps} = rpc_url_meta(),
Timeout = request_timeout_ms(),
Payload = iolist_to_binary(json:encode(Request)),
Headers = rpc_headers(Request),
RequestOpts = #{
+6
View File
@@ -59,6 +59,8 @@ handle_call({reload, NewData}, _From, State) ->
handle_reload_call(NewData, State);
handle_call(get_voice_server_pid, _From, State) ->
guild_voice_lifecycle:reply_voice_server_pid(State);
handle_call({released_push_holds, SessionIds}, _From, State) when is_list(SessionIds) ->
{reply, guild_sessions:released_push_holds(SessionIds, State), State};
handle_call({terminate}, _From, State) ->
{stop, normal, ok, State};
handle_call(Msg, From, State) when is_tuple(Msg) ->
@@ -158,6 +160,10 @@ handle_cast({drop_session_member_lists, SessionId}, State) when is_binary(Sessio
{noreply, guild_member_list:unsubscribe_session(SessionId, State)};
handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) ->
handle_set_session_typing_override_cast(SessionId, TypingFlag, State);
handle_cast({set_session_push_hold, SessionId, Hold}, State) when
is_binary(SessionId), is_boolean(Hold)
->
{noreply, guild_sessions:set_session_push_hold(SessionId, Hold, State)};
handle_cast({send_guild_sync, SessionId}, State) ->
handle_send_guild_sync_cast(SessionId, State);
handle_cast({send_members_chunk, SessionId, ChunkData}, State) ->
+255 -76
View File
@@ -19,6 +19,7 @@
-define(PUSH_WORKER_MAX_AGE_MS, 60000).
-define(GRACE_RECHECK_KEY, guild_push_offline_grace_recheck_ms).
-define(DEFAULT_GRACE_RECHECK_MS, 7000).
-define(GRACE_HOLD_CALL_TIMEOUT_MS, 5000).
-define(PUSH_COUNTERS, guild_push_counters).
-define(PUSH_COUNTER_KEYS, [
worker_started,
@@ -40,14 +41,17 @@
-type user_id() :: integer().
-type push_worker() :: {integer(), pid(), integer()}.
-type push_gate() :: push_worker() | undefined.
-type grace_sessions() :: #{user_id() => [pid()]}.
-type grace_sessions() :: #{user_id() => [{binary(), pid()}]}.
-type grace_hold() :: {grace_sessions(), integer()} | none.
-type held_push() :: {[user_id()], grace_hold(), term()}.
-type held_push() :: {[user_id()], grace_hold(), term(), pid() | undefined}.
-export_type([event/0, event_data/0, guild_state/0, guild_id/0]).
-spec maybe_send_push_notifications(event(), event_data(), guild_id(), guild_state()) -> ok.
maybe_send_push_notifications(message_create, FinalData, GuildId, UpdatedState) ->
case maps:get(disable_push_notifications, UpdatedState, false) of
case
maps:get(disable_push_notifications, UpdatedState, false) orelse
push_message_params:suppresses_notifications(FinalData)
of
true -> ok;
false -> maybe_spawn_push(FinalData, GuildId, UpdatedState)
end;
@@ -251,15 +255,16 @@ spawn_push(FinalData, GuildId, UpdatedState, Gate) ->
-spec compact_push_state(ets:tid(), map(), guild_id(), guild_state()) -> guild_state().
compact_push_state(MembersTab, Data, GuildId, UpdatedState) ->
Sessions = maps:get(sessions, UpdatedState, #{}),
SessionEligibility = build_push_session_eligibility(Sessions, UpdatedState),
{SessionEligibility, Hold} = build_push_session_eligibility(Sessions, UpdatedState),
#{
id => maps:get(id, UpdatedState, GuildId),
data => compact_push_data(Data),
virtual_channel_access => maps:get(virtual_channel_access, UpdatedState, #{}),
members_ets => MembersTab,
member_presence => maps:get(member_presence, UpdatedState, undefined),
guild_pid => grace_guild_pid(),
session_eligibility => SessionEligibility,
grace_hold => grace_hold(Sessions, SessionEligibility),
grace_hold => Hold,
member_count => maps:get(member_count, UpdatedState, undefined)
}.
@@ -316,6 +321,7 @@ legacy_push_state(GuildId, UpdatedState) ->
data => maps:get(data, UpdatedState, #{}),
sessions => maps:get(sessions, UpdatedState, #{}),
member_presence => maps:get(member_presence, UpdatedState, undefined),
guild_pid => grace_guild_pid(),
virtual_channel_access => maps:get(virtual_channel_access, UpdatedState, #{}),
member_count => maps:get(member_count, UpdatedState, undefined)
}.
@@ -577,7 +583,8 @@ send_compact_scanned_push(MessageData, GuildId, Scan, State) ->
{
lists:reverse(maps:get(held_user_ids, Scan, [])),
maps:get(grace_hold, State, none),
maps:get(member_presence, State, undefined)
maps:get(member_presence, State, undefined),
maps:get(guild_pid, State, undefined)
}
).
@@ -654,7 +661,7 @@ send_push_notifications(MessageData, GuildId, State) ->
Data = maps:get(data, State),
Members = guild_data_index:member_map(Data),
Sessions = maps:get(sessions, State, #{}),
SessionEligibility = build_push_session_eligibility(Sessions, State),
{SessionEligibility, Hold} = build_push_session_eligibility(Sessions, State),
CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData),
ChannelIdBin = maps:get(<<"channel_id">>, MessageData, undefined),
case guild_dispatch_decorate:parse_snowflake(<<"channel_id">>, ChannelIdBin) of
@@ -668,7 +675,7 @@ send_push_notifications(MessageData, GuildId, State) ->
CandidateUserIds,
ChannelId,
SessionEligibility,
grace_hold(Sessions, SessionEligibility),
Hold,
Data,
State
)
@@ -715,7 +722,12 @@ send_to_eligible(
SessionEligibility,
Data,
large_guild_meta(State),
{HeldUserIds, Hold, maps:get(member_presence, State, undefined)}
{
HeldUserIds,
Hold,
maps:get(member_presence, State, undefined),
maps:get(guild_pid, State, undefined)
}
)
end.
@@ -885,41 +897,81 @@ view_to_filtermap(UserId, ChannelId, Member, State) ->
false -> false
end.
-spec build_push_session_eligibility(map(), guild_state()) -> #{user_id() => boolean()}.
-spec build_push_session_eligibility(map(), guild_state()) ->
{#{user_id() => boolean()}, grace_hold()}.
build_push_session_eligibility(Sessions, State) ->
case presence_eligibility_enabled() of
true -> build_push_presence_eligibility(Sessions, State);
false -> build_push_session_eligibility(Sessions)
false -> build_legacy_session_eligibility(Sessions)
end.
-spec grace_guild_pid() -> pid() | undefined.
grace_guild_pid() ->
case presence_eligibility_enabled() of
true -> self();
false -> undefined
end.
-spec presence_eligibility_enabled() -> boolean().
presence_eligibility_enabled() ->
application:get_env(fluxer_gateway, push_presence_eligibility, true) =:= true.
-spec build_push_presence_eligibility(map(), guild_state()) -> #{user_id() => boolean()}.
-spec build_legacy_session_eligibility(map()) -> {#{user_id() => boolean()}, grace_hold()}.
build_legacy_session_eligibility(Sessions) ->
Eligibility = build_push_session_eligibility(Sessions),
{Eligibility, grace_hold(suppressed_sessions(Sessions, Eligibility))}.
-spec build_push_presence_eligibility(map(), guild_state()) ->
{#{user_id() => boolean()}, grace_hold()}.
build_push_presence_eligibility(Sessions, State) ->
Presences = maps:get(member_presence, State, undefined),
Engaged = engaged_sessions(Sessions, maps:get(member_presence, State, undefined)),
{engagement_eligibility(Sessions, Engaged), grace_hold(held_engaged_sessions(Engaged))}.
-spec engaged_sessions(map(), term()) -> grace_sessions().
engaged_sessions(Sessions, Presences) ->
maps:fold(
fun(_Sid, Session, Acc) ->
accumulate_presence_eligibility(Session, Presences, Acc)
fun(Sid, Session, Acc) -> maybe_add_engaged_session(Sid, Session, Presences, Acc) end,
#{},
Sessions
).
-spec maybe_add_engaged_session(term(), term(), term(), grace_sessions()) -> grace_sessions().
maybe_add_engaged_session(Sid, #{user_id := UserId} = Session, Presences, Acc) when
is_integer(UserId)
->
case session_engaged(Session, UserId, Presences) of
true -> Acc#{UserId => session_pids(Sid, Session) ++ maps:get(UserId, Acc, [])};
false -> Acc
end;
maybe_add_engaged_session(_Sid, _Session, _Presences, Acc) ->
Acc.
-spec session_engaged(map(), user_id(), term()) -> boolean().
session_engaged(#{push_hold := Hold}, _UserId, _Presences) ->
Hold =:= true;
session_engaged(_Session, UserId, Presences) ->
actively_engaged(UserId, Presences).
-spec session_pids(term(), map()) -> [{binary(), pid()}].
session_pids(Sid, #{pid := Pid}) when is_binary(Sid), is_pid(Pid) -> [{Sid, Pid}];
session_pids(_Sid, _Session) -> [].
-spec engagement_eligibility(map(), grace_sessions()) -> #{user_id() => boolean()}.
engagement_eligibility(Sessions, Engaged) ->
maps:fold(
fun
(_Sid, #{user_id := UserId}, Acc) when is_integer(UserId) ->
Acc#{UserId => not maps:is_key(UserId, Engaged)};
(_Sid, _Session, Acc) ->
Acc
end,
#{},
Sessions
).
-spec accumulate_presence_eligibility(map(), term(), #{user_id() => boolean()}) ->
#{user_id() => boolean()}.
accumulate_presence_eligibility(Session, Presences, Acc) ->
case maps:get(user_id, Session, undefined) of
UserId when is_integer(UserId) ->
Acc#{
UserId =>
maps:get(UserId, Acc, true) andalso
not actively_engaged(UserId, Presences)
};
_ ->
Acc
end.
-spec held_engaged_sessions(grace_sessions()) -> grace_sessions().
held_engaged_sessions(Engaged) ->
maps:filter(fun(_UserId, Pids) -> Pids =/= [] end, Engaged).
-spec actively_engaged(user_id(), term()) -> boolean().
actively_engaged(_UserId, undefined) ->
@@ -1059,46 +1111,77 @@ send_push_now(UserIds, Params) ->
push:handle_message_create(Params#{user_ids => UserIds}).
-spec hold_push_through_grace(held_push(), map()) -> ok.
hold_push_through_grace({[_ | _] = UserIds, {Sessions, RecheckAt}, Presences}, Params) ->
hold_push_through_grace(
{[_ | _] = UserIds, {Sessions, RecheckAt}, Presences, GuildPid}, Params
) ->
HeldParams = Params#{
user_roles => maps:with(UserIds, maps:get(user_roles, Params)),
connected_users => maps:with(UserIds, maps:get(connected_users, Params))
},
HeldSessions = maps:with(UserIds, Sessions),
_ = spawn(fun() ->
deliver_after_grace(HeldParams, HeldSessions, RecheckAt, Presences)
deliver_after_grace(HeldParams, HeldSessions, RecheckAt, {Presences, GuildPid})
end),
ok;
hold_push_through_grace(_Held, _Params) ->
ok.
-spec deliver_after_grace(map(), grace_sessions(), integer(), term()) -> ok.
deliver_after_grace(Params, Sessions, RecheckAt, Presences) ->
-spec deliver_after_grace(map(), grace_sessions(), integer(), {term(), pid() | undefined}) ->
ok.
deliver_after_grace(Params, Sessions, RecheckAt, {Presences, GuildPid}) ->
ok = apply_push_worker_priority(),
LiveSessions = await_grace_recheck(monitor_grace_sessions(Sessions), RecheckAt),
send_push_now(
grace_recipients(maps:keys(Sessions), maps:values(LiveSessions), Presences), Params
).
HeldUserIds = maps:keys(Sessions),
AlreadyOffline = users_left_offline(HeldUserIds, Presences),
LiveSessions = maps:values(
await_grace_recheck(monitor_grace_sessions(Sessions), RecheckAt)
),
Holding = still_holding(LiveSessions, released_push_holds(GuildPid, LiveSessions)),
send_push_now(grace_recipients(HeldUserIds, Holding, Presences, AlreadyOffline), Params).
-spec grace_recipients([user_id()], [user_id()], term()) -> [user_id()].
grace_recipients(HeldUserIds, LiveUserIds, Presences) ->
-spec still_holding([{user_id(), binary()}], [binary()]) -> [user_id()].
still_holding(LiveSessions, Released) ->
ReleasedSet = maps:from_keys(Released, true),
[UserId || {UserId, Sid} <- LiveSessions, not maps:is_key(Sid, ReleasedSet)].
-spec released_push_holds(pid() | undefined, [{user_id(), binary()}]) -> [binary()].
released_push_holds(GuildPid, [_ | _] = LiveSessions) when is_pid(GuildPid) ->
SessionIds = [Sid || {_UserId, Sid} <- LiveSessions],
try
gen_server:call(
GuildPid, {released_push_holds, SessionIds}, ?GRACE_HOLD_CALL_TIMEOUT_MS
)
of
Released when is_list(Released) -> Released;
_ -> []
catch
exit:_ -> []
end;
released_push_holds(_GuildPid, _LiveSessions) ->
[].
-spec grace_recipients([user_id()], [user_id()], term(), [user_id()]) -> [user_id()].
grace_recipients(HeldUserIds, LiveUserIds, Presences, AlreadyOffline) ->
Live = lists:usort(LiveUserIds),
Gone = lists:usort(HeldUserIds) -- Live,
lists:usort(Gone ++ users_left_offline(Live, Presences)).
lists:usort(Gone ++ (users_left_offline(Live, Presences) -- AlreadyOffline)).
-spec monitor_grace_sessions(grace_sessions()) -> #{reference() => user_id()}.
-spec monitor_grace_sessions(grace_sessions()) -> #{reference() => {user_id(), binary()}}.
monitor_grace_sessions(Sessions) ->
maps:fold(fun monitor_user_sessions/3, #{}, Sessions).
-spec monitor_user_sessions(user_id(), [pid()], #{reference() => user_id()}) ->
#{reference() => user_id()}.
monitor_user_sessions(UserId, Pids, Monitors) ->
-spec monitor_user_sessions(
user_id(), [{binary(), pid()}], #{reference() => {user_id(), binary()}}
) ->
#{reference() => {user_id(), binary()}}.
monitor_user_sessions(UserId, Held, Monitors) ->
lists:foldl(
fun(Pid, Acc) -> Acc#{erlang:monitor(process, Pid) => UserId} end, Monitors, Pids
fun({Sid, Pid}, Acc) -> Acc#{erlang:monitor(process, Pid) => {UserId, Sid}} end,
Monitors,
Held
).
-spec await_grace_recheck(#{reference() => user_id()}, integer()) ->
#{reference() => user_id()}.
-spec await_grace_recheck(#{reference() => {user_id(), binary()}}, integer()) ->
#{reference() => {user_id(), binary()}}.
await_grace_recheck(Monitors, RecheckAt) ->
Remaining = max(0, RecheckAt - erlang:monotonic_time(millisecond)),
receive
@@ -1119,14 +1202,11 @@ presence_is_offline(UserId, Presences) ->
Presence -> maps:get(<<"status">>, Presence, <<"offline">>) =:= <<"offline">>
end.
-spec grace_hold(map(), #{user_id() => boolean()}) -> grace_hold().
grace_hold(Sessions, SessionEligibility) ->
case suppressed_sessions(Sessions, SessionEligibility) of
Held when map_size(Held) =:= 0 ->
none;
Held ->
{Held, erlang:monotonic_time(millisecond) + grace_recheck_ms()}
end.
-spec grace_hold(grace_sessions()) -> grace_hold().
grace_hold(Held) when map_size(Held) =:= 0 ->
none;
grace_hold(Held) ->
{Held, erlang:monotonic_time(millisecond) + grace_recheck_ms()}.
-spec held_sessions(grace_hold()) -> grace_sessions().
held_sessions(none) -> #{};
@@ -1135,35 +1215,37 @@ held_sessions({Held, _RecheckAt}) -> Held.
-spec suppressed_sessions(map(), #{user_id() => boolean()}) -> grace_sessions().
suppressed_sessions(Sessions, SessionEligibility) ->
maps:fold(
fun(_Sid, Session, Acc) -> maybe_hold_session(Session, SessionEligibility, Acc) end,
fun(Sid, Session, Acc) -> maybe_hold_session(Sid, Session, SessionEligibility, Acc) end,
#{},
Sessions
).
-spec maybe_hold_session(term(), #{user_id() => boolean()}, grace_sessions()) ->
-spec maybe_hold_session(term(), term(), #{user_id() => boolean()}, grace_sessions()) ->
grace_sessions().
maybe_hold_session(Session, SessionEligibility, Acc) when is_map(Session) ->
maybe_hold_session(Sid, Session, SessionEligibility, Acc) when is_map(Session) ->
hold_suppressed_session(
maps:get(user_id, Session, undefined),
maps:get(pid, Session, undefined),
session_pids(Sid, Session),
SessionEligibility,
Acc
);
maybe_hold_session(_Session, _SessionEligibility, Acc) ->
maybe_hold_session(_Sid, _Session, _SessionEligibility, Acc) ->
Acc.
-spec hold_suppressed_session(term(), term(), #{user_id() => boolean()}, grace_sessions()) ->
-spec hold_suppressed_session(
term(), [{binary(), pid()}], #{user_id() => boolean()}, grace_sessions()
) ->
grace_sessions().
hold_suppressed_session(UserId, Pid, SessionEligibility, Acc) when
is_integer(UserId), is_pid(Pid)
hold_suppressed_session(UserId, [_ | _] = Held, SessionEligibility, Acc) when
is_integer(UserId)
->
case maps:get(UserId, SessionEligibility, true) of
false ->
Acc#{UserId => [Pid | maps:get(UserId, Acc, [])]};
Acc#{UserId => Held ++ maps:get(UserId, Acc, [])};
true ->
Acc
end;
hold_suppressed_session(_UserId, _Pid, _SessionEligibility, Acc) ->
hold_suppressed_session(_UserId, _Held, _SessionEligibility, Acc) ->
Acc.
-spec grace_recheck_ms() -> pos_integer().
@@ -1258,7 +1340,7 @@ presence_eligibility_suppresses_active_desktop_test() ->
),
?assertEqual(
#{1 => false},
build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
member_presence => Tab
})
)
@@ -1274,7 +1356,7 @@ presence_eligibility_allows_mobile_only_session_test() ->
),
?assertEqual(
#{1 => true},
build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
member_presence => Tab
})
)
@@ -1293,7 +1375,7 @@ presence_eligibility_allows_idle_and_afk_test() ->
),
?assertEqual(
#{1 => true, 2 => true},
build_push_presence_eligibility(
presence_eligibility(
#{<<"s1">> => #{user_id => 1}, <<"s2">> => #{user_id => 2}},
#{member_presence => Tab}
)
@@ -1307,7 +1389,7 @@ presence_eligibility_allows_unknown_presence_test() ->
try
?assertEqual(
#{7 => true},
build_push_presence_eligibility(#{<<"s1">> => #{user_id => 7}}, #{
presence_eligibility(#{<<"s1">> => #{user_id => 7}}, #{
member_presence => Tab
})
)
@@ -1318,24 +1400,121 @@ presence_eligibility_allows_unknown_presence_test() ->
presence_eligibility_allows_when_presence_table_missing_test() ->
?assertEqual(
#{1 => true},
build_push_presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{})
presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{})
).
presence_eligibility(Sessions, State) ->
{Eligibility, _Hold} = build_push_presence_eligibility(Sessions, State),
Eligibility.
session_push_hold_keeps_an_active_desktop_engaged_beside_a_backgrounded_phone_test() ->
Tab = ets:new(test_member_presence, [set, public]),
Desktop = spawn(fun() -> ok end),
Phone = spawn(fun() -> ok end),
try
ets:insert(
Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => true, <<"afk">> => false}}
),
Sessions = #{
<<"desktop">> => #{user_id => 1, pid => Desktop, push_hold => true},
<<"phone">> => #{user_id => 1, pid => Phone, push_hold => false}
},
{Eligibility, {Held, _RecheckAt}} =
build_push_presence_eligibility(Sessions, #{member_presence => Tab}),
?assertEqual(#{1 => false}, Eligibility),
?assertEqual(#{1 => [{<<"desktop">>, Desktop}]}, Held)
after
ets:delete(Tab)
end.
session_push_hold_engages_a_user_published_as_offline_test() ->
Tab = ets:new(test_member_presence, [set, public]),
try
ets:insert(Tab, {1, #{<<"status">> => <<"offline">>}}),
?assertEqual(
#{1 => false},
presence_eligibility(
#{<<"s1">> => #{user_id => 1, push_hold => true}}, #{member_presence => Tab}
)
)
after
ets:delete(Tab)
end.
session_push_hold_false_overrides_an_active_published_presence_test() ->
Tab = ets:new(test_member_presence, [set, public]),
try
ets:insert(
Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => false, <<"afk">> => false}}
),
?assertEqual(
{#{1 => true}, none},
build_push_presence_eligibility(
#{<<"s1">> => #{user_id => 1, pid => self(), push_hold => false}},
#{member_presence => Tab}
)
)
after
ets:delete(Tab)
end.
released_push_holds_treat_an_old_guild_reply_as_nothing_released_test() ->
GuildPid = spawn(fun() ->
receive
{'$gen_call', From, {released_push_holds, _SessionIds}} ->
gen_server:reply(From, ok)
end
end),
?assertEqual([], released_push_holds(GuildPid, [{1, <<"s1">>}])).
released_push_holds_treat_a_dead_guild_as_nothing_released_test() ->
{GuildPid, MRef} = spawn_monitor(fun() -> ok end),
receive
{'DOWN', MRef, process, GuildPid, _} -> ok
end,
?assertEqual([], released_push_holds(GuildPid, [{1, <<"s1">>}])).
push_states_carry_the_guild_pid_for_the_grace_recheck_test() ->
?assertEqual({self(), self()}, push_state_guild_pids()).
push_states_skip_the_grace_recheck_while_presence_eligibility_is_off_test() ->
ok = application:set_env(fluxer_gateway, push_presence_eligibility, false),
try
?assertEqual({undefined, undefined}, push_state_guild_pids())
after
ok = application:unset_env(fluxer_gateway, push_presence_eligibility)
end.
push_state_guild_pids() ->
Tab = ets:new(push_state_members, []),
try
Compact = compact_push_state(Tab, #{}, 7, #{sessions => #{}}),
Legacy = legacy_push_state(7, #{}),
{maps:get(guild_pid, Compact), maps:get(guild_pid, Legacy)}
after
ets:delete(Tab)
end.
grace_recipients_keep_holding_a_user_already_offline_when_held_test() ->
Presences = #{1 => #{<<"status">> => <<"offline">>}},
?assertEqual([], grace_recipients([1], [1], Presences, [1])),
?assertEqual([1], grace_recipients([1], [], Presences, [1])).
grace_recipients_push_a_user_whose_held_sessions_all_ended_test() ->
Presences = #{1 => #{<<"status">> => <<"online">>}},
?assertEqual([1], grace_recipients([1], [], Presences)).
?assertEqual([1], grace_recipients([1], [], Presences, [])).
grace_recipients_skip_a_user_still_online_on_a_held_session_test() ->
Presences = #{1 => #{<<"status">> => <<"online">>}},
?assertEqual([], grace_recipients([1], [1], Presences)).
?assertEqual([], grace_recipients([1], [1], Presences, [])).
grace_recipients_push_a_user_whose_live_session_went_offline_test() ->
Presences = #{1 => #{<<"status">> => <<"offline">>}},
?assertEqual([1], grace_recipients([1], [1], Presences)).
?assertEqual([1], grace_recipients([1], [1], Presences, [])).
grace_recipients_skip_a_user_with_one_ended_and_one_live_online_session_test() ->
Presences = #{1 => #{<<"status">> => <<"online">>}},
?assertEqual([], grace_recipients([1], [1], Presences)).
?assertEqual([], grace_recipients([1], [1], Presences, [])).
grace_recipients_mix_users_test() ->
Presences = #{
@@ -1343,7 +1522,7 @@ grace_recipients_mix_users_test() ->
2 => #{<<"status">> => <<"online">>},
3 => #{<<"status">> => <<"offline">>}
},
?assertEqual([1, 3], grace_recipients([1, 2, 3], [2, 3, 3], Presences)).
?assertEqual([1, 3], grace_recipients([1, 2, 3], [2, 3, 3], Presences, [])).
deliver_after_grace_pushes_a_user_whose_session_exits_inside_the_window_test() ->
Self = self(),
@@ -1358,10 +1537,10 @@ deliver_after_grace_pushes_a_user_whose_session_exits_inside_the_window_test() -
stop -> ok
end
end),
Held = #{42 => [Session]},
Held = #{42 => [{<<"s1">>, Session}]},
Presences = #{42 => #{<<"status">> => <<"online">>}},
RecheckAt = erlang:monotonic_time(millisecond) + 300,
_ = spawn(fun() -> deliver_after_grace(#{}, Held, RecheckAt, Presences) end),
_ = spawn(fun() -> deliver_after_grace(#{}, Held, RecheckAt, {Presences, undefined}) end),
timer:sleep(50),
Session ! stop,
receive
@@ -23,6 +23,8 @@
refresh_user_session_cache/2,
refresh_all_viewable_channels/1,
handle_set_typing_override/3,
set_session_push_hold/3,
released_push_holds/2,
handle_send_guild_sync/2,
handle_send_members_chunk/3,
build_viewable_channel_map/1
@@ -214,6 +216,25 @@ is_session_active(SessionId, State) ->
handle_set_typing_override(SessionId, TypingFlag, State) ->
guild_sessions_passive:handle_set_typing_override(SessionId, TypingFlag, State).
-spec set_session_push_hold(session_id(), boolean(), guild_state()) -> guild_state().
set_session_push_hold(SessionId, Hold, State) ->
Sessions = maps:get(sessions, State, #{}),
case maps:get(SessionId, Sessions, undefined) of
SessionData when is_map(SessionData) ->
State#{sessions => Sessions#{SessionId => SessionData#{push_hold => Hold}}};
_ ->
State
end.
-spec released_push_holds([session_id()], guild_state()) -> [session_id()].
released_push_holds(SessionIds, State) ->
Sessions = maps:get(sessions, State, #{}),
[
SessionId
|| SessionId <- SessionIds,
maps:get(push_hold, maps:get(SessionId, Sessions, #{}), undefined) =:= false
].
-spec handle_send_guild_sync(session_id(), guild_state()) -> guild_state().
handle_send_guild_sync(SessionId, State) ->
guild_sessions_passive:handle_send_guild_sync(SessionId, State).
+1 -1
View File
@@ -22,7 +22,7 @@
}.
-type sessions() :: #{session_id() => session_entry()}.
-type push_buffer_entry() :: #{
channel_id := integer(), message_id := integer(), params := map()
channel_id := integer(), message_id := integer(), params := map(), buffered_at => integer()
}.
-type state() :: #{
user_id := user_id(),
+100 -9
View File
@@ -19,11 +19,12 @@
-type user_id() :: integer().
-type state() :: map().
-type push_buffer_entry() :: #{
channel_id := integer(), message_id := integer(), params := map()
channel_id := integer(), message_id := integer(), params := map(), buffered_at => integer()
}.
-define(DEFAULT_PUSH_BUFFER_MAX_ENTRIES, 128).
-define(DEFAULT_PUSH_BUFFER_MAX_BYTES, 1048576).
-define(PUSH_READ_MARKS_MAX_CHANNELS, 32).
-define(PUSH_BUFFER_MAX_ENTRIES_CONFIG_KEY, presence_push_buffer_max_entries).
-define(PUSH_BUFFER_MAX_BYTES_CONFIG_KEY, presence_push_buffer_max_bytes).
-define(PUSH_BUFFER_COUNTERS, presence_push_buffer_counters).
@@ -133,9 +134,27 @@ handle_message_create_event(Data, State) ->
UserId = maps:get(user_id, State),
case build_push_create_params(UserId, Data) of
undefined -> State;
Params -> route_push_notification(Params, State)
#{author_id := UserId} -> ack_own_message(Data, State);
Params -> route_unread_push_notification(Data, Params, State)
end.
-spec route_unread_push_notification(map(), map(), state()) -> state().
route_unread_push_notification(Data, Params, State) ->
ChannelId = extract_snowflake(<<"channel_id">>, Data),
MessageId = extract_snowflake(<<"id">>, Data),
ReadMarks = maps:get(push_read_marks, State, #{}),
Read = is_integer(MessageId) andalso MessageId =< maps:get(ChannelId, ReadMarks, 0),
case Read orelse push_message_params:suppresses_notifications(Data) of
true -> State;
false -> route_push_notification(Params, State)
end.
-spec ack_own_message(map(), state()) -> state().
ack_own_message(Data, State) ->
ChannelId = extract_snowflake(<<"channel_id">>, Data),
MessageId = extract_snowflake(<<"id">>, Data),
maybe_ack_push_buffer(ChannelId, MessageId, State).
-spec handle_message_ack_event(map(), state()) -> state().
handle_message_ack_event(Data, State) ->
ChannelId = extract_snowflake(<<"channel_id">>, Data),
@@ -146,11 +165,10 @@ handle_message_ack_event(Data, State) ->
flush_push_buffer(#{push_buffer := []} = State) ->
State;
flush_push_buffer(#{push_buffer := Buffer} = State) ->
Entries = lists:reverse(Buffer),
lists:foreach(
fun(Entry) -> push:handle_message_create(maps:get(params, Entry)) end,
Entries
),
ok = push:handle_buffered_message_creates([
(maps:get(params, Entry))#{buffered_at => maps:get(buffered_at, Entry, undefined)}
|| Entry <- lists:reverse(Buffer)
]),
State#{push_buffer := []}.
-spec maybe_update_push_eligibility(state()) -> state().
@@ -362,10 +380,23 @@ maybe_ack_push_buffer(_, _, State) ->
ack_push_buffer(ChannelId, MessageId, State) when ChannelId > 0, MessageId > 0 ->
Buffer = maps:get(push_buffer, State, []),
FilteredBuffer = [E || E <- Buffer, not should_drop_buffer_entry(E, ChannelId, MessageId)],
State#{push_buffer := FilteredBuffer};
record_read_mark(ChannelId, MessageId, State#{push_buffer := FilteredBuffer});
ack_push_buffer(_, _, State) ->
State.
-spec record_read_mark(integer(), integer(), state()) -> state().
record_read_mark(ChannelId, MessageId, State) ->
ReadMarks = maps:get(push_read_marks, State, #{}),
ReadMark = max(MessageId, maps:get(ChannelId, ReadMarks, 0)),
State#{push_read_marks => cap_read_marks(ReadMarks#{ChannelId => ReadMark})}.
-spec cap_read_marks(#{integer() => integer()}) -> #{integer() => integer()}.
cap_read_marks(ReadMarks) when map_size(ReadMarks) > ?PUSH_READ_MARKS_MAX_CHANNELS ->
{_, OldestChannelId} = lists:min([{Mark, Id} || {Id, Mark} <- maps:to_list(ReadMarks)]),
maps:remove(OldestChannelId, ReadMarks);
cap_read_marks(ReadMarks) ->
ReadMarks.
-spec should_drop_buffer_entry(push_buffer_entry(), integer(), integer()) -> boolean().
should_drop_buffer_entry(Entry, ChannelId, MessageId) ->
maps:get(channel_id, Entry) =:= ChannelId andalso
@@ -383,7 +414,12 @@ make_push_buffer_entry(Params) ->
build_buffer_entry(ChannelId, MessageId, Params) when
is_integer(ChannelId), is_integer(MessageId)
->
#{channel_id => ChannelId, message_id => MessageId, params => Params};
#{
channel_id => ChannelId,
message_id => MessageId,
params => Params,
buffered_at => erlang:system_time(millisecond)
};
build_buffer_entry(_, _, _) ->
undefined.
@@ -512,6 +548,61 @@ buffer_push_notification_caps_bytes_test() ->
end
).
private_message(MessageId, ChannelType, Flags) ->
#{
<<"id">> => integer_to_binary(MessageId),
<<"channel_id">> => <<"5">>,
<<"channel_type">> => ChannelType,
<<"flags">> => Flags,
<<"author">> => #{<<"id">> => <<"20">>}
}.
pushed_private_message_ids(Sessions, Messages) ->
Self = self(),
ok = meck:new(push, [passthrough, no_link]),
try
ok = meck:expect(push, handle_message_create, fun(Params) ->
Self ! {pushed, maps:get(<<"id">>, maps:get(message_data, Params))},
ok
end),
State = lists:foldl(
fun(Message, Acc) -> handle_message_create_event(Message, Acc) end,
#{user_id => 10, sessions => Sessions, push_buffer => []},
Messages
),
Buffered = [maps:get(message_id, Entry) || Entry <- maps:get(push_buffer, State)],
{pushed_ids(), lists:sort(Buffered)}
after
meck:unload(push)
end.
pushed_ids() ->
receive
{pushed, Id} -> [binary_to_integer(Id) | pushed_ids()]
after 0 -> []
end.
silent_dms_and_group_dms_are_not_pushed_test() ->
Messages = [
private_message(1, 1, 4096),
private_message(2, 3, 4096 bor 4),
private_message(3, 1, 0),
private_message(4, 3, 4)
],
?assertEqual({[3, 4], []}, pushed_private_message_ids(#{}, Messages)).
silent_dms_and_group_dms_are_not_buffered_behind_an_active_desktop_test() ->
Desktop = #{<<"desktop">> => #{status => online, afk => false, mobile => false}},
Messages = [
private_message(1, 1, 4096),
private_message(2, 3, 4096),
private_message(3, 1, 0),
private_message(4, 3, 0)
],
with_gateway_config(#{}, fun() ->
?assertEqual({[], [3, 4]}, pushed_private_message_ids(Desktop, Messages))
end).
push_params(ChannelId, MessageId) ->
#{
message_data => #{
+372 -2
View File
@@ -8,6 +8,7 @@
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([
handle_message_create/1,
handle_buffered_message_creates/1,
sync_user_guild_settings/3,
sync_user_guild_settings_local/3,
sync_user_blocked_ids/2,
@@ -15,7 +16,8 @@
invalidate_user_subscriptions_local/1,
invalidate_user_badge_count_local/1,
invalidate_user_badge_counts_local/1,
clear_channel_notifications/3
clear_channel_notifications/3,
clear_notifications_enabled/0
]).
-export([get_cache_stats/0]).
-export([push_owner_key/1]).
@@ -29,7 +31,14 @@
-define(CNT_FETCH_FAILURES, push_blocked_ids_fetch_failures).
-define(CNT_SUPPRESSED, push_blocked_ids_suppressed).
-define(CNT_BUDGET_EXHAUSTED, push_blocked_ids_budget_exhausted).
-define(CNT_READ_STATE_SUPPRESSED, push_read_state_suppressed).
-define(CNT_READ_STATE_FAILURES, push_read_state_fetch_failures).
-define(CNT_READ_STATE_SKIPPED, push_read_state_fetch_skipped).
-define(MAX_FETCH_RPCS, 8).
-define(READ_STATE_FETCH_TIMEOUT_MS, 2000).
-define(READ_STATE_FETCH_MAX_POOL_PCT, 50).
-define(READ_STATE_FETCH_SLOTS, read_state_fetches_in_flight).
-define(MAX_READ_STATE_FETCHES, 64).
-define(DEFAULT_FETCH_USERS, 2000).
-define(MAX_FETCH_USERS, 5000).
-define(DEFAULT_FETCH_CHUNK, 500).
@@ -39,6 +48,8 @@
-type state() :: #{
max_entries := non_neg_integer()
}.
-type read_key() :: {integer(), integer(), integer()}.
-type read_marks() :: #{{integer(), integer()} => non_neg_integer()}.
-spec start_link() -> {ok, pid()} | {error, term()} | ignore.
start_link() ->
@@ -115,6 +126,17 @@ handle_message_create(Params) ->
false -> ok
end.
-spec handle_buffered_message_creates([map()]) -> ok.
handle_buffered_message_creates(ParamsList) ->
case is_push_active() of
true ->
{Stale, Fresh} = split_stale_buffered(ParamsList),
lists:foreach(fun handle_message_create/1, Fresh),
spawn_read_state_check(Stale);
false ->
ok
end.
-spec sync_user_guild_settings(integer(), integer(), map()) -> ok.
sync_user_guild_settings(UserId, GuildId, Settings) ->
maybe_cast(UserId, {sync_user_guild_settings, UserId, GuildId, Settings}).
@@ -254,6 +276,149 @@ do_handle_message_create(Params) ->
ok
end.
-spec split_stale_buffered([map()]) -> {[{read_key(), map()}], [map()]}.
split_stale_buffered(ParamsList) ->
lists:foldr(
fun(Params, {Stale, Fresh}) ->
Unbuffered = maps:remove(buffered_at, Params),
case stale_read_key(Params) of
{ok, Key} -> {[{Key, Unbuffered} | Stale], Fresh};
none -> {Stale, [Unbuffered | Fresh]}
end
end,
{[], []},
ParamsList
).
-spec stale_read_key(map()) -> {ok, read_key()} | none.
stale_read_key(#{buffered_at := BufferedAt} = Params) when is_integer(BufferedAt) ->
case
erlang:system_time(millisecond) - BufferedAt >= push_outbox:max_age_ms() andalso
push_message_params:context(Params)
of
{ok, #{user_ids := [UserId], channel_id := ChannelId, message_id := MessageId}} ->
{ok, {UserId, ChannelId, MessageId}};
_ ->
none
end;
stale_read_key(_Params) ->
none.
-spec spawn_read_state_check([{read_key(), map()}]) -> ok.
spawn_read_state_check([]) ->
ok;
spawn_read_state_check(Stale) ->
Channels = lists:usort([{UserId, ChannelId} || {{UserId, ChannelId, _}, _Params} <- Stale]),
case read_state_fetch_allowed(length(Channels)) of
true ->
_ = spawn(fun() -> publish_unread(Channels, Stale) end),
ok;
false ->
lists:foreach(fun(Entry) -> publish_unless_read(#{}, Entry) end, Stale)
end.
-spec publish_unread([{integer(), integer()}], [{read_key(), map()}]) -> ok.
publish_unread(Channels, Stale) ->
try
ReadMarks =
try
fetch_each_read_mark(Channels, #{})
catch
_:_ -> #{}
end,
lists:foreach(fun(Entry) -> publish_unless_read(ReadMarks, Entry) end, Stale)
after
release_read_state_fetch_slot()
end.
-spec publish_unless_read(read_marks(), {read_key(), map()}) -> ok.
publish_unless_read(ReadMarks, {{UserId, ChannelId, MessageId}, Params}) ->
case MessageId =< maps:get({UserId, ChannelId}, ReadMarks, 0) of
true -> bump_counter(?CNT_READ_STATE_SUPPRESSED);
false -> handle_message_create(Params)
end.
-spec read_state_fetch_allowed(non_neg_integer()) -> boolean().
read_state_fetch_allowed(Fetches) ->
case
push_worker_pool:utilization_pct() < ?READ_STATE_FETCH_MAX_POOL_PCT andalso
acquire_read_state_fetch_slot()
of
true ->
true;
false ->
bump_counter(?CNT_READ_STATE_SKIPPED, Fetches),
false
end.
-spec acquire_read_state_fetch_slot() -> boolean().
acquire_read_state_fetch_slot() ->
try
ets:update_counter(
?PUSH_COUNTER_TABLE, ?READ_STATE_FETCH_SLOTS, {2, 1}, {?READ_STATE_FETCH_SLOTS, 0}
)
of
InFlight when InFlight > ?MAX_READ_STATE_FETCHES ->
release_read_state_fetch_slot(),
false;
_InFlight ->
true
catch
error:badarg -> false
end.
-spec release_read_state_fetch_slot() -> ok.
release_read_state_fetch_slot() ->
try ets:update_counter(?PUSH_COUNTER_TABLE, ?READ_STATE_FETCH_SLOTS, {2, -1, 0, 0}) of
_InFlight -> ok
catch
error:badarg -> ok
end.
-spec fetch_each_read_mark([{integer(), integer()}], read_marks()) -> read_marks().
fetch_each_read_mark([], ReadMarks) ->
ReadMarks;
fetch_each_read_mark([{UserId, ChannelId} = Key | Rest], ReadMarks) ->
case fetch_read_message_id(UserId, ChannelId) of
{ok, ReadMessageId} ->
fetch_each_read_mark(Rest, ReadMarks#{Key => ReadMessageId});
error ->
bump_counter(?CNT_READ_STATE_SKIPPED, length(Rest)),
ReadMarks
end.
-spec fetch_read_message_id(integer(), integer()) -> {ok, non_neg_integer()} | error.
fetch_read_message_id(UserId, ChannelId) ->
Request = #{
<<"type">> => <<"get_read_state">>,
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId)
},
case read_state_call(Request) of
{ok, #{<<"last_message_id">> := LastMessageId}} ->
{ok, read_message_id(snowflake_id:parse_maybe(LastMessageId))};
{ok, _Data} ->
{ok, 0};
{error, Reason} ->
bump_counter(?CNT_READ_STATE_FAILURES),
logger:debug("Push: read state fetch failed", #{
reason => Reason, user_id => UserId, channel_id => ChannelId
}),
error
end.
-spec read_state_call(map()) -> {ok, term()} | {error, term()}.
read_state_call(Request) ->
try
rpc_client:call(Request, ?READ_STATE_FETCH_TIMEOUT_MS)
catch
Class:Reason -> {error, {Class, Reason}}
end.
-spec read_message_id(integer() | undefined) -> non_neg_integer().
read_message_id(MessageId) when is_integer(MessageId), MessageId > 0 -> MessageId;
read_message_id(_MessageId) -> 0.
-spec do_handle_message_create_context(push_message_params:context()) -> ok.
do_handle_message_create_context(Context) ->
#{
@@ -625,7 +790,11 @@ cache_stats_with_counters() ->
push_loss_counters() ->
#{
counters => counter_table_status(),
worker_pool_dropped => read_counter(?CNT_WORKER_POOL)
worker_pool_dropped => read_counter(?CNT_WORKER_POOL),
read_state_suppressed => read_counter(?CNT_READ_STATE_SUPPRESSED),
read_state_fetch_failures => read_counter(?CNT_READ_STATE_FAILURES),
read_state_fetch_skipped => read_counter(?CNT_READ_STATE_SKIPPED),
read_state_fetches_in_flight => read_counter(?READ_STATE_FETCH_SLOTS)
}.
-spec counter_table_status() -> live | unavailable.
@@ -826,6 +995,207 @@ filter_eligible_users_fetches_large_metadata_once_test() ->
)
end.
a_dm_buffered_past_the_outbox_window_and_read_since_is_not_published_test() ->
Suppressed = read_counter_value(?CNT_READ_STATE_SUPPRESSED),
Published = with_buffered_dms(
[{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"456">>})
),
?assertEqual({0, 1}, Published),
?assertEqual(Suppressed + 1, read_counter_value(?CNT_READ_STATE_SUPPRESSED)).
a_dm_buffered_past_the_outbox_window_and_still_unread_is_published_test() ->
Published = with_buffered_dms(
[{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"455">>})
),
?assertEqual({1, 1}, Published).
a_dm_buffered_past_the_outbox_window_without_a_read_state_is_published_test() ->
Published = with_buffered_dms(
[{stale_buffered_at(), 123, 456}], fun(_ChannelId) ->
{ok, #{<<"last_message_id">> => null}}
end
),
?assertEqual({1, 1}, Published).
a_dm_buffered_past_the_outbox_window_is_published_when_the_read_state_fetch_fails_test() ->
Published = with_buffered_dms(
[{stale_buffered_at(), 123, 456}], fun(_ChannelId) -> {error, timeout} end
),
?assertEqual({1, 1}, Published).
a_dm_buffered_within_the_outbox_window_is_published_without_a_read_state_fetch_test() ->
Published = with_buffered_dms(
[{erlang:system_time(millisecond), 123, 456}], fun(_ChannelId) ->
{error, unexpected}
end
),
?assertEqual({1, 0}, Published).
a_buffered_dm_without_a_buffer_time_is_published_without_a_read_state_fetch_test() ->
Published = with_buffered_dms(
[{undefined, 123, 456}], fun(_ChannelId) -> {error, unexpected} end
),
?assertEqual({1, 0}, Published).
a_dm_buffered_past_the_outbox_window_is_published_without_a_fetch_when_the_pool_is_busy_test() ->
Skipped = read_counter_value(?CNT_READ_STATE_SKIPPED),
ok = meck:new(push_worker_pool, [passthrough, no_link]),
try
ok = meck:expect(push_worker_pool, utilization_pct, fun() ->
?READ_STATE_FETCH_MAX_POOL_PCT
end),
Published = with_buffered_dms(
[{stale_buffered_at(), 123, 456}], read_through(#{123 => <<"456">>})
),
?assertEqual({1, 0}, Published)
after
meck:unload(push_worker_pool)
end,
?assertEqual(Skipped + 1, read_counter_value(?CNT_READ_STATE_SKIPPED)).
a_full_buffer_of_read_dms_is_not_published_by_its_own_flush_test() ->
Entries = [{stale_buffered_at(), 123, 1000 + N} || N <- lists:seq(1, 128)],
Published = with_buffered_dms(Entries, fun(123) ->
timer:sleep(50),
{ok, #{<<"last_message_id">> => <<"999999">>}}
end),
?assertEqual({0, 1}, Published).
a_flush_fetches_each_channel_read_state_once_test() ->
Entries = [
{stale_buffered_at(), 123, 456},
{stale_buffered_at(), 124, 460},
{stale_buffered_at(), 123, 470}
],
Published = with_buffered_dms(Entries, read_through(#{123 => <<"456">>, 124 => <<"459">>})),
?assertEqual({2, 2}, Published).
a_failed_read_state_fetch_publishes_the_rest_of_the_flush_without_fetching_test() ->
Entries = [
{stale_buffered_at(), 123, 456},
{stale_buffered_at(), 124, 460},
{stale_buffered_at(), 125, 470}
],
Skipped = read_counter_value(?CNT_READ_STATE_SKIPPED),
Published = with_buffered_dms(Entries, fun(_ChannelId) -> {error, timeout} end),
?assertEqual({3, 1}, Published),
?assertEqual(Skipped + 2, read_counter_value(?CNT_READ_STATE_SKIPPED)).
a_flush_publishes_fresh_and_unread_stale_dms_and_drops_read_ones_test() ->
Stale = stale_buffered_at(),
Params = [
buffered_dm_params(erlang:system_time(millisecond), 123, 500),
buffered_dm_params(Stale, 124, 456),
buffered_dm_params(Stale, 125, 470)
],
{Published, Requests} = with_buffered_dm_push(
read_through(#{124 => <<"456">>, 125 => <<"469">>}),
fun() ->
ok = handle_buffered_message_creates(Params),
lists:sort(published_message_ids(1000))
end
),
?assertEqual([470, 500], Published),
?assertEqual(2, Requests).
stale_buffered_at() ->
erlang:system_time(millisecond) - push_outbox:max_age_ms() - 1000.
read_through(ReadMessageIds) ->
fun(ChannelId) -> {ok, #{<<"last_message_id">> => maps:get(ChannelId, ReadMessageIds)}} end.
read_counter_value(Key) ->
ok = push_worker_pool:init_counter(),
case read_counter(Key) of
Value when is_integer(Value) -> Value;
unavailable -> 0
end.
with_buffered_dms(Entries, ReadStateReply) ->
with_buffered_dm_push(ReadStateReply, fun() ->
{Stale, Fresh} = split_stale_buffered([
buffered_dm_params(BufferedAt, ChannelId, MessageId)
|| {BufferedAt, ChannelId, MessageId} <- Entries
]),
lists:foreach(fun handle_message_create/1, Fresh),
ok = spawn_read_state_check(Stale),
ok = wait_for_read_state_fetches(100),
length(published_message_ids(0))
end).
wait_for_read_state_fetches(0) ->
erlang:error(read_state_fetch_still_in_flight);
wait_for_read_state_fetches(Attempts) ->
case read_counter(?READ_STATE_FETCH_SLOTS) of
InFlight when is_integer(InFlight), InFlight > 0 ->
timer:sleep(20),
wait_for_read_state_fetches(Attempts - 1);
_ ->
ok
end.
with_buffered_dm_push(ReadStateReply, Fun) ->
ok = push_worker_pool:init_counter(),
Self = self(),
Modules = [fluxer_gateway_env, gateway_node_router, rpc_client],
lists:foreach(fun(Module) -> ok = meck:new(Module, [passthrough, no_link]) end, Modules),
try
ok = meck:expect(fluxer_gateway_env, get, fun
(push_enabled) -> true;
(Key) -> meck:passthrough([Key])
end),
ok = meck:expect(gateway_node_router, owner_node_result, fun(_Key, push) ->
{ok, node()}
end),
ok = meck:expect(rpc_client, call, fun(Request, ?READ_STATE_FETCH_TIMEOUT_MS) ->
Self ! {read_state_request, Request},
ReadStateReply(binary_to_integer(maps:get(<<"channel_id">>, Request)))
end),
Published = with_registered_push(Fun),
Requests = meck:num_calls(rpc_client, call, ['_', '_']),
assert_read_state_requests(Requests),
{Published, Requests}
after
lists:foreach(fun meck:unload/1, Modules)
end.
published_message_ids(Timeout) ->
receive
{'$gen_cast', {handle_message_create, Params}} ->
?assertNot(maps:is_key(buffered_at, Params)),
MessageData = maps:get(message_data, Params),
[
binary_to_integer(maps:get(<<"id">>, MessageData))
| published_message_ids(Timeout)
]
after Timeout -> []
end.
buffered_dm_params(BufferedAt, ChannelId, MessageId) ->
#{
message_data => #{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"id">> => integer_to_binary(MessageId),
<<"channel_type">> => 1
},
user_ids => [71],
guild_id => 0,
author_id => 7,
buffered_at => BufferedAt
}.
assert_read_state_requests(0) ->
ok;
assert_read_state_requests(Count) ->
receive
{read_state_request, Request} ->
?assertMatch(
#{<<"type">> := <<"get_read_state">>, <<"user_id">> := <<"71">>}, Request
),
assert_read_state_requests(Count - 1)
after 0 -> erlang:error(no_read_state_request)
end.
blocked_ids_fetch_defaults_are_bounded_test() ->
?assertEqual(?DEFAULT_FETCH_USERS, blocked_ids_fetch_max_users()),
?assertEqual(?DEFAULT_FETCH_CHUNK, blocked_ids_fetch_chunk()).
+111 -13
View File
@@ -92,17 +92,6 @@ is_user_blocked(UserId, AuthorId) ->
-spec check_user_guild_settings(
integer(), integer(), integer(), map(), integer(), map(), map(), map() | undefined
) -> boolean().
check_user_guild_settings(
_UserId,
0,
_ChannelId,
_MessageData,
_GuildDefaultNotifications,
_UserRolesMap,
_ConnectedUsers,
_LargeGuildMetadata
) ->
true;
check_user_guild_settings(
UserId,
GuildId,
@@ -114,8 +103,7 @@ check_user_guild_settings(
LargeGuildMetadata
) ->
Settings = fetch_settings(UserId, GuildId),
MobilePush = get_boolean_setting(mobile_push, Settings, true),
case MobilePush of
case mobile_push_allowed(GuildId, Settings) of
false ->
false;
true ->
@@ -132,6 +120,12 @@ check_user_guild_settings(
)
end.
-spec mobile_push_allowed(integer(), map()) -> boolean().
mobile_push_allowed(0, _Settings) ->
true;
mobile_push_allowed(_GuildId, Settings) ->
get_boolean_setting(mobile_push, Settings, true).
-spec fetch_settings(integer(), integer()) -> map().
fetch_settings(UserId, GuildId) ->
case push_ets_cache:get_user_guild_settings(UserId, GuildId) of
@@ -446,6 +440,110 @@ mention_here_requires_connected_user_test() ->
?assertEqual(false, is_user_mentioned(123, MessageData, #{}, #{}, #{})),
?assertEqual(true, is_user_mentioned(123, MessageData, #{}, #{}, #{123 => true})).
-define(DM_USER, 920001).
-define(DM_AUTHOR, 920002).
-define(DM_CHANNEL, 920005).
dm_eligible(ChannelType, PrivateSettings) ->
push_ets_cache:init(),
ok = push_ets_cache:put_user_guild_settings(?DM_USER, 0, PrivateSettings),
try
is_eligible_for_push(
?DM_USER,
?DM_AUTHOR,
0,
?DM_CHANNEL,
#{<<"channel_type">> => ChannelType},
0,
#{},
#{},
undefined
)
after
push_ets_cache:delete_user_guild_settings(?DM_USER, 0)
end.
dm_override(Override) ->
#{<<"channel_overrides">> => #{integer_to_binary(?DM_CHANNEL) => Override}}.
mute_ending_in(OffsetMs) ->
Ms = erlang:system_time(millisecond) + OffsetMs,
#{
<<"end_time">> => list_to_binary(
calendar:system_time_to_rfc3339(Ms, [{unit, millisecond}, {offset, "Z"}])
)
}.
a_muted_dm_is_not_pushed_test() ->
Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => null}),
?assertEqual(false, dm_eligible(1, Muted)).
a_muted_group_dm_is_not_pushed_test() ->
Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => mute_ending_in(60000)}),
?assertEqual(false, dm_eligible(3, Muted)).
a_dm_whose_mute_expired_is_pushed_test() ->
Expired = dm_override(#{<<"muted">> => true, <<"mute_config">> => mute_ending_in(-60000)}),
?assertEqual(true, dm_eligible(1, Expired)),
?assertEqual(true, dm_eligible(3, Expired)).
an_unmuted_dm_is_pushed_test() ->
?assertEqual(true, dm_eligible(1, #{})),
?assertEqual(true, dm_eligible(3, #{})),
Other = #{<<"channel_overrides">> => #{<<"1">> => #{<<"muted">> => true}}},
?assertEqual(true, dm_eligible(1, Other)).
a_dm_set_to_no_messages_is_not_pushed_test() ->
?assertEqual(false, dm_eligible(1, dm_override(#{<<"message_notifications">> => 2}))),
?assertEqual(false, dm_eligible(3, #{<<"message_notifications">> => 2})).
a_dm_set_to_only_mentions_is_still_pushed_test() ->
?assertEqual(true, dm_eligible(1, dm_override(#{<<"message_notifications">> => 1}))),
?assertEqual(true, dm_eligible(3, #{<<"message_notifications">> => 1})).
all_dms_muted_are_not_pushed_test() ->
?assertEqual(false, dm_eligible(1, #{<<"muted">> => true})).
dm_pushes_ignore_the_private_mobile_push_setting_test() ->
?assertEqual(true, dm_eligible(1, #{<<"mobile_push">> => false})).
dm_settings_are_fetched_once_for_the_private_scope_and_cached_test() ->
push_ets_cache:init(),
push_ets_cache:delete_user_guild_settings(?DM_USER, 0),
Self = self(),
Muted = dm_override(#{<<"muted">> => true, <<"mute_config">> => null}),
ok = meck:new(rpc_client, [passthrough, no_link]),
try
ok = meck:expect(rpc_client, call, fun(Request) ->
Self ! {settings_request, Request},
{ok, #{<<"user_guild_settings">> => [Muted]}}
end),
MessageData = #{<<"channel_type">> => 1},
Check = fun() ->
is_eligible_for_push(
?DM_USER, ?DM_AUTHOR, 0, ?DM_CHANNEL, MessageData, 0, #{}, #{}, undefined
)
end,
?assertEqual(false, Check()),
?assertEqual(false, Check()),
?assertEqual(1, meck:num_calls(rpc_client, call, '_')),
receive
{settings_request, Request} ->
?assertMatch(
#{
<<"type">> := <<"get_user_guild_settings">>,
<<"guild_id">> := <<"0">>,
<<"user_ids">> := [<<"920001">>]
},
Request
)
after 0 -> erlang:error(no_settings_request)
end
after
meck:unload(rpc_client),
push_ets_cache:delete_user_guild_settings(?DM_USER, 0)
end.
mention_here_respects_suppress_everyone_test() ->
MessageData = #{<<"mention_everyone">> => true, <<"mention_here">> => true},
Settings = #{suppress_everyone => true},
+27 -9
View File
@@ -3,7 +3,7 @@
-module(push_job_publisher).
-typing([eqwalizer]).
-export([publish_message/8, publish_clear/3]).
-export([publish_message/8, publish_clear/3, clear_job/4]).
-export([publish_ring/6, request/3]).
-define(SUBJECT_MESSAGE, <<"push.job.message">>).
@@ -85,19 +85,37 @@ first_error({error, Reason}, _Second) ->
-spec publish_clear(integer(), integer(), integer()) -> ok | {error, term()}.
publish_clear(UserId, ChannelId, MessageId) ->
Job = #{
publish(
?SUBJECT_CLEAR,
clear_fields(UserId, ChannelId, MessageId),
clear_meta(UserId, ChannelId, MessageId)
).
-spec clear_job(integer(), integer(), integer(), integer()) -> push_outbox:job().
clear_job(UserId, ChannelId, MessageId, AfterMessageId) ->
Job = (clear_fields(UserId, ChannelId, MessageId))#{
<<"after_message_id">> => integer_to_binary(AfterMessageId)
},
outbox_job(
?SUBJECT_CLEAR,
Job,
iolist_to_binary(json:encode(Job)),
clear_meta(UserId, ChannelId, MessageId)
).
-spec clear_fields(integer(), integer(), integer()) -> map().
clear_fields(UserId, ChannelId, MessageId) ->
#{
<<"v">> => ?JOB_VERSION,
<<"config_version">> => ?LEGACY_CONFIG_VERSION,
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId)
},
publish(?SUBJECT_CLEAR, Job, #{
kind => clear,
user_ids => [UserId],
channel_id => ChannelId,
message_id => MessageId
}).
}.
-spec clear_meta(integer(), integer(), integer()) -> meta().
clear_meta(UserId, ChannelId, MessageId) ->
#{kind => clear, user_ids => [UserId], channel_id => ChannelId, message_id => MessageId}.
-spec publish_ring(integer(), integer(), integer(), integer(), integer(), map()) ->
ok | {error, term()}.
@@ -3,7 +3,7 @@
-module(push_message_params).
-typing([eqwalizer]).
-export([context/1, owner_key/1]).
-export([context/1, owner_key/1, suppresses_notifications/1]).
-export_type([context/0]).
@@ -11,6 +11,7 @@
-define(MESSAGE_TYPE_DEFAULT, 0).
-define(MESSAGE_TYPE_REPLY, 19).
-define(PUSHABLE_MESSAGE_TYPES, [?MESSAGE_TYPE_DEFAULT, ?MESSAGE_TYPE_REPLY]).
-define(MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS, 4096).
-type context() :: #{
message_data := map(),
@@ -118,10 +119,23 @@ validate(Context) ->
-spec validate_message_type(integer(), context()) -> {ok, context()} | {error, term()}.
validate_message_type(Type, Context) ->
case lists:member(Type, ?PUSHABLE_MESSAGE_TYPES) of
true -> {ok, Context};
true -> validate_notifications(Context);
false -> {error, {unpushable_message_type, Type}}
end.
-spec validate_notifications(context()) -> {ok, context()} | {error, term()}.
validate_notifications(#{message_data := MessageData} = Context) ->
case suppresses_notifications(MessageData) of
true -> {error, suppressed_notifications};
false -> {ok, Context}
end.
-spec suppresses_notifications(map()) -> boolean().
suppresses_notifications(#{<<"flags">> := Flags}) when is_integer(Flags) ->
Flags band ?MESSAGE_FLAG_SUPPRESS_NOTIFICATIONS =/= 0;
suppresses_notifications(_MessageData) ->
false.
-spec message_type(context()) -> integer().
message_type(#{message_data := MessageData}) ->
normalize_message_type(maps:get(<<"type">>, MessageData, ?MESSAGE_TYPE_DEFAULT)).
@@ -230,4 +244,37 @@ context_treats_a_missing_message_type_as_pushable_test() ->
context_treats_a_malformed_message_type_as_pushable_test() ->
?assertMatch({ok, _}, context(params_with_message_type(<<"nonsense">>))).
params_with_flags(Flags, ChannelType, GuildId) ->
Params = params_with_message_type(?MESSAGE_TYPE_DEFAULT),
MessageData = maps:get(message_data, Params),
Params#{
message_data := MessageData#{<<"flags">> => Flags, <<"channel_type">> => ChannelType},
guild_id := GuildId
}.
context_rejects_a_silent_dm_test() ->
?assertEqual(
{error, suppressed_notifications}, context(params_with_flags(4096, 1, 0))
).
context_rejects_a_silent_group_dm_test() ->
?assertEqual(
{error, suppressed_notifications}, context(params_with_flags(4096, 3, 0))
).
context_rejects_a_silent_guild_message_that_mentions_the_recipient_test() ->
Params = params_with_flags(4096 bor 4, 0, <<"1472200708085309475">>),
MessageData = maps:get(message_data, Params),
Mentioned = MessageData#{
<<"mentions">> => [#{<<"id">> => <<"1474262819227156566">>}],
<<"mention_everyone">> => true
},
?assertEqual(
{error, suppressed_notifications}, context(Params#{message_data := Mentioned})
).
context_allows_a_message_with_other_flags_test() ->
?assertMatch({ok, _}, context(params_with_flags(4 bor 8192, 1, 0))),
?assertMatch({ok, _}, context(params_with_flags(0, 3, 0))).
-endif.
+105 -4
View File
@@ -11,7 +11,8 @@
note_session_active/1,
record_dropped/2,
stats/0,
request_timeout_ms/0
request_timeout_ms/0,
max_age_ms/0
]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export_type([job/0, kind/0]).
@@ -54,11 +55,13 @@
| sheds
| truncations
| skipped_active
| followup_clears
| enqueued.
-type state() :: #{
jobs := gb_trees:tree(non_neg_integer(), entry()),
ready := queue:queue(non_neg_integer()),
inflight := #{pid() => {reference(), reference(), entry()}},
followups => #{pid() => #{integer() => integer()}},
next_seq := non_neg_integer(),
reads := #{{integer(), integer()} => {integer(), integer()}},
active := #{integer() => {non_neg_integer(), integer()}},
@@ -108,6 +111,10 @@ stats() ->
request_timeout_ms() ->
env_pos_integer(push_outbox_request_timeout_ms, ?DEFAULT_REQUEST_TIMEOUT_MS).
-spec max_age_ms() -> pos_integer().
max_age_ms() ->
env_pos_integer(push_outbox_max_age_ms, ?DEFAULT_MAX_AGE_MS).
-spec init([]) -> {ok, state()}.
init([]) ->
erlang:process_flag(fullsweep_after, 10),
@@ -116,6 +123,7 @@ init([]) ->
jobs => gb_trees:empty(),
ready => queue:new(),
inflight => #{},
followups => #{},
next_seq => 0,
reads => #{},
active => #{},
@@ -124,7 +132,7 @@ init([]) ->
max_queue => env_pos_integer(push_outbox_max_queue, ?DEFAULT_MAX_QUEUE),
max_inflight => env_pos_integer(push_outbox_max_inflight, ?DEFAULT_MAX_INFLIGHT),
request_timeout_ms => request_timeout_ms(),
max_age_ms => env_pos_integer(push_outbox_max_age_ms, ?DEFAULT_MAX_AGE_MS),
max_age_ms => max_age_ms(),
retry_base_ms => app_pos_integer(push_outbox_retry_base_ms, ?DEFAULT_RETRY_BASE_MS)
}}.
@@ -298,14 +306,53 @@ apply_read(UserId, ChannelId, MessageId, #{reads := Reads, jobs := Jobs} = State
error -> MessageId
end,
State1 = State#{reads := Reads#{Key => {Watermark, now_ms()}}},
lists:foldl(
State2 = lists:foldl(
fun({Seq, Entry}, Acc) ->
truncate_entry(Seq, Entry, UserId, ChannelId, MessageId, Acc)
end,
State1,
gb_trees:to_list(Jobs)
),
note_inflight_read(UserId, ChannelId, MessageId, State2).
-spec note_inflight_read(integer(), integer(), integer(), state()) -> state().
note_inflight_read(UserId, ChannelId, MessageId, #{inflight := Inflight} = State) ->
maps:fold(
fun(Pid, {_MRef, _TRef, Entry}, Acc) ->
note_followup(Pid, Entry, UserId, ChannelId, MessageId, Acc)
end,
State,
Inflight
).
-spec note_followup(pid(), entry(), integer(), integer(), integer(), state()) -> state().
note_followup(
Pid,
#{
kind := message,
channel_id := ChannelId,
message_id := JobMessageId,
user_ids := UserIds
},
UserId,
ChannelId,
MessageId,
State
) when JobMessageId =< MessageId ->
case lists:member(UserId, UserIds) of
false -> State;
true -> record_followup(Pid, UserId, MessageId, State)
end;
note_followup(_Pid, _Entry, _UserId, _ChannelId, _MessageId, State) ->
State.
-spec record_followup(pid(), integer(), integer(), state()) -> state().
record_followup(Pid, UserId, MessageId, State) ->
Followups = maps:get(followups, State, #{}),
Reads = maps:get(Pid, Followups, #{}),
Watermark = max(MessageId, maps:get(UserId, Reads, MessageId)),
State#{followups => Followups#{Pid => Reads#{UserId => Watermark}}}.
-spec truncate_entry(non_neg_integer(), entry(), integer(), integer(), integer(), state()) ->
state().
truncate_entry(
@@ -374,11 +421,63 @@ finish_worker(Pid, Result, #{inflight := Inflight} = State) ->
{{MRef, TRef, Entry}, Rest} ->
erlang:demonitor(MRef, [flush]),
_ = erlang:cancel_timer(TRef, [{async, true}, {info, false}]),
handle_result(Result, Entry, State#{inflight := Rest});
Followups = maps:get(followups, State, #{}),
Reads = maps:get(Pid, Followups, #{}),
State1 = State#{inflight := Rest, followups => maps:remove(Pid, Followups)},
follow_up_clears(Entry, Reads, handle_result(Result, Entry, State1));
error ->
State
end.
-spec follow_up_clears(entry(), #{integer() => integer()}, state()) -> state().
follow_up_clears(_Entry, Reads, State) when map_size(Reads) =:= 0 ->
State;
follow_up_clears(#{channel_id := ChannelId, message_id := AfterMessageId}, Reads, State) ->
case push:clear_notifications_enabled() of
true ->
maps:fold(
fun(UserId, MessageId, Acc) ->
follow_up_clear(UserId, ChannelId, MessageId, AfterMessageId, Acc)
end,
State,
Reads
);
false ->
State
end.
-spec follow_up_clear(integer(), integer(), integer(), integer(), state()) -> state().
follow_up_clear(UserId, ChannelId, MessageId, AfterMessageId, State) ->
case pending_followup(UserId, ChannelId, State) of
{ok, Pid} ->
record_followup(Pid, UserId, MessageId, State);
none ->
Job = push_job_publisher:clear_job(UserId, ChannelId, MessageId, AfterMessageId),
bump(followup_clears, 1, admit(Job, State))
end.
-spec pending_followup(integer(), integer(), state()) -> {ok, pid()} | none.
pending_followup(UserId, ChannelId, #{inflight := Inflight} = State) ->
Pending = maps:filter(
fun(Pid, Reads) ->
is_map_key(UserId, Reads) andalso
inflight_channel(Pid, Inflight) =:= {ok, ChannelId}
end,
maps:get(followups, State, #{})
),
case maps:keys(Pending) of
[Pid | _] -> {ok, Pid};
[] -> none
end.
-spec inflight_channel(pid(), #{pid() => {reference(), reference(), entry()}}) ->
{ok, integer()} | error.
inflight_channel(Pid, Inflight) ->
case maps:find(Pid, Inflight) of
{ok, {_MRef, _TRef, #{channel_id := ChannelId}}} -> {ok, ChannelId};
error -> error
end.
-spec reply_result(term()) -> ok | {error, term()}.
reply_result(ok) -> ok;
reply_result({error, Reason}) -> {error, Reason};
@@ -466,6 +565,7 @@ build_stats(#{jobs := Jobs, inflight := Inflight, counters := Counters, dropped
sheds => 0,
truncations => 0,
skipped_active => 0,
followup_clears => 0,
enqueued => 0
},
Counters#{
@@ -569,6 +669,7 @@ test_state(Reads) ->
jobs => gb_trees:empty(),
ready => queue:new(),
inflight => #{},
followups => #{},
next_seq => 1,
reads => Reads,
active => #{},
@@ -449,6 +449,7 @@ finalize_guild_connection(GuildId, GuildPid, State, ReadyFun) ->
finalize_guild_monitor(GuildId, GuildPid, Guilds0, State, ReadyFun) ->
MonitorRef = monitor(process, GuildPid),
Guilds = Guilds0#{GuildId => {GuildPid, MonitorRef}},
ok = session_lifecycle:send_guild_push_hold({GuildPid, MonitorRef}, State),
apply_ready_fun(GuildId, GuildPid, ReadyFun, State#{guilds => Guilds}).
-spec apply_ready_fun(
@@ -21,7 +21,8 @@
handle_resume/3,
handle_resume_offline_timeout/2,
handle_presence_update_cast/2,
handle_initial_global_presences/2
handle_initial_global_presences/2,
send_guild_push_hold/2
]).
-export_type([session_state/0, channel_id/0, user_id/0, session_id/0, seq/0, status/0]).
@@ -352,6 +353,7 @@ handle_resume(Seq, SocketPid, #{seq := CurrentSeq} = State) ->
NewState1 = replace_socket(SocketPid, NewState0),
ReplyEvents = replay_missed_events_inline(MissedEvents, SocketPid),
NewState = NewState1#{status => ResumeStatus, resume_status => ResumeStatus},
ok = sync_guild_push_hold(State, NewState),
NewState2 = ensure_presence_attached_on_resume(
NewState, SessionId, ResumeStatus, Afk, Mobile
),
@@ -509,8 +511,40 @@ handle_presence_update_cast(Update, State) ->
NewStatus, State#{status => NewStatus, afk => NewAfk, mobile => NewMobile}
),
send_presence_update(State, SessionId, NewStatus, NewAfk, NewMobile, Update),
ok = sync_guild_push_hold(State, NewState),
{noreply, NewState}.
-spec sync_guild_push_hold(session_state(), session_state()) -> ok.
sync_guild_push_hold(OldState, NewState) ->
notify_guild_push_hold(guild_push_hold(OldState), guild_push_hold(NewState), NewState).
-spec notify_guild_push_hold(boolean(), boolean(), session_state()) -> ok.
notify_guild_push_hold(Hold, Hold, _State) ->
ok;
notify_guild_push_hold(_OldHold, _NewHold, State) ->
maps:foreach(
fun(_GuildId, GuildRef) -> send_guild_push_hold(GuildRef, State) end,
maps:get(guilds, State, #{})
).
-spec send_guild_push_hold(term(), session_state()) -> ok.
send_guild_push_hold({GuildPid, _Ref}, #{id := SessionId} = State) when is_pid(GuildPid) ->
case maps:get(bot, State, false) of
true ->
ok;
false ->
gen_server:cast(
GuildPid, {set_session_push_hold, SessionId, guild_push_hold(State)}
)
end;
send_guild_push_hold(_GuildRef, _State) ->
ok.
-spec guild_push_hold(session_state()) -> boolean().
guild_push_hold(State) ->
lists:member(maps:get(status, State, online), [online, dnd, invisible]) andalso
not maps:get(afk, State, false) andalso not maps:get(mobile, State, false).
-spec maybe_update_resume_status(status(), session_state()) -> session_state().
maybe_update_resume_status(offline, State) ->
State;