mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
2294 lines
82 KiB
Erlang
2294 lines
82 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(guild_dispatch_push).
|
|
-typing([eqwalizer]).
|
|
|
|
-export([
|
|
maybe_send_push_notifications/4,
|
|
collect_and_send_push_notifications/3,
|
|
push_counters/0
|
|
]).
|
|
|
|
-define(MAX_FORMAT_MEMBERS, 50).
|
|
-define(CONCURRENCY_LIMIT_KEY, guild_push_concurrency_limit).
|
|
-define(DEFAULT_PUSH_CONCURRENCY, 2).
|
|
-define(MAX_PUSH_CONCURRENCY, 8).
|
|
-define(QUEUE_LIMIT_KEY, guild_push_queue_limit).
|
|
-define(DEFAULT_PUSH_QUEUE_LIMIT, 64).
|
|
-define(MAX_PUSH_QUEUE_LIMIT, 1024).
|
|
-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,
|
|
worker_completed,
|
|
worker_failed,
|
|
queued,
|
|
dropped_at_limit,
|
|
spawn_failed,
|
|
slot_reclaimed,
|
|
slot_deduped,
|
|
members_table_missing,
|
|
scan_table_unavailable
|
|
]).
|
|
|
|
-type event() :: atom().
|
|
-type event_data() :: map().
|
|
-type guild_state() :: map().
|
|
-type guild_id() :: integer().
|
|
-type user_id() :: integer().
|
|
-type push_worker() :: {integer(), pid(), integer()}.
|
|
-type push_gate() :: push_worker() | undefined.
|
|
-type grace_sessions() :: #{user_id() => [{binary(), pid()}]}.
|
|
-type grace_hold() :: {grace_sessions(), integer()} | none.
|
|
-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) orelse
|
|
push_message_params:suppresses_notifications(FinalData)
|
|
of
|
|
true -> ok;
|
|
false -> maybe_spawn_push(FinalData, GuildId, UpdatedState)
|
|
end;
|
|
maybe_send_push_notifications(_Event, _FinalData, _GuildId, _UpdatedState) ->
|
|
ok.
|
|
|
|
-spec maybe_spawn_push(event_data(), guild_id(), guild_state()) -> ok.
|
|
maybe_spawn_push(FinalData, GuildId, UpdatedState) ->
|
|
Limit = push_concurrency_limit(),
|
|
QueueLimit = push_queue_limit(),
|
|
Workers = live_push_workers(),
|
|
put(push_inflight_workers, Workers),
|
|
case push_admission(length(Workers), Limit, QueueLimit) of
|
|
run ->
|
|
spawn_push(FinalData, GuildId, UpdatedState, undefined);
|
|
queue ->
|
|
count_push_event(queued),
|
|
spawn_push(FinalData, GuildId, UpdatedState, lists:nth(Limit, Workers));
|
|
overflow ->
|
|
push_queue_overflow(GuildId, Limit, QueueLimit)
|
|
end.
|
|
|
|
-spec push_admission(non_neg_integer(), pos_integer(), non_neg_integer()) ->
|
|
run | queue | overflow.
|
|
push_admission(Tracked, Limit, _QueueLimit) when Tracked < Limit -> run;
|
|
push_admission(Tracked, Limit, QueueLimit) when Tracked - Limit < QueueLimit -> queue;
|
|
push_admission(_Tracked, _Limit, _QueueLimit) -> overflow.
|
|
|
|
-spec push_queue_overflow(guild_id(), pos_integer(), non_neg_integer()) -> ok.
|
|
push_queue_overflow(GuildId, Limit, QueueLimit) ->
|
|
count_push_event(dropped_at_limit),
|
|
logger:warning(
|
|
"guild_push_dropped_at_limit: guild_id=~p concurrency=~p queue_limit=~p",
|
|
[GuildId, Limit, QueueLimit]
|
|
).
|
|
|
|
-spec push_queue_limit() -> non_neg_integer().
|
|
push_queue_limit() ->
|
|
case application:get_env(fluxer_gateway, ?QUEUE_LIMIT_KEY, undefined) of
|
|
Value when is_integer(Value), Value >= 0 -> min(Value, ?MAX_PUSH_QUEUE_LIMIT);
|
|
_ -> ?DEFAULT_PUSH_QUEUE_LIMIT
|
|
end.
|
|
|
|
-spec push_counters() -> #{atom() => non_neg_integer()}.
|
|
push_counters() ->
|
|
try ets:tab2list(?PUSH_COUNTERS) of
|
|
Rows ->
|
|
maps:from_list([
|
|
{Key, Count}
|
|
|| {Key, Count} <- Rows, is_atom(Key), is_integer(Count)
|
|
])
|
|
catch
|
|
error:badarg -> #{}
|
|
end.
|
|
|
|
-spec local_process_alive(pid()) -> boolean().
|
|
local_process_alive(Pid) ->
|
|
node(Pid) =:= node() andalso erlang:is_process_alive(Pid).
|
|
|
|
-spec push_concurrency_limit() -> pos_integer().
|
|
push_concurrency_limit() ->
|
|
case application:get_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, undefined) of
|
|
Value when is_integer(Value), Value > 0 -> min(Value, ?MAX_PUSH_CONCURRENCY);
|
|
_ -> ?DEFAULT_PUSH_CONCURRENCY
|
|
end.
|
|
|
|
-spec live_push_workers() -> [push_worker()].
|
|
live_push_workers() ->
|
|
Now = erlang:monotonic_time(millisecond),
|
|
prune_push_workers(tracked_push_workers(Now), Now, #{}, []).
|
|
|
|
-spec tracked_push_workers(integer()) -> [term()].
|
|
tracked_push_workers(Now) ->
|
|
merge_inflight_push_worker(get(push_inflight), tracked_worker_list(), Now).
|
|
|
|
-spec tracked_worker_list() -> [term()].
|
|
tracked_worker_list() ->
|
|
case get(push_inflight_workers) of
|
|
Workers when is_list(Workers) -> Workers;
|
|
_ -> []
|
|
end.
|
|
|
|
-spec merge_inflight_push_worker(term(), [term()], integer()) -> [term()].
|
|
merge_inflight_push_worker(Pid, Workers, Now) when is_pid(Pid) ->
|
|
case lists:any(fun(Worker) -> push_worker_pid(Worker) =:= Pid end, Workers) of
|
|
true -> Workers;
|
|
false -> Workers ++ [{0, Pid, Now}]
|
|
end;
|
|
merge_inflight_push_worker(_Inflight, Workers, _Now) ->
|
|
Workers.
|
|
|
|
-spec push_worker_pid(term()) -> pid() | undefined.
|
|
push_worker_pid({_Gen, Pid, _StartedMs}) when is_pid(Pid) -> Pid;
|
|
push_worker_pid(_Worker) -> undefined.
|
|
|
|
-spec prune_push_workers([term()], integer(), map(), [push_worker()]) -> [push_worker()].
|
|
prune_push_workers([], _Now, _Seen, Acc) ->
|
|
lists:reverse(Acc);
|
|
prune_push_workers([Worker | Rest], Now, Seen, Acc) ->
|
|
case keep_push_worker(Worker, Now, Seen) of
|
|
{keep, {_Gen, Pid, _StartedMs} = Kept} ->
|
|
prune_push_workers(Rest, Now, Seen#{Pid => true}, [Kept | Acc]);
|
|
drop ->
|
|
prune_push_workers(Rest, Now, Seen, Acc)
|
|
end.
|
|
|
|
-spec keep_push_worker(term(), integer(), map()) -> {keep, push_worker()} | drop.
|
|
keep_push_worker({Gen, Pid, StartedMs} = Worker, Now, Seen) when
|
|
is_integer(Gen), is_pid(Pid), is_integer(StartedMs)
|
|
->
|
|
case maps:is_key(Pid, Seen) of
|
|
true -> drop_duplicate_push_worker();
|
|
false -> keep_live_push_worker(Worker, Now - StartedMs, local_process_alive(Pid))
|
|
end;
|
|
keep_push_worker(_Worker, _Now, _Seen) ->
|
|
drop.
|
|
|
|
-spec drop_duplicate_push_worker() -> drop.
|
|
drop_duplicate_push_worker() ->
|
|
count_push_event(slot_deduped),
|
|
drop.
|
|
|
|
-spec keep_live_push_worker(push_worker(), integer(), boolean()) ->
|
|
{keep, push_worker()} | drop.
|
|
keep_live_push_worker(_Worker, _AgeMs, false) ->
|
|
drop;
|
|
keep_live_push_worker(Worker, AgeMs, true) when AgeMs < ?PUSH_WORKER_MAX_AGE_MS ->
|
|
{keep, Worker};
|
|
keep_live_push_worker(_Worker, _AgeMs, true) ->
|
|
count_push_event(slot_reclaimed),
|
|
drop.
|
|
|
|
-spec note_push_worker(pid()) -> ok.
|
|
note_push_worker(Pid) ->
|
|
Gen = erlang:unique_integer([monotonic, positive]),
|
|
StartedMs = erlang:monotonic_time(millisecond),
|
|
put(push_inflight_workers, [{Gen, Pid, StartedMs} | tracked_worker_list()]),
|
|
ok.
|
|
|
|
-spec count_push_event(atom()) -> ok.
|
|
count_push_event(Key) ->
|
|
bump_push_counter(Key, true).
|
|
|
|
-spec bump_push_counter(atom(), boolean()) -> ok.
|
|
bump_push_counter(Key, Retry) ->
|
|
try ets:update_counter(?PUSH_COUNTERS, Key, {2, 1}) of
|
|
_ -> ok
|
|
catch
|
|
error:badarg -> retry_push_counter(Key, Retry)
|
|
end.
|
|
|
|
-spec retry_push_counter(atom(), boolean()) -> ok.
|
|
retry_push_counter(Key, true) ->
|
|
ok = ensure_push_counters(),
|
|
bump_push_counter(Key, false);
|
|
retry_push_counter(_Key, false) ->
|
|
ok.
|
|
|
|
-spec ensure_push_counters() -> ok.
|
|
ensure_push_counters() ->
|
|
case ets:whereis(?PUSH_COUNTERS) of
|
|
undefined -> create_push_counters();
|
|
_ -> ensure_push_counter_keys()
|
|
end.
|
|
|
|
-spec create_push_counters() -> ok.
|
|
create_push_counters() ->
|
|
ok = guild_ets_utils:ensure_table(?PUSH_COUNTERS, push_counter_table_options()),
|
|
ensure_push_counter_keys().
|
|
|
|
-spec push_counter_table_options() -> [term()].
|
|
push_counter_table_options() ->
|
|
[named_table, public, set, {read_concurrency, true}, {write_concurrency, true}].
|
|
|
|
-spec ensure_push_counter_keys() -> ok.
|
|
ensure_push_counter_keys() ->
|
|
lists:foreach(fun ensure_push_counter_key/1, ?PUSH_COUNTER_KEYS).
|
|
|
|
-spec ensure_push_counter_key(atom()) -> ok.
|
|
ensure_push_counter_key(Key) ->
|
|
try
|
|
_ = ets:insert_new(?PUSH_COUNTERS, {Key, 0}),
|
|
ok
|
|
catch
|
|
error:badarg -> ok
|
|
end.
|
|
|
|
-spec spawn_push(event_data(), guild_id(), guild_state(), push_gate()) -> ok.
|
|
spawn_push(FinalData, GuildId, UpdatedState, Gate) ->
|
|
Data = maps:get(data, UpdatedState, #{}),
|
|
case maps:get(members_ets, Data, undefined) of
|
|
MembersTab when is_reference(MembersTab) ->
|
|
CompactState = compact_push_state(
|
|
eqwalizer:dynamic_cast(MembersTab), Data, GuildId, UpdatedState
|
|
),
|
|
spawn_compact_push(FinalData, GuildId, CompactState, Gate);
|
|
_ ->
|
|
missing_members_table(FinalData, GuildId, UpdatedState, Gate)
|
|
end.
|
|
|
|
-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, 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 => Hold,
|
|
member_count => maps:get(member_count, UpdatedState, undefined)
|
|
}.
|
|
|
|
-spec compact_push_data(map()) -> map().
|
|
compact_push_data(Data) ->
|
|
maps:with(
|
|
[
|
|
<<"guild">>,
|
|
<<"roles">>,
|
|
<<"role_index">>,
|
|
role_perms_cache,
|
|
<<"channels">>,
|
|
<<"channel_index">>,
|
|
overwrite_perms_cache,
|
|
members_ets
|
|
],
|
|
Data
|
|
).
|
|
|
|
-spec spawn_compact_push(event_data(), guild_id(), guild_state(), push_gate()) -> ok.
|
|
spawn_compact_push(FinalData, GuildId, CompactState, Gate) ->
|
|
spawn_push_worker(
|
|
fun() ->
|
|
collect_and_send_compact_push_notifications(FinalData, GuildId, CompactState)
|
|
end,
|
|
GuildId,
|
|
Gate
|
|
).
|
|
|
|
-spec missing_members_table(event_data(), guild_id(), guild_state(), push_gate()) -> ok.
|
|
missing_members_table(FinalData, GuildId, UpdatedState, Gate) ->
|
|
count_push_event(members_table_missing),
|
|
logger:warning(
|
|
"guild_push_members_table_unavailable: guild_id=~p phase=spawn",
|
|
[GuildId]
|
|
),
|
|
spawn_legacy_push(FinalData, GuildId, UpdatedState, Gate).
|
|
|
|
-spec spawn_legacy_push(event_data(), guild_id(), guild_state(), push_gate()) -> ok.
|
|
spawn_legacy_push(FinalData, GuildId, UpdatedState, Gate) ->
|
|
LegacyState = legacy_push_state(GuildId, UpdatedState),
|
|
spawn_push_worker(
|
|
fun() ->
|
|
collect_and_send_push_notifications(FinalData, GuildId, LegacyState)
|
|
end,
|
|
GuildId,
|
|
Gate
|
|
).
|
|
|
|
-spec legacy_push_state(guild_id(), guild_state()) -> guild_state().
|
|
legacy_push_state(GuildId, UpdatedState) ->
|
|
#{
|
|
id => maps:get(id, UpdatedState, GuildId),
|
|
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)
|
|
}.
|
|
|
|
-spec spawn_push_worker(fun(() -> ok), guild_id(), push_gate()) -> ok.
|
|
spawn_push_worker(Worker, GuildId, Gate) ->
|
|
Counted = fun() ->
|
|
ok = wait_for_push_slot(Gate),
|
|
run_counted_push_worker(Worker)
|
|
end,
|
|
case try_spawn_push_worker(Counted, GuildId) of
|
|
{ok, Pid} ->
|
|
put(push_inflight, Pid),
|
|
note_push_worker(Pid),
|
|
count_push_event(worker_started);
|
|
error ->
|
|
count_push_event(spawn_failed)
|
|
end.
|
|
|
|
-spec wait_for_push_slot(push_gate()) -> ok.
|
|
wait_for_push_slot(undefined) ->
|
|
ok;
|
|
wait_for_push_slot({_Gen, Pid, _StartedMs}) ->
|
|
Ref = erlang:monitor(process, Pid),
|
|
receive
|
|
{'DOWN', Ref, process, Pid, _Reason} -> ok
|
|
after ?PUSH_WORKER_MAX_AGE_MS ->
|
|
erlang:demonitor(Ref, [flush]),
|
|
ok
|
|
end.
|
|
|
|
-spec run_counted_push_worker(fun(() -> ok)) -> ok.
|
|
run_counted_push_worker(Worker) ->
|
|
try Worker() of
|
|
_ -> count_push_event(worker_completed)
|
|
catch
|
|
_Class:_Reason -> count_push_event(worker_failed)
|
|
end.
|
|
|
|
-spec try_spawn_push_worker(fun(() -> ok), guild_id()) -> {ok, pid()} | error.
|
|
try_spawn_push_worker(Worker, GuildId) ->
|
|
try
|
|
{ok, spawn(fun() -> run_deprioritised_push_worker(Worker) end)}
|
|
catch
|
|
error:system_limit ->
|
|
logger:warning(
|
|
"guild_push_worker_spawn_failed: guild_id=~p reason=system_limit",
|
|
[GuildId]
|
|
),
|
|
error
|
|
end.
|
|
|
|
-spec run_deprioritised_push_worker(fun(() -> ok)) -> ok.
|
|
run_deprioritised_push_worker(Worker) ->
|
|
ok = apply_push_worker_priority(),
|
|
Worker().
|
|
|
|
-spec apply_push_worker_priority() -> ok.
|
|
apply_push_worker_priority() ->
|
|
_ = erlang:process_flag(priority, low),
|
|
ok.
|
|
|
|
-spec large_guild_meta(guild_state()) -> map() | undefined.
|
|
large_guild_meta(State) ->
|
|
case maps:get(member_count, State, undefined) of
|
|
MemberCount when is_integer(MemberCount), MemberCount >= 0 ->
|
|
#{member_count => MemberCount, features => guild_features(State)};
|
|
_ ->
|
|
undefined
|
|
end.
|
|
|
|
-spec guild_features(guild_state()) -> [binary()].
|
|
guild_features(State) ->
|
|
Data = maps:get(data, State, #{}),
|
|
Guild = maps:get(<<"guild">>, Data, #{}),
|
|
case maps:get(<<"features">>, Guild, []) of
|
|
Features when is_list(Features) -> Features;
|
|
_ -> []
|
|
end.
|
|
|
|
-spec collect_and_send_compact_push_notifications(event_data(), guild_id(), guild_state()) ->
|
|
ok.
|
|
collect_and_send_compact_push_notifications(MessageData, GuildId, State) ->
|
|
case guild_dispatch_config:should_send_push_notifications(State) of
|
|
false -> ok;
|
|
true -> send_compact_push_notifications(MessageData, GuildId, State)
|
|
end.
|
|
|
|
-spec send_compact_push_notifications(event_data(), guild_id(), guild_state()) -> ok.
|
|
send_compact_push_notifications(MessageData, GuildId, State) ->
|
|
ChannelIdBin = maps:get(<<"channel_id">>, MessageData, undefined),
|
|
case guild_dispatch_decorate:parse_snowflake(<<"channel_id">>, ChannelIdBin) of
|
|
undefined -> ok;
|
|
ChannelId -> scan_and_send_compact_push(MessageData, GuildId, ChannelId, State)
|
|
end.
|
|
|
|
-spec scan_and_send_compact_push(event_data(), guild_id(), integer(), guild_state()) -> ok.
|
|
scan_and_send_compact_push(MessageData, GuildId, ChannelId, State) ->
|
|
Context = compact_scan_context(MessageData, ChannelId, State),
|
|
MembersTab = eqwalizer:dynamic_cast(maps:get(members_ets, State)),
|
|
case scan_push_members(MembersTab, Context, State) of
|
|
{ok, #{eligible_user_ids := [], held_user_ids := []}} ->
|
|
ok;
|
|
{ok, Scan} ->
|
|
send_compact_scanned_push(MessageData, GuildId, Scan, State);
|
|
{error, table_unavailable} ->
|
|
scan_table_unavailable(GuildId)
|
|
end.
|
|
|
|
-spec scan_table_unavailable(guild_id()) -> ok.
|
|
scan_table_unavailable(GuildId) ->
|
|
count_push_event(scan_table_unavailable),
|
|
logger:warning(
|
|
"guild_push_members_table_unavailable: guild_id=~p phase=scan",
|
|
[GuildId]
|
|
),
|
|
ok.
|
|
|
|
-spec compact_scan_context(event_data(), integer(), guild_state()) -> map().
|
|
compact_scan_context(MessageData, ChannelId, State) ->
|
|
MentionRoles = maps:get(<<"mention_roles">>, MessageData, []),
|
|
#{
|
|
mention_everyone => maps:get(<<"mention_everyone">>, MessageData, false) =:= true,
|
|
direct_mentions => direct_mention_id_set(MessageData),
|
|
mention_roles => mention_role_id_set(MentionRoles),
|
|
format_members => format_member_id_set(MessageData),
|
|
channel_id => ChannelId,
|
|
session_eligibility => maps:get(session_eligibility, State),
|
|
grace_sessions => held_sessions(maps:get(grace_hold, State, none))
|
|
}.
|
|
|
|
-spec scan_push_members(ets:tid(), map(), guild_state()) ->
|
|
{ok, map()} | {error, table_unavailable}.
|
|
scan_push_members(MembersTab, Context, State) ->
|
|
try
|
|
{ok, fold_push_members(member_id_snapshot(MembersTab), MembersTab, Context, State)}
|
|
catch
|
|
error:badarg -> {error, table_unavailable}
|
|
end.
|
|
|
|
-spec initial_scan_acc() -> map().
|
|
initial_scan_acc() ->
|
|
#{
|
|
eligible_user_ids => [],
|
|
held_user_ids => [],
|
|
user_roles => #{},
|
|
format_members => #{}
|
|
}.
|
|
|
|
-spec member_id_snapshot(ets:tid()) -> [user_id()].
|
|
member_id_snapshot(MembersTab) ->
|
|
ets:select(MembersTab, [{{'$1', '_'}, [], ['$1']}]).
|
|
|
|
-spec fold_push_members([user_id()], ets:tid(), map(), guild_state()) -> map().
|
|
fold_push_members(UserIds, MembersTab, Context, State) ->
|
|
lists:foldl(
|
|
fun(UserId, Acc) -> scan_push_member_id(UserId, MembersTab, Context, State, Acc) end,
|
|
initial_scan_acc(),
|
|
UserIds
|
|
).
|
|
|
|
-spec scan_push_member_id(user_id(), ets:tid(), map(), guild_state(), map()) -> map().
|
|
scan_push_member_id(UserId, MembersTab, Context, State, Acc) ->
|
|
case ets:lookup(MembersTab, UserId) of
|
|
[Row] -> scan_push_member(Row, Context, State, Acc);
|
|
_ -> Acc
|
|
end.
|
|
|
|
-spec scan_push_member(term(), map(), guild_state(), map()) -> map().
|
|
scan_push_member({UserId, Member}, Context, State, Acc) when
|
|
is_integer(UserId), UserId > 0, is_map(Member)
|
|
->
|
|
Acc1 = maybe_collect_format_member(UserId, Member, Context, Acc),
|
|
case scanned_member_route(UserId, Member, Context, State) of
|
|
skip -> Acc1;
|
|
Key -> add_scanned_member(Key, UserId, Member, Acc1)
|
|
end;
|
|
scan_push_member(_Row, _Context, _State, Acc) ->
|
|
Acc.
|
|
|
|
-spec scanned_member_route(user_id(), map(), map(), guild_state()) ->
|
|
eligible_user_ids | held_user_ids | skip.
|
|
scanned_member_route(UserId, Member, Context, State) ->
|
|
case member_push_route(UserId, Member, Context) of
|
|
skip ->
|
|
skip;
|
|
Key ->
|
|
visible_member_route(
|
|
Key,
|
|
guild_permissions:can_view_channel(
|
|
UserId, maps:get(channel_id, Context), Member, State
|
|
)
|
|
)
|
|
end.
|
|
|
|
-spec member_push_route(user_id(), map(), map()) ->
|
|
eligible_user_ids | held_user_ids | skip.
|
|
member_push_route(UserId, Member, Context) ->
|
|
case is_push_candidate(UserId, Member, Context) of
|
|
true -> eligible_user_ids;
|
|
false -> grace_member_route(maps:is_key(UserId, maps:get(grace_sessions, Context, #{})))
|
|
end.
|
|
|
|
-spec grace_member_route(boolean()) -> held_user_ids | skip.
|
|
grace_member_route(true) -> held_user_ids;
|
|
grace_member_route(false) -> skip.
|
|
|
|
-spec visible_member_route(eligible_user_ids | held_user_ids, boolean()) ->
|
|
eligible_user_ids | held_user_ids | skip.
|
|
visible_member_route(Key, true) -> Key;
|
|
visible_member_route(_Key, false) -> skip.
|
|
|
|
-spec add_scanned_member(eligible_user_ids | held_user_ids, user_id(), map(), map()) -> map().
|
|
add_scanned_member(Key, UserId, Member, Acc) ->
|
|
UserIds = maps:get(Key, Acc),
|
|
UserRoles = maps:get(user_roles, Acc),
|
|
Acc#{
|
|
Key := [UserId | UserIds],
|
|
user_roles := UserRoles#{UserId => extract_role_ids(Member)}
|
|
}.
|
|
|
|
-spec maybe_collect_format_member(user_id(), map(), map(), map()) -> map().
|
|
maybe_collect_format_member(UserId, Member, Context, Acc) ->
|
|
case maps:is_key(UserId, maps:get(format_members, Context)) of
|
|
true ->
|
|
Members = maps:get(format_members, Acc),
|
|
Acc#{format_members := Members#{UserId => Member}};
|
|
false ->
|
|
Acc
|
|
end.
|
|
|
|
-spec is_push_candidate(user_id(), map(), map()) -> boolean().
|
|
is_push_candidate(UserId, Member, Context) ->
|
|
maps:get(UserId, maps:get(session_eligibility, Context), true) orelse
|
|
(not mentions_respect_active_session() andalso
|
|
is_mention_candidate(UserId, Member, Context)).
|
|
|
|
-spec is_mention_candidate(user_id(), map(), map()) -> boolean().
|
|
is_mention_candidate(UserId, Member, Context) ->
|
|
maps:get(mention_everyone, Context) orelse
|
|
maps:is_key(UserId, maps:get(direct_mentions, Context)) orelse
|
|
member_has_mentioned_role(Member, maps:get(mention_roles, Context)).
|
|
|
|
-spec mentions_respect_active_session() -> boolean().
|
|
mentions_respect_active_session() ->
|
|
application:get_env(fluxer_gateway, push_mentions_respect_active_session, true) =:= true.
|
|
|
|
-spec send_compact_scanned_push(event_data(), guild_id(), map(), guild_state()) -> ok.
|
|
send_compact_scanned_push(MessageData, GuildId, Scan, State) ->
|
|
FormatData = compact_format_data(maps:get(data, State), maps:get(format_members, Scan)),
|
|
send_push_to_eligible_users(
|
|
MessageData,
|
|
GuildId,
|
|
lists:reverse(maps:get(eligible_user_ids, Scan)),
|
|
maps:get(user_roles, Scan),
|
|
maps:get(session_eligibility, State),
|
|
FormatData,
|
|
large_guild_meta(State),
|
|
{
|
|
lists:reverse(maps:get(held_user_ids, Scan, [])),
|
|
maps:get(grace_hold, State, none),
|
|
maps:get(member_presence, State, undefined),
|
|
maps:get(guild_pid, State, undefined)
|
|
}
|
|
).
|
|
|
|
-spec compact_format_data(map(), map()) -> map().
|
|
compact_format_data(Data, FormatMembers) ->
|
|
WithoutRevision = maps:remove(member_list_revision, Data),
|
|
WithoutRevision#{
|
|
<<"members">> => FormatMembers,
|
|
members_normalized => FormatMembers,
|
|
members_sorted_ids => lists:sort(maps:keys(FormatMembers)),
|
|
<<"member_role_index">> =>
|
|
guild_data_index_members:build_member_role_index(FormatMembers)
|
|
}.
|
|
|
|
-spec direct_mention_id_set(event_data()) -> map().
|
|
direct_mention_id_set(MessageData) ->
|
|
lists:foldl(
|
|
fun add_direct_mention_id/2,
|
|
#{},
|
|
maps:get(<<"mentions">>, MessageData, [])
|
|
).
|
|
|
|
-spec add_direct_mention_id(term(), map()) -> map().
|
|
add_direct_mention_id(Mention, Acc) ->
|
|
case direct_mention_user_id(Mention) of
|
|
{true, UserId} -> Acc#{UserId => true};
|
|
false -> Acc
|
|
end.
|
|
|
|
-spec format_member_id_set(event_data()) -> map().
|
|
format_member_id_set(MessageData) ->
|
|
AuthorSet = author_format_id_set(maps:get(<<"author">>, MessageData, undefined)),
|
|
collect_format_mention_ids(
|
|
maps:get(<<"mentions">>, MessageData, []),
|
|
?MAX_FORMAT_MEMBERS,
|
|
AuthorSet
|
|
).
|
|
|
|
-spec author_format_id_set(term()) -> map().
|
|
author_format_id_set(Author) when is_map(Author) ->
|
|
case direct_mention_user_id(Author) of
|
|
{true, UserId} -> #{UserId => true};
|
|
false -> #{}
|
|
end;
|
|
author_format_id_set(_Author) ->
|
|
#{}.
|
|
|
|
-spec collect_format_mention_ids([term()], non_neg_integer(), map()) -> map().
|
|
collect_format_mention_ids(_Mentions, 0, Acc) ->
|
|
Acc;
|
|
collect_format_mention_ids([], _Remaining, Acc) ->
|
|
Acc;
|
|
collect_format_mention_ids([Mention | Rest], Remaining, Acc) ->
|
|
case direct_mention_user_id(Mention) of
|
|
{true, UserId} -> collect_format_mention_id(UserId, Rest, Remaining, Acc);
|
|
false -> collect_format_mention_ids(Rest, Remaining, Acc)
|
|
end.
|
|
|
|
-spec collect_format_mention_id(user_id(), [term()], non_neg_integer(), map()) -> map().
|
|
collect_format_mention_id(UserId, Rest, Remaining, Acc) ->
|
|
case maps:is_key(UserId, Acc) of
|
|
true -> collect_format_mention_ids(Rest, Remaining, Acc);
|
|
false -> collect_format_mention_ids(Rest, Remaining - 1, Acc#{UserId => true})
|
|
end.
|
|
|
|
-spec collect_and_send_push_notifications(event_data(), guild_id(), guild_state()) -> ok.
|
|
collect_and_send_push_notifications(MessageData, GuildId, State) ->
|
|
case guild_dispatch_config:should_send_push_notifications(State) of
|
|
false -> ok;
|
|
true -> send_push_notifications(MessageData, GuildId, State)
|
|
end.
|
|
|
|
-spec send_push_notifications(event_data(), guild_id(), guild_state()) -> ok.
|
|
send_push_notifications(MessageData, GuildId, State) ->
|
|
Data = maps:get(data, State),
|
|
Members = guild_data_index:member_map(Data),
|
|
Sessions = maps:get(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
|
|
undefined ->
|
|
ok;
|
|
ChannelId ->
|
|
send_to_eligible(
|
|
MessageData,
|
|
GuildId,
|
|
Members,
|
|
CandidateUserIds,
|
|
ChannelId,
|
|
SessionEligibility,
|
|
Hold,
|
|
Data,
|
|
State
|
|
)
|
|
end.
|
|
|
|
-spec send_to_eligible(
|
|
event_data(),
|
|
guild_id(),
|
|
map(),
|
|
[user_id()],
|
|
integer(),
|
|
map(),
|
|
grace_hold(),
|
|
map(),
|
|
guild_state()
|
|
) -> ok.
|
|
send_to_eligible(
|
|
MessageData,
|
|
GuildId,
|
|
Members,
|
|
CandidateUserIds,
|
|
ChannelId,
|
|
SessionEligibility,
|
|
Hold,
|
|
Data,
|
|
State
|
|
) ->
|
|
EligibleUserIds = find_eligible_users_for_push(
|
|
Members, CandidateUserIds, ChannelId, State
|
|
),
|
|
HeldUserIds = find_eligible_users_for_push(
|
|
Members, held_candidate_user_ids(Hold, CandidateUserIds), ChannelId, State
|
|
),
|
|
case {EligibleUserIds, HeldUserIds} of
|
|
{[], []} ->
|
|
ok;
|
|
_ ->
|
|
UserRolesMap = build_user_roles_map(Members, EligibleUserIds ++ HeldUserIds),
|
|
send_push_to_eligible_users(
|
|
MessageData,
|
|
GuildId,
|
|
EligibleUserIds,
|
|
UserRolesMap,
|
|
SessionEligibility,
|
|
Data,
|
|
large_guild_meta(State),
|
|
{
|
|
HeldUserIds,
|
|
Hold,
|
|
maps:get(member_presence, State, undefined),
|
|
maps:get(guild_pid, State, undefined)
|
|
}
|
|
)
|
|
end.
|
|
|
|
-spec held_candidate_user_ids(grace_hold(), [user_id()]) -> [user_id()].
|
|
held_candidate_user_ids(none, _CandidateUserIds) ->
|
|
[];
|
|
held_candidate_user_ids({Held, _RecheckAt}, CandidateUserIds) ->
|
|
Candidates = maps:from_keys(CandidateUserIds, true),
|
|
[UserId || UserId <- maps:keys(Held), not maps:is_key(UserId, Candidates)].
|
|
|
|
-spec push_candidate_user_ids(map(), map(), event_data()) -> [user_id()].
|
|
push_candidate_user_ids(Members, SessionEligibility, MessageData) ->
|
|
push_candidate_user_ids(
|
|
maps:get(<<"mention_everyone">>, MessageData, false),
|
|
Members,
|
|
SessionEligibility,
|
|
MessageData
|
|
).
|
|
|
|
-spec push_candidate_user_ids(term(), map(), map(), event_data()) -> [user_id()].
|
|
push_candidate_user_ids(true, Members, SessionEligibility, _MessageData) ->
|
|
{CandidateUserIds, _Seen} = base_candidate_acc(Members, SessionEligibility),
|
|
lists:reverse(CandidateUserIds);
|
|
push_candidate_user_ids(_MentionEveryone, Members, SessionEligibility, MessageData) ->
|
|
BaseAcc = base_candidate_acc(Members, SessionEligibility),
|
|
{CandidateUserIds, _Seen} =
|
|
add_mentioned_candidate_user_ids(Members, MessageData, BaseAcc),
|
|
lists:reverse(CandidateUserIds).
|
|
|
|
-spec base_candidate_acc(map(), map()) -> {[user_id()], map()}.
|
|
base_candidate_acc(Members, SessionEligibility) ->
|
|
maps:fold(
|
|
fun(UserId, _Member, Acc) ->
|
|
maybe_add_session_candidate(UserId, SessionEligibility, Acc)
|
|
end,
|
|
{[], #{}},
|
|
Members
|
|
).
|
|
|
|
-spec maybe_add_session_candidate(user_id(), map(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
maybe_add_session_candidate(UserId, SessionEligibility, Acc) ->
|
|
case maps:get(UserId, SessionEligibility, true) of
|
|
true -> add_candidate_user_id(UserId, Acc);
|
|
false -> Acc
|
|
end.
|
|
|
|
-spec add_candidate_user_id(user_id(), {[user_id()], map()}) -> {[user_id()], map()}.
|
|
add_candidate_user_id(UserId, {UserIds, Seen} = Acc) ->
|
|
case maps:is_key(UserId, Seen) of
|
|
true -> Acc;
|
|
false -> {[UserId | UserIds], Seen#{UserId => true}}
|
|
end.
|
|
|
|
-spec add_mentioned_candidate_user_ids(map(), event_data(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
add_mentioned_candidate_user_ids(Members, MessageData, Acc) ->
|
|
Acc1 = add_direct_mention_user_ids(MessageData, Acc),
|
|
add_role_mention_user_ids(Members, MessageData, Acc1).
|
|
|
|
-spec add_direct_mention_user_ids(event_data(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
add_direct_mention_user_ids(MessageData, Acc) ->
|
|
lists:foldl(
|
|
fun add_direct_mention_user/2,
|
|
Acc,
|
|
maps:get(<<"mentions">>, MessageData, [])
|
|
).
|
|
|
|
-spec add_direct_mention_user(term(), {[user_id()], map()}) -> {[user_id()], map()}.
|
|
add_direct_mention_user(Mention, Acc) ->
|
|
case direct_mention_user_id(Mention) of
|
|
{true, UserId} -> add_candidate_user_id(UserId, Acc);
|
|
false -> Acc
|
|
end.
|
|
|
|
-spec direct_mention_user_id(term()) -> {true, user_id()} | false.
|
|
direct_mention_user_id(Mention) when is_map(Mention) ->
|
|
case
|
|
validation:validate_snowflake(<<"mention.id">>, maps:get(<<"id">>, Mention, undefined))
|
|
of
|
|
{ok, UserId} -> {true, UserId};
|
|
_ -> false
|
|
end;
|
|
direct_mention_user_id(_) ->
|
|
false.
|
|
|
|
-spec add_role_mention_user_ids(map(), event_data(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
add_role_mention_user_ids(Members, MessageData, Acc) ->
|
|
MentionRoleSet = mention_role_id_set(maps:get(<<"mention_roles">>, MessageData, [])),
|
|
add_role_mention_user_ids_for_set(MentionRoleSet, Members, Acc).
|
|
|
|
-spec add_role_mention_user_ids_for_set(map(), map(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
add_role_mention_user_ids_for_set(MentionRoleSet, _Members, Acc) when
|
|
map_size(MentionRoleSet) =:= 0
|
|
->
|
|
Acc;
|
|
add_role_mention_user_ids_for_set(MentionRoleSet, Members, Acc) ->
|
|
maps:fold(
|
|
fun(UserId, Member, AccIn) ->
|
|
maybe_add_role_mention_user(UserId, Member, MentionRoleSet, AccIn)
|
|
end,
|
|
Acc,
|
|
Members
|
|
).
|
|
|
|
-spec maybe_add_role_mention_user(user_id(), map(), map(), {[user_id()], map()}) ->
|
|
{[user_id()], map()}.
|
|
maybe_add_role_mention_user(UserId, Member, MentionRoleSet, Acc) ->
|
|
HasMentionedRole = member_has_mentioned_role(Member, MentionRoleSet),
|
|
case HasMentionedRole of
|
|
true -> add_candidate_user_id(UserId, Acc);
|
|
false -> Acc
|
|
end.
|
|
|
|
-spec mention_role_id_set(list()) -> map().
|
|
mention_role_id_set(MentionRoles) ->
|
|
lists:foldl(fun add_mention_role_id/2, #{}, MentionRoles).
|
|
|
|
-spec add_mention_role_id(term(), map()) -> map().
|
|
add_mention_role_id(RoleId, Acc) ->
|
|
case snowflake_id:parse_optional(RoleId) of
|
|
Id when is_integer(Id), Id > 0 -> Acc#{Id => true};
|
|
_ -> Acc
|
|
end.
|
|
|
|
-spec member_has_mentioned_role(map(), map()) -> boolean().
|
|
member_has_mentioned_role(Member, MentionRoleSet) ->
|
|
member_roles_include_mentioned(maps:get(<<"roles">>, Member, []), MentionRoleSet).
|
|
|
|
-spec member_roles_include_mentioned(list(), map()) -> boolean().
|
|
member_roles_include_mentioned([], _MentionRoleSet) ->
|
|
false;
|
|
member_roles_include_mentioned([Role | Rest], MentionRoleSet) ->
|
|
case snowflake_id:parse_optional(Role) of
|
|
RoleId when is_integer(RoleId), RoleId > 0 ->
|
|
maps:is_key(RoleId, MentionRoleSet) orelse
|
|
member_roles_include_mentioned(Rest, MentionRoleSet);
|
|
_ ->
|
|
member_roles_include_mentioned(Rest, MentionRoleSet)
|
|
end.
|
|
|
|
-spec find_eligible_users_for_push(map(), [user_id()], integer(), guild_state()) -> [user_id()].
|
|
find_eligible_users_for_push(Members, CandidateUserIds, ChannelId, State) ->
|
|
lists:filtermap(
|
|
fun(UserId) -> is_push_eligible(UserId, Members, ChannelId, State) end,
|
|
CandidateUserIds
|
|
).
|
|
|
|
-spec is_push_eligible(user_id(), map(), integer(), guild_state()) -> {true, user_id()} | false.
|
|
is_push_eligible(UserId, Members, ChannelId, State) ->
|
|
case maps:get(UserId, Members, undefined) of
|
|
undefined ->
|
|
false;
|
|
Member ->
|
|
view_to_filtermap(UserId, ChannelId, Member, State)
|
|
end.
|
|
|
|
-spec view_to_filtermap(
|
|
user_id(), integer(), map(), guild_state()
|
|
) -> {true, user_id()} | false.
|
|
view_to_filtermap(UserId, ChannelId, Member, State) ->
|
|
case guild_permissions:can_view_channel(UserId, ChannelId, Member, State) of
|
|
true -> {true, UserId};
|
|
false -> false
|
|
end.
|
|
|
|
-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_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_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) ->
|
|
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) -> 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 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) ->
|
|
false;
|
|
actively_engaged(UserId, Presences) ->
|
|
case lookup_presence_safe(UserId, Presences) of
|
|
undefined -> false;
|
|
Presence -> presence_is_active(Presence)
|
|
end.
|
|
|
|
-spec lookup_presence_safe(user_id(), term()) -> map() | undefined.
|
|
lookup_presence_safe(UserId, Presences) ->
|
|
try guild_state_member:lookup_presence(Presences, UserId) of
|
|
Presence -> Presence
|
|
catch
|
|
error:badarg -> undefined
|
|
end.
|
|
|
|
-spec presence_is_active(map()) -> boolean().
|
|
presence_is_active(Presence) ->
|
|
Status = maps:get(<<"status">>, Presence, <<"offline">>),
|
|
Afk = maps:get(<<"afk">>, Presence, false) =:= true,
|
|
Mobile = maps:get(<<"mobile">>, Presence, false) =:= true,
|
|
is_engaged_status(Status) andalso not Afk andalso not Mobile.
|
|
|
|
-spec is_engaged_status(term()) -> boolean().
|
|
is_engaged_status(<<"online">>) -> true;
|
|
is_engaged_status(<<"dnd">>) -> true;
|
|
is_engaged_status(_Status) -> false.
|
|
|
|
-spec build_push_session_eligibility(map()) -> #{user_id() => boolean()}.
|
|
build_push_session_eligibility(Sessions) ->
|
|
maps:fold(
|
|
fun(_Sid, Session, Acc) ->
|
|
accumulate_session_eligibility(Session, Acc)
|
|
end,
|
|
#{},
|
|
Sessions
|
|
).
|
|
|
|
-spec accumulate_session_eligibility(map(), #{user_id() => boolean()}) ->
|
|
#{user_id() => boolean()}.
|
|
accumulate_session_eligibility(Session, Acc) ->
|
|
case maps:get(user_id, Session, undefined) of
|
|
UserId when is_integer(UserId) ->
|
|
Acc#{
|
|
UserId =>
|
|
maps:get(UserId, Acc, true) andalso maps:get(afk, Session, false)
|
|
};
|
|
_ ->
|
|
Acc
|
|
end.
|
|
|
|
-spec send_push_to_eligible_users(
|
|
event_data(), guild_id(), [user_id()], map(), map(), map(), map() | undefined, held_push()
|
|
) -> ok.
|
|
send_push_to_eligible_users(
|
|
MessageData,
|
|
GuildId,
|
|
EligibleUserIds,
|
|
UserRolesMap,
|
|
ConnectedUsers,
|
|
Data,
|
|
LargeGuildMeta,
|
|
Held
|
|
) ->
|
|
AuthorIdBin = maps:get(<<"id">>, maps:get(<<"author">>, MessageData, #{}), undefined),
|
|
case guild_dispatch_decorate:parse_snowflake(<<"author.id">>, AuthorIdBin) of
|
|
undefined ->
|
|
ok;
|
|
AuthorId ->
|
|
ChannelIdBin = maps:get(<<"channel_id">>, MessageData),
|
|
ChannelName = find_channel_name(ChannelIdBin, Data),
|
|
RoleNames = build_role_names_map(Data),
|
|
Params = push_params(
|
|
MessageData,
|
|
GuildId,
|
|
UserRolesMap,
|
|
ConnectedUsers,
|
|
ChannelName,
|
|
RoleNames,
|
|
Data,
|
|
AuthorId,
|
|
LargeGuildMeta
|
|
),
|
|
ok = send_push_now(EligibleUserIds, Params),
|
|
hold_push_through_grace(Held, Params)
|
|
end.
|
|
|
|
-spec push_params(
|
|
event_data(),
|
|
guild_id(),
|
|
map(),
|
|
map(),
|
|
binary(),
|
|
map(),
|
|
map(),
|
|
integer(),
|
|
map() | undefined
|
|
) -> map().
|
|
push_params(
|
|
MessageData,
|
|
GuildId,
|
|
UserRolesMap,
|
|
ConnectedUsers,
|
|
ChannelName,
|
|
RoleNames,
|
|
Data,
|
|
AuthorId,
|
|
LargeGuildMeta
|
|
) ->
|
|
Guild = maps:get(<<"guild">>, Data),
|
|
DefaultMessageNotifications = maps:get(<<"default_message_notifications">>, Guild, 0),
|
|
GuildName = maps:get(<<"name">>, Guild, <<"Unknown">>),
|
|
#{
|
|
message_data => MessageData,
|
|
guild_id => GuildId,
|
|
author_id => AuthorId,
|
|
guild_default_notifications => DefaultMessageNotifications,
|
|
guild_name => GuildName,
|
|
channel_name => ChannelName,
|
|
role_names => RoleNames,
|
|
markdown_context =>
|
|
push_notification_format:build_markdown_context(
|
|
MessageData, GuildId, RoleNames, Data
|
|
),
|
|
user_roles => UserRolesMap,
|
|
connected_users => ConnectedUsers,
|
|
guild_member_count => meta_member_count(LargeGuildMeta),
|
|
guild_features => meta_features(LargeGuildMeta)
|
|
}.
|
|
|
|
-spec send_push_now([user_id()], map()) -> ok.
|
|
send_push_now([], _Params) ->
|
|
ok;
|
|
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, 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, GuildPid})
|
|
end),
|
|
ok;
|
|
hold_push_through_grace(_Held, _Params) ->
|
|
ok.
|
|
|
|
-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(),
|
|
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 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) -- AlreadyOffline)).
|
|
|
|
-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(), [{binary(), pid()}], #{reference() => {user_id(), binary()}}
|
|
) ->
|
|
#{reference() => {user_id(), binary()}}.
|
|
monitor_user_sessions(UserId, Held, Monitors) ->
|
|
lists:foldl(
|
|
fun({Sid, Pid}, Acc) -> Acc#{erlang:monitor(process, Pid) => {UserId, Sid}} end,
|
|
Monitors,
|
|
Held
|
|
).
|
|
|
|
-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
|
|
{'DOWN', Ref, process, _Pid, _Reason} when is_map_key(Ref, Monitors) ->
|
|
await_grace_recheck(maps:remove(Ref, Monitors), RecheckAt)
|
|
after Remaining ->
|
|
Monitors
|
|
end.
|
|
|
|
-spec users_left_offline([user_id()], term()) -> [user_id()].
|
|
users_left_offline(UserIds, Presences) ->
|
|
[UserId || UserId <- UserIds, presence_is_offline(UserId, Presences)].
|
|
|
|
-spec presence_is_offline(user_id(), term()) -> boolean().
|
|
presence_is_offline(UserId, Presences) ->
|
|
case lookup_presence_safe(UserId, Presences) of
|
|
undefined -> false;
|
|
Presence -> maps:get(<<"status">>, Presence, <<"offline">>) =:= <<"offline">>
|
|
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) -> #{};
|
|
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(Sid, Session, SessionEligibility, Acc) end,
|
|
#{},
|
|
Sessions
|
|
).
|
|
|
|
-spec maybe_hold_session(term(), term(), #{user_id() => boolean()}, grace_sessions()) ->
|
|
grace_sessions().
|
|
maybe_hold_session(Sid, Session, SessionEligibility, Acc) when is_map(Session) ->
|
|
hold_suppressed_session(
|
|
maps:get(user_id, Session, undefined),
|
|
session_pids(Sid, Session),
|
|
SessionEligibility,
|
|
Acc
|
|
);
|
|
maybe_hold_session(_Sid, _Session, _SessionEligibility, Acc) ->
|
|
Acc.
|
|
|
|
-spec hold_suppressed_session(
|
|
term(), [{binary(), pid()}], #{user_id() => boolean()}, grace_sessions()
|
|
) ->
|
|
grace_sessions().
|
|
hold_suppressed_session(UserId, [_ | _] = Held, SessionEligibility, Acc) when
|
|
is_integer(UserId)
|
|
->
|
|
case maps:get(UserId, SessionEligibility, true) of
|
|
false ->
|
|
Acc#{UserId => Held ++ maps:get(UserId, Acc, [])};
|
|
true ->
|
|
Acc
|
|
end;
|
|
hold_suppressed_session(_UserId, _Held, _SessionEligibility, Acc) ->
|
|
Acc.
|
|
|
|
-spec grace_recheck_ms() -> pos_integer().
|
|
grace_recheck_ms() ->
|
|
case application:get_env(fluxer_gateway, ?GRACE_RECHECK_KEY, undefined) of
|
|
Value when is_integer(Value), Value > 0 -> Value;
|
|
_ -> ?DEFAULT_GRACE_RECHECK_MS
|
|
end.
|
|
|
|
-spec meta_member_count(map() | undefined) -> non_neg_integer() | undefined.
|
|
meta_member_count(#{member_count := MemberCount}) -> MemberCount;
|
|
meta_member_count(_Meta) -> undefined.
|
|
|
|
-spec meta_features(map() | undefined) -> [binary()] | undefined.
|
|
meta_features(#{features := Features}) -> Features;
|
|
meta_features(_Meta) -> undefined.
|
|
|
|
-spec find_channel_name(binary(), map()) -> binary().
|
|
find_channel_name(ChannelIdBin, Data) ->
|
|
case guild_dispatch_decorate:parse_snowflake(<<"channel_id">>, ChannelIdBin) of
|
|
undefined ->
|
|
<<"unknown">>;
|
|
ChannelId ->
|
|
lookup_channel_name(ChannelId, Data)
|
|
end.
|
|
|
|
-spec lookup_channel_name(integer(), map()) -> binary().
|
|
lookup_channel_name(ChannelId, Data) ->
|
|
Channels = guild_data_index:channel_index(Data),
|
|
case maps:get(ChannelId, Channels, undefined) of
|
|
undefined -> <<"unknown">>;
|
|
Channel -> maps:get(<<"name">>, Channel, <<"unknown">>)
|
|
end.
|
|
|
|
-spec build_role_names_map(map()) -> #{integer() => binary()}.
|
|
build_role_names_map(Data) ->
|
|
maps:fold(
|
|
fun add_role_name/3,
|
|
#{},
|
|
guild_data_index:role_index(Data)
|
|
).
|
|
|
|
-spec add_role_name(term(), term(), #{integer() => binary()}) -> #{integer() => binary()}.
|
|
add_role_name(RoleId, Role, Acc) when is_integer(RoleId), is_map(Role) ->
|
|
case push_utils:normalize_binary(maps:get(<<"name">>, Role, undefined)) of
|
|
Name when is_binary(Name), byte_size(Name) > 0 -> Acc#{RoleId => Name};
|
|
_ -> Acc
|
|
end;
|
|
add_role_name(_RoleId, _Role, Acc) ->
|
|
Acc.
|
|
|
|
-spec build_user_roles_map(map(), [user_id()]) -> #{user_id() => [integer()]}.
|
|
build_user_roles_map(Members, EligibleUserIds) ->
|
|
lists:foldl(
|
|
fun(UserId, Acc) -> add_user_roles(UserId, Members, Acc) end,
|
|
#{},
|
|
EligibleUserIds
|
|
).
|
|
|
|
-spec add_user_roles(user_id(), map(), #{user_id() => [integer()]}) ->
|
|
#{user_id() => [integer()]}.
|
|
add_user_roles(UserId, Members, Acc) ->
|
|
case maps:get(UserId, Members, undefined) of
|
|
undefined -> Acc;
|
|
Member -> Acc#{UserId => extract_role_ids(Member)}
|
|
end.
|
|
|
|
-spec extract_role_ids(map()) -> [integer()].
|
|
extract_role_ids(Member) ->
|
|
Roles = maps:get(<<"roles">>, Member, []),
|
|
lists:foldl(
|
|
fun collect_role_id/2,
|
|
[],
|
|
Roles
|
|
).
|
|
|
|
-spec collect_role_id(term(), [integer()]) -> [integer()].
|
|
collect_role_id(Role, Acc) ->
|
|
case validation:validate_snowflake(<<"role">>, Role) of
|
|
{ok, RoleId} -> [RoleId | Acc];
|
|
_ -> Acc
|
|
end.
|
|
|
|
-ifdef(TEST).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
presence_eligibility_suppresses_active_desktop_test() ->
|
|
Tab = ets:new(test_member_presence, [set, public]),
|
|
try
|
|
ets:insert(
|
|
Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => false, <<"afk">> => false}}
|
|
),
|
|
?assertEqual(
|
|
#{1 => false},
|
|
presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
|
|
member_presence => Tab
|
|
})
|
|
)
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
presence_eligibility_allows_mobile_only_session_test() ->
|
|
Tab = ets:new(test_member_presence, [set, public]),
|
|
try
|
|
ets:insert(
|
|
Tab, {1, #{<<"status">> => <<"online">>, <<"mobile">> => true, <<"afk">> => false}}
|
|
),
|
|
?assertEqual(
|
|
#{1 => true},
|
|
presence_eligibility(#{<<"s1">> => #{user_id => 1}}, #{
|
|
member_presence => Tab
|
|
})
|
|
)
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
presence_eligibility_allows_idle_and_afk_test() ->
|
|
Tab = ets:new(test_member_presence, [set, public]),
|
|
try
|
|
ets:insert(
|
|
Tab, {1, #{<<"status">> => <<"idle">>, <<"mobile">> => false, <<"afk">> => false}}
|
|
),
|
|
ets:insert(
|
|
Tab, {2, #{<<"status">> => <<"online">>, <<"mobile">> => false, <<"afk">> => true}}
|
|
),
|
|
?assertEqual(
|
|
#{1 => true, 2 => true},
|
|
presence_eligibility(
|
|
#{<<"s1">> => #{user_id => 1}, <<"s2">> => #{user_id => 2}},
|
|
#{member_presence => Tab}
|
|
)
|
|
)
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
presence_eligibility_allows_unknown_presence_test() ->
|
|
Tab = ets:new(test_member_presence, [set, public]),
|
|
try
|
|
?assertEqual(
|
|
#{7 => true},
|
|
presence_eligibility(#{<<"s1">> => #{user_id => 7}}, #{
|
|
member_presence => Tab
|
|
})
|
|
)
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
presence_eligibility_allows_when_presence_table_missing_test() ->
|
|
?assertEqual(
|
|
#{1 => true},
|
|
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_include_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, [])).
|
|
|
|
grace_recipients_skip_a_user_still_online_on_a_held_session_test() ->
|
|
Presences = #{1 => #{<<"status">> => <<"online">>}},
|
|
?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, [])).
|
|
|
|
grace_recipients_skip_a_user_with_one_ended_and_one_live_online_session_test() ->
|
|
Presences = #{1 => #{<<"status">> => <<"online">>}},
|
|
?assertEqual([], grace_recipients([1], [1], Presences, [])).
|
|
|
|
grace_recipients_mix_users_test() ->
|
|
Presences = #{
|
|
1 => #{<<"status">> => <<"online">>},
|
|
2 => #{<<"status">> => <<"online">>},
|
|
3 => #{<<"status">> => <<"offline">>}
|
|
},
|
|
?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(),
|
|
ok = meck:new(push, [passthrough, non_strict]),
|
|
try
|
|
ok = meck:expect(push, handle_message_create, fun(Params) ->
|
|
Self ! {pushed, maps:get(user_ids, Params)},
|
|
ok
|
|
end),
|
|
Session = spawn(fun() ->
|
|
receive
|
|
stop -> ok
|
|
end
|
|
end),
|
|
Held = #{42 => [{<<"s1">>, Session}]},
|
|
Presences = #{42 => #{<<"status">> => <<"online">>}},
|
|
RecheckAt = erlang:monotonic_time(millisecond) + 300,
|
|
_ = spawn(fun() -> deliver_after_grace(#{}, Held, RecheckAt, {Presences, undefined}) end),
|
|
timer:sleep(50),
|
|
Session ! stop,
|
|
receive
|
|
{pushed, UserIds} -> ?assertEqual([42], UserIds)
|
|
after 2000 -> erlang:error(no_push_after_session_exit)
|
|
end
|
|
after
|
|
meck:unload(push)
|
|
end.
|
|
|
|
legacy_session_eligibility_suppresses_every_real_session_test() ->
|
|
RealSession = #{
|
|
session_id => <<"s1">>,
|
|
user_id => 1,
|
|
pid => self(),
|
|
active_guilds => sets:new(),
|
|
bot => false,
|
|
is_staff => false,
|
|
pending_connect => false,
|
|
viewable_channels => #{}
|
|
},
|
|
?assertEqual(#{1 => false}, build_push_session_eligibility(#{<<"s1">> => RealSession})).
|
|
|
|
is_push_candidate_suppresses_mention_for_active_user_test() ->
|
|
Context = #{
|
|
session_eligibility => #{1 => false},
|
|
mention_everyone => true,
|
|
direct_mentions => #{1 => true},
|
|
mention_roles => #{}
|
|
},
|
|
?assertEqual(false, is_push_candidate(1, #{}, Context)).
|
|
|
|
is_push_candidate_allows_eligible_user_test() ->
|
|
Context = #{
|
|
session_eligibility => #{1 => true},
|
|
mention_everyone => false,
|
|
direct_mentions => #{},
|
|
mention_roles => #{}
|
|
},
|
|
?assertEqual(true, is_push_candidate(1, #{}, Context)).
|
|
|
|
is_push_candidate_allows_sessionless_user_test() ->
|
|
Context = #{
|
|
session_eligibility => #{},
|
|
mention_everyone => false,
|
|
direct_mentions => #{},
|
|
mention_roles => #{}
|
|
},
|
|
?assertEqual(true, is_push_candidate(9, #{}, Context)).
|
|
|
|
push_candidate_user_ids_everyone_respects_active_sessions_test() ->
|
|
Members = #{1 => #{}, 2 => #{}, 3 => #{}},
|
|
?assertEqual(
|
|
[],
|
|
push_candidate_user_ids(
|
|
Members, #{1 => false, 2 => false, 3 => false}, #{<<"mention_everyone">> => true}
|
|
)
|
|
),
|
|
?assertEqual(
|
|
[1, 3],
|
|
lists:sort(
|
|
push_candidate_user_ids(
|
|
Members, #{1 => true, 2 => false}, #{<<"mention_everyone">> => true}
|
|
)
|
|
)
|
|
).
|
|
|
|
build_push_session_eligibility_test() ->
|
|
Sessions = #{
|
|
<<"s1">> => #{user_id => 1, mobile => false, afk => true},
|
|
<<"s2">> => #{user_id => 1, mobile => false, afk => true},
|
|
<<"s3">> => #{user_id => 2, mobile => true, afk => true},
|
|
<<"s4">> => #{user_id => 3, mobile => false, afk => false}
|
|
},
|
|
Eligibility = build_push_session_eligibility(Sessions),
|
|
?assertEqual(true, maps:get(1, Eligibility)),
|
|
?assertEqual(true, maps:get(2, Eligibility)),
|
|
?assertEqual(false, maps:get(3, Eligibility)).
|
|
|
|
push_candidate_user_ids_prefers_sessionless_and_eligible_sessions_test() ->
|
|
Members = #{1 => #{}, 2 => #{}, 3 => #{}, 4 => #{}},
|
|
SessionEligibility = #{1 => false, 2 => true},
|
|
CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, #{}),
|
|
?assertEqual([2, 3, 4], lists:sort(CandidateUserIds)).
|
|
|
|
push_candidate_user_ids_includes_connected_mentioned_users_test() ->
|
|
Members = #{
|
|
1 => #{<<"roles">> => [<<"10">>]},
|
|
2 => #{<<"roles">> => [<<"20">>]},
|
|
3 => #{<<"roles">> => []},
|
|
4 => #{<<"roles">> => []}
|
|
},
|
|
SessionEligibility = #{1 => false, 2 => false, 3 => false, 4 => false},
|
|
MessageData = #{
|
|
<<"mentions">> => [#{<<"id">> => <<"3">>}],
|
|
<<"mention_roles">> => [<<"10">>]
|
|
},
|
|
CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData),
|
|
?assertEqual([1, 3], lists:sort(CandidateUserIds)).
|
|
|
|
push_candidate_user_ids_deduplicates_mentions_test() ->
|
|
Members = #{1 => #{<<"roles">> => [<<"10">>]}, 2 => #{<<"roles">> => []}},
|
|
SessionEligibility = #{1 => false, 2 => false},
|
|
MessageData = #{
|
|
<<"mentions">> => [#{<<"id">> => <<"1">>}, #{<<"id">> => <<"1">>}],
|
|
<<"mention_roles">> => [<<"10">>]
|
|
},
|
|
CandidateUserIds = push_candidate_user_ids(Members, SessionEligibility, MessageData),
|
|
?assertEqual([1], CandidateUserIds).
|
|
|
|
build_user_roles_map_uses_member_map_test() ->
|
|
Members = #{
|
|
1 => #{<<"roles">> => [<<"10">>, <<"11">>]},
|
|
2 => #{<<"roles">> => [<<"20">>]}
|
|
},
|
|
Result = build_user_roles_map(Members, [2, 1]),
|
|
?assertEqual([10, 11], lists:sort(maps:get(1, Result))),
|
|
?assertEqual([20], maps:get(2, Result)).
|
|
|
|
find_channel_name_found_test() ->
|
|
Data = #{
|
|
<<"channels">> => [
|
|
#{<<"id">> => <<"100">>, <<"name">> => <<"general">>},
|
|
#{<<"id">> => <<"101">>, <<"name">> => <<"random">>}
|
|
]
|
|
},
|
|
?assertEqual(<<"general">>, find_channel_name(<<"100">>, Data)).
|
|
|
|
find_channel_name_not_found_test() ->
|
|
Data = #{<<"channels">> => []},
|
|
?assertEqual(<<"unknown">>, find_channel_name(<<"100">>, Data)).
|
|
|
|
find_channel_name_uses_index_test() ->
|
|
Data = #{
|
|
<<"channels">> => [
|
|
#{<<"id">> => <<"100">>, <<"name">> => <<"general">>}
|
|
],
|
|
<<"channel_index">> => #{100 => #{<<"id">> => <<"100">>, <<"name">> => <<"general">>}}
|
|
},
|
|
?assertEqual(<<"general">>, find_channel_name(<<"100">>, Data)).
|
|
|
|
send_push_to_eligible_users_uses_full_data_for_channel_name_test() ->
|
|
Self = self(),
|
|
ok = meck:new(push, [passthrough, no_link]),
|
|
try
|
|
ok = meck:expect(push, handle_message_create, fun(Params) ->
|
|
Self ! {push_params, Params},
|
|
ok
|
|
end),
|
|
MessageData = #{
|
|
<<"channel_id">> => <<"100">>,
|
|
<<"author">> => #{<<"id">> => <<"42">>}
|
|
},
|
|
Data = #{
|
|
<<"guild">> => #{
|
|
<<"name">> => <<"Test Guild">>,
|
|
<<"default_message_notifications">> => 0
|
|
},
|
|
<<"channels">> => [
|
|
#{<<"id">> => <<"100">>, <<"name">> => <<"general">>}
|
|
],
|
|
<<"channel_index">> => #{
|
|
100 => #{<<"id">> => <<"100">>, <<"name">> => <<"general">>}
|
|
},
|
|
<<"roles">> => [
|
|
#{<<"id">> => <<"200">>, <<"name">> => <<"Alerts">>}
|
|
],
|
|
<<"role_index">> => #{
|
|
200 => #{<<"id">> => <<"200">>, <<"name">> => <<"Alerts">>}
|
|
}
|
|
},
|
|
?assertEqual(
|
|
ok,
|
|
send_push_to_eligible_users(
|
|
MessageData, 10, [1], #{1 => []}, #{}, Data, undefined, {[], none, undefined}
|
|
)
|
|
),
|
|
receive
|
|
{push_params, Params} ->
|
|
?assertEqual(<<"general">>, maps:get(channel_name, Params)),
|
|
?assertEqual(<<"Test Guild">>, maps:get(guild_name, Params)),
|
|
?assertEqual(#{200 => <<"Alerts">>}, maps:get(role_names, Params)),
|
|
?assertEqual(undefined, maps:get(guild_member_count, Params)),
|
|
?assertEqual(undefined, maps:get(guild_features, Params))
|
|
after 1000 ->
|
|
?assert(false)
|
|
end,
|
|
?assert(meck:validate(push))
|
|
after
|
|
meck:unload(push)
|
|
end.
|
|
|
|
find_channel_name_invalid_id_test() ->
|
|
Data = #{<<"channels">> => []},
|
|
?assertEqual(<<"unknown">>, find_channel_name(<<"invalid">>, Data)).
|
|
|
|
build_role_names_map_uses_role_index_test() ->
|
|
Data = #{
|
|
<<"roles">> => [
|
|
#{<<"id">> => <<"100">>, <<"name">> => <<"Fallback">>}
|
|
],
|
|
<<"role_index">> => #{
|
|
100 => #{<<"id">> => <<"100">>, <<"name">> => <<"Mods">>},
|
|
200 => #{<<"id">> => <<"200">>, <<"name">> => <<>>},
|
|
bad => #{<<"id">> => <<"300">>, <<"name">> => <<"Bad">>}
|
|
}
|
|
},
|
|
?assertEqual(#{100 => <<"Mods">>}, build_role_names_map(Data)).
|
|
|
|
extract_role_ids_test() ->
|
|
Member = #{<<"roles">> => [<<"10">>, <<"20">>, <<"invalid">>]},
|
|
Result = lists:sort(extract_role_ids(Member)),
|
|
?assertEqual([10, 20], Result).
|
|
|
|
extract_role_ids_empty_test() ->
|
|
Member = #{<<"roles">> => []},
|
|
?assertEqual([], extract_role_ids(Member)).
|
|
|
|
extract_role_ids_missing_key_test() ->
|
|
Member = #{},
|
|
?assertEqual([], extract_role_ids(Member)).
|
|
|
|
meta_accessors_default_to_undefined_test() ->
|
|
?assertEqual(undefined, meta_member_count(undefined)),
|
|
?assertEqual(undefined, meta_features(undefined)),
|
|
?assertEqual(5, meta_member_count(#{member_count => 5, features => []})),
|
|
?assertEqual([], meta_features(#{member_count => 5, features => []})).
|
|
|
|
compact_push_state_drops_members_and_keeps_member_count_test() ->
|
|
Tab = ets:new(test_members, [set, public]),
|
|
try
|
|
Data = compact_test_data(Tab),
|
|
UpdatedState = #{
|
|
id => 7,
|
|
data => Data,
|
|
sessions => #{<<"s1">> => #{user_id => 1}},
|
|
virtual_channel_access => #{},
|
|
member_count => 49435
|
|
},
|
|
Compact = compact_push_state(Tab, Data, 7, UpdatedState),
|
|
CompactData = maps:get(data, Compact),
|
|
?assertEqual(49435, maps:get(member_count, Compact)),
|
|
?assertEqual(Tab, maps:get(members_ets, Compact)),
|
|
?assertNot(maps:is_key(sessions, Compact)),
|
|
?assertEqual(#{1 => true}, maps:get(session_eligibility, Compact)),
|
|
?assertNot(maps:is_key(<<"members">>, CompactData)),
|
|
?assertNot(maps:is_key(members_normalized, CompactData)),
|
|
?assertNot(maps:is_key(<<"member_role_index">>, CompactData)),
|
|
?assertEqual(
|
|
#{member_count => 49435, features => [<<"COMMUNITY">>]},
|
|
large_guild_meta(Compact)
|
|
)
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
legacy_push_state_includes_member_count_test() ->
|
|
UpdatedState = #{id => 7, data => #{}, sessions => #{}, member_count => 1234},
|
|
Legacy = legacy_push_state(7, UpdatedState),
|
|
?assertEqual(1234, maps:get(member_count, Legacy)),
|
|
?assertEqual(#{}, maps:get(sessions, Legacy)),
|
|
?assertEqual(#{member_count => 1234, features => []}, large_guild_meta(Legacy)).
|
|
|
|
compact_scan_collects_eligible_users_and_format_members_test() ->
|
|
Tab = ets:new(test_members, [set, public]),
|
|
try
|
|
true = ets:insert(Tab, {1, #{<<"roles">> => []}}),
|
|
true = ets:insert(Tab, {2, #{<<"roles">> => []}}),
|
|
true = ets:insert(Tab, {3, #{<<"roles">> => [<<"200">>]}}),
|
|
State = compact_scan_state(Tab),
|
|
MessageData = #{<<"mentions">> => [#{<<"id">> => <<"3">>}]},
|
|
Context = compact_scan_context(MessageData, 10, State),
|
|
{ok, Scan} = scan_push_members(Tab, Context, State),
|
|
?assertEqual([1], lists:sort(maps:get(eligible_user_ids, Scan))),
|
|
?assertEqual(#{3 => #{<<"roles">> => [<<"200">>]}}, maps:get(format_members, Scan)),
|
|
?assertNot(maps:is_key(3, maps:get(user_roles, Scan))),
|
|
?assertEqual([], maps:get(1, maps:get(user_roles, Scan)))
|
|
after
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
compact_scan_includes_mentioned_user_when_bypass_is_re_enabled_test() ->
|
|
Tab = ets:new(test_members, [set, public]),
|
|
application:set_env(fluxer_gateway, push_mentions_respect_active_session, false),
|
|
try
|
|
true = ets:insert(Tab, {1, #{<<"roles">> => []}}),
|
|
true = ets:insert(Tab, {3, #{<<"roles">> => [<<"200">>]}}),
|
|
State = compact_scan_state(Tab),
|
|
MessageData = #{<<"mentions">> => [#{<<"id">> => <<"3">>}]},
|
|
Context = compact_scan_context(MessageData, 10, State),
|
|
{ok, Scan} = scan_push_members(Tab, Context, State),
|
|
?assertEqual([1, 3], lists:sort(maps:get(eligible_user_ids, Scan)))
|
|
after
|
|
application:unset_env(fluxer_gateway, push_mentions_respect_active_session),
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
scan_push_members_reports_unavailable_table_test() ->
|
|
Tab = ets:new(test_members, [set, public]),
|
|
State = compact_scan_state(Tab),
|
|
Context = compact_scan_context(#{}, 10, State),
|
|
true = ets:delete(Tab),
|
|
?assertEqual({error, table_unavailable}, scan_push_members(Tab, Context, State)).
|
|
|
|
collect_format_mention_ids_is_bounded_test() ->
|
|
Mentions = [#{<<"id">> => integer_to_binary(Id)} || Id <- lists:seq(1, 80)],
|
|
Result = collect_format_mention_ids(Mentions, ?MAX_FORMAT_MEMBERS, #{}),
|
|
?assertEqual(?MAX_FORMAT_MEMBERS, map_size(Result)).
|
|
|
|
format_member_id_set_includes_author_test() ->
|
|
MessageData = #{
|
|
<<"author">> => #{<<"id">> => <<"99">>},
|
|
<<"mentions">> => [#{<<"id">> => <<"1">>}, #{<<"id">> => <<"1">>}]
|
|
},
|
|
?assertEqual(#{1 => true, 99 => true}, format_member_id_set(MessageData)).
|
|
|
|
spawn_push_without_members_table_falls_back_to_legacy_push_test() ->
|
|
reset_push_worker_state(),
|
|
try
|
|
?assertEqual(
|
|
ok, spawn_push(#{}, 7, #{id => 7, data => #{}, sessions => #{}}, undefined)
|
|
),
|
|
?assert(is_pid(get(push_inflight)))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
compact_push_includes_large_guild_metadata_test() ->
|
|
Self = self(),
|
|
Tab = ets:new(test_members, [set, public]),
|
|
ok = meck:new(push, [passthrough, no_link]),
|
|
try
|
|
ok = meck:expect(push, handle_message_create, fun(Params) ->
|
|
Self ! {push_params, Params},
|
|
ok
|
|
end),
|
|
State = compact_scan_state(Tab),
|
|
Scan = #{
|
|
eligible_user_ids => [1],
|
|
user_roles => #{1 => []},
|
|
format_members => #{1 => #{<<"nick">> => <<"one">>}}
|
|
},
|
|
MessageData = #{
|
|
<<"channel_id">> => <<"10">>,
|
|
<<"author">> => #{<<"id">> => <<"42">>}
|
|
},
|
|
?assertEqual(ok, send_compact_scanned_push(MessageData, 7, Scan, State)),
|
|
receive
|
|
{push_params, Params} ->
|
|
?assertEqual(49435, maps:get(guild_member_count, Params)),
|
|
?assertEqual([<<"COMMUNITY">>], maps:get(guild_features, Params)),
|
|
?assertEqual([1], maps:get(user_ids, Params)),
|
|
?assertEqual(<<"general">>, maps:get(channel_name, Params))
|
|
after 1000 ->
|
|
?assert(false)
|
|
end,
|
|
?assert(meck:validate(push))
|
|
after
|
|
meck:unload(push),
|
|
ets:delete(Tab)
|
|
end.
|
|
|
|
compact_format_data_restricts_member_map_test() ->
|
|
Data = #{
|
|
<<"guild">> => #{},
|
|
<<"members">> => #{1 => #{}, 2 => #{}, 3 => #{}},
|
|
member_list_revision => make_ref()
|
|
},
|
|
FormatMembers = #{2 => #{<<"roles">> => [<<"200">>]}},
|
|
Result = compact_format_data(Data, FormatMembers),
|
|
?assertNot(maps:is_key(member_list_revision, Result)),
|
|
?assertEqual(FormatMembers, maps:get(<<"members">>, Result)),
|
|
?assertEqual(FormatMembers, maps:get(members_normalized, Result)),
|
|
?assertEqual([2], maps:get(members_sorted_ids, Result)),
|
|
?assertEqual(#{200 => #{2 => true}}, maps:get(<<"member_role_index">>, Result)).
|
|
|
|
inflight_pid_without_a_slot_still_counts_against_the_limit_test() ->
|
|
reset_push_worker_state(),
|
|
Blocker = blocking_push_worker(),
|
|
put(push_inflight, Blocker),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 1),
|
|
ok = application:set_env(fluxer_gateway, ?QUEUE_LIMIT_KEY, 0),
|
|
try
|
|
?assertEqual([Blocker], worker_pids(live_push_workers())),
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Blocker, get(push_inflight)),
|
|
?assertEqual([Blocker], worker_pids(get(push_inflight_workers)))
|
|
after
|
|
stop_push_worker(Blocker),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
unknown_worker_entries_are_dropped_test() ->
|
|
reset_push_worker_state(),
|
|
Blocker = blocking_push_worker(),
|
|
put(push_inflight_workers, [Blocker, {Blocker, 0}, not_a_worker]),
|
|
try
|
|
?assertEqual([], live_push_workers())
|
|
after
|
|
stop_push_worker(Blocker),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
remote_worker_pid_is_dropped_without_badarg_test() ->
|
|
reset_push_worker_state(),
|
|
Remote = remote_test_pid(),
|
|
put(push_inflight_workers, [{1, Remote, erlang:monotonic_time(millisecond)}]),
|
|
try
|
|
?assertNotEqual(node(), node(Remote)),
|
|
?assertError(badarg, erlang:is_process_alive(Remote)),
|
|
?assertEqual(false, local_process_alive(Remote)),
|
|
?assertEqual([], live_push_workers())
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
reused_worker_pid_consumes_only_one_slot_test() ->
|
|
reset_push_worker_state(),
|
|
Blocker = blocking_push_worker(),
|
|
Now = erlang:monotonic_time(millisecond),
|
|
put(push_inflight_workers, [{2, Blocker, Now}, {1, Blocker, Now - 10}]),
|
|
Before = read_push_counter(slot_deduped),
|
|
try
|
|
?assertEqual([{2, Blocker, Now}], live_push_workers()),
|
|
?assertEqual(Before + 1, read_push_counter(slot_deduped))
|
|
after
|
|
stop_push_worker(Blocker),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
stale_push_worker_slot_is_reclaimed_and_counted_test() ->
|
|
reset_push_worker_state(),
|
|
Stale = blocking_push_worker(),
|
|
Aged = erlang:monotonic_time(millisecond) - ?PUSH_WORKER_MAX_AGE_MS - 1,
|
|
put(push_inflight_workers, [{1, Stale, Aged}]),
|
|
Before = read_push_counter(slot_reclaimed),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Before + 1, read_push_counter(slot_reclaimed)),
|
|
?assertEqual(1, length(get(push_inflight_workers))),
|
|
?assertNot(lists:member(Stale, worker_pids(get(push_inflight_workers))))
|
|
after
|
|
stop_push_worker(Stale),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
push_drop_is_readable_from_named_ets_table_test() ->
|
|
reset_push_worker_state(),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 1),
|
|
ok = application:set_env(fluxer_gateway, ?QUEUE_LIMIT_KEY, 0),
|
|
Blocker = blocking_push_worker(),
|
|
put(push_inflight_workers, [{1, Blocker, erlang:monotonic_time(millisecond)}]),
|
|
Before = read_push_counter(dropped_at_limit),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Before + 1, read_push_counter(dropped_at_limit)),
|
|
?assertMatch([{dropped_at_limit, _}], ets:lookup(?PUSH_COUNTERS, dropped_at_limit)),
|
|
?assertEqual(
|
|
lists:sort(?PUSH_COUNTER_KEYS),
|
|
lists:sort([Key || {Key, _} <- ets:tab2list(?PUSH_COUNTERS)])
|
|
)
|
|
after
|
|
stop_push_worker(Blocker),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
started_push_worker_is_counted_test() ->
|
|
reset_push_worker_state(),
|
|
Before = read_push_counter(worker_started),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Before + 1, read_push_counter(worker_started)),
|
|
?assert(is_pid(get(push_inflight))),
|
|
?assertEqual(1, length(get(push_inflight_workers)))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
worker_completion_is_counted_test() ->
|
|
reset_push_worker_state(),
|
|
Before = read_push_counter(worker_completed),
|
|
BeforeFailed = read_push_counter(worker_failed),
|
|
try
|
|
?assertEqual(ok, run_counted_push_worker(fun() -> ok end)),
|
|
?assertEqual(Before + 1, read_push_counter(worker_completed)),
|
|
?assertEqual(BeforeFailed, read_push_counter(worker_failed))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
worker_crash_is_counted_as_failure_not_completion_test() ->
|
|
reset_push_worker_state(),
|
|
Before = read_push_counter(worker_failed),
|
|
BeforeCompleted = read_push_counter(worker_completed),
|
|
try
|
|
?assertEqual(ok, run_counted_push_worker(fun() -> error(boom) end)),
|
|
?assertEqual(Before + 1, read_push_counter(worker_failed)),
|
|
?assertEqual(BeforeCompleted, read_push_counter(worker_completed))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
spawned_worker_reports_started_and_completed_test() ->
|
|
reset_push_worker_state(),
|
|
Self = self(),
|
|
Started = read_push_counter(worker_started),
|
|
Completed = read_push_counter(worker_completed),
|
|
try
|
|
?assertEqual(
|
|
ok,
|
|
spawn_push_worker(
|
|
fun() ->
|
|
Self ! {ran, self()},
|
|
ok
|
|
end,
|
|
7,
|
|
undefined
|
|
)
|
|
),
|
|
Pid = get(push_inflight),
|
|
receive
|
|
{ran, Pid} -> ok
|
|
after 1000 -> ?assert(false)
|
|
end,
|
|
wait_for_counter(worker_completed, Completed + 1),
|
|
?assertEqual(Started + 1, read_push_counter(worker_started)),
|
|
?assertEqual(Completed + 1, read_push_counter(worker_completed)),
|
|
Messages = element(2, process_info(self(), messages)),
|
|
?assertEqual([], [M || {'DOWN', _, process, P, _} = M <- Messages, P =:= Pid])
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
missing_members_table_is_counted_test() ->
|
|
reset_push_worker_state(),
|
|
Before = read_push_counter(members_table_missing),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Before + 1, read_push_counter(members_table_missing))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
scan_table_unavailable_is_counted_test() ->
|
|
reset_push_worker_state(),
|
|
Tab = ets:new(test_members, [set, public]),
|
|
State = compact_scan_state(Tab),
|
|
true = ets:delete(Tab),
|
|
Before = read_push_counter(scan_table_unavailable),
|
|
try
|
|
MessageData = #{<<"channel_id">> => <<"10">>},
|
|
?assertEqual(ok, send_compact_push_notifications(MessageData, 7, State)),
|
|
?assertEqual(Before + 1, read_push_counter(scan_table_unavailable))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
bounded_scan_leaves_the_members_table_unfixed_test() ->
|
|
reset_push_worker_state(),
|
|
Tab = ets:new(test_members, [set, public]),
|
|
try
|
|
true = ets:insert(Tab, {1, #{<<"roles">> => []}}),
|
|
true = ets:insert(Tab, {2, #{<<"roles">> => []}}),
|
|
State = compact_scan_state(Tab),
|
|
Context = compact_scan_context(#{}, 10, State),
|
|
?assertEqual(false, ets:info(Tab, fixed)),
|
|
{ok, Scan} = scan_push_members(Tab, Context, State),
|
|
?assertEqual([1], lists:sort(maps:get(eligible_user_ids, Scan))),
|
|
?assertEqual(false, ets:info(Tab, fixed)),
|
|
?assertEqual(false, ets:info(Tab, safe_fixed_monotonic_time))
|
|
after
|
|
ets:delete(Tab),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
bounded_push_concurrency_spawns_second_worker_under_limit_test() ->
|
|
reset_push_worker_state(),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 3),
|
|
Blocker = blocking_push_worker(),
|
|
put(push_inflight, Blocker),
|
|
put(push_inflight_workers, [{1, Blocker, erlang:monotonic_time(millisecond)}]),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertNotEqual(Blocker, get(push_inflight)),
|
|
?assertEqual(2, length(get(push_inflight_workers))),
|
|
?assert(lists:member(Blocker, worker_pids(get(push_inflight_workers))))
|
|
after
|
|
stop_push_worker(Blocker),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
bounded_push_concurrency_counts_drops_at_limit_test() ->
|
|
reset_push_worker_state(),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 2),
|
|
ok = application:set_env(fluxer_gateway, ?QUEUE_LIMIT_KEY, 0),
|
|
First = blocking_push_worker(),
|
|
Second = blocking_push_worker(),
|
|
Now = erlang:monotonic_time(millisecond),
|
|
put(push_inflight, Second),
|
|
put(push_inflight_workers, [{2, Second, Now}, {1, First, Now}]),
|
|
Before = read_push_counter(dropped_at_limit),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Second, get(push_inflight)),
|
|
?assertEqual([Second, First], worker_pids(get(push_inflight_workers))),
|
|
?assertEqual(Before + 1, read_push_counter(dropped_at_limit)),
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertEqual(Before + 2, read_push_counter(dropped_at_limit)),
|
|
?assertEqual([Second, First], worker_pids(get(push_inflight_workers)))
|
|
after
|
|
stop_push_worker(First),
|
|
stop_push_worker(Second),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
bounded_push_concurrency_reaps_finished_workers_test() ->
|
|
reset_push_worker_state(),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 1),
|
|
Finished = blocking_push_worker(),
|
|
stop_push_worker(Finished),
|
|
put(push_inflight, Finished),
|
|
put(push_inflight_workers, [{1, Finished, erlang:monotonic_time(millisecond)}]),
|
|
try
|
|
?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())),
|
|
?assertNot(lists:member(Finished, worker_pids(get(push_inflight_workers))))
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
bounded_worker_list_stays_bounded_by_the_limit_test() ->
|
|
reset_push_worker_state(),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 2),
|
|
ok = application:set_env(fluxer_gateway, ?QUEUE_LIMIT_KEY, 0),
|
|
Blockers = [blocking_push_worker() || _ <- lists:seq(1, 6)],
|
|
Now = erlang:monotonic_time(millisecond),
|
|
put(push_inflight_workers, [
|
|
{Gen, Pid, Now}
|
|
|| {Gen, Pid} <- lists:zip(lists:seq(1, 6), Blockers)
|
|
]),
|
|
try
|
|
lists:foreach(
|
|
fun(_) -> ?assertEqual(ok, maybe_spawn_push(#{}, 7, legacy_test_state())) end,
|
|
lists:seq(1, 20)
|
|
),
|
|
?assert(length(get(push_inflight_workers)) =< 6)
|
|
after
|
|
lists:foreach(fun stop_push_worker/1, Blockers),
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
push_concurrency_limit_defaults_and_clamps_test() ->
|
|
reset_push_worker_state(),
|
|
try
|
|
?assertEqual(?DEFAULT_PUSH_CONCURRENCY, push_concurrency_limit()),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 0),
|
|
?assertEqual(?DEFAULT_PUSH_CONCURRENCY, push_concurrency_limit()),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, not_a_number),
|
|
?assertEqual(?DEFAULT_PUSH_CONCURRENCY, push_concurrency_limit()),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 1000),
|
|
?assertEqual(?MAX_PUSH_CONCURRENCY, push_concurrency_limit()),
|
|
ok = application:set_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY, 6),
|
|
?assertEqual(6, push_concurrency_limit())
|
|
after
|
|
reset_push_worker_state()
|
|
end.
|
|
|
|
legacy_test_state() ->
|
|
#{id => 7, data => #{}, sessions => #{}}.
|
|
|
|
worker_pids(Workers) ->
|
|
[Pid || {_Gen, Pid, _StartedMs} <- Workers].
|
|
|
|
wait_for_counter(Key, Target) ->
|
|
wait_for_counter(Key, Target, 200).
|
|
|
|
wait_for_counter(Key, Target, 0) ->
|
|
?assertEqual(Target, read_push_counter(Key));
|
|
wait_for_counter(Key, Target, Retries) ->
|
|
case read_push_counter(Key) >= Target of
|
|
true ->
|
|
ok;
|
|
false ->
|
|
timer:sleep(5),
|
|
wait_for_counter(Key, Target, Retries - 1)
|
|
end.
|
|
|
|
remote_test_pid() ->
|
|
binary_to_term(<<131, 88, 119, 12, "fake@nowhere", 1:32, 0:32, 1:32>>).
|
|
|
|
read_push_counter(Key) ->
|
|
try ets:lookup(?PUSH_COUNTERS, Key) of
|
|
[{Key, Value}] when is_integer(Value) -> Value;
|
|
_ -> 0
|
|
catch
|
|
error:badarg -> 0
|
|
end.
|
|
|
|
blocking_push_worker() ->
|
|
spawn(fun() ->
|
|
receive
|
|
stop -> ok
|
|
end
|
|
end).
|
|
|
|
stop_push_worker(Pid) ->
|
|
Ref = monitor(process, Pid),
|
|
Pid ! stop,
|
|
receive
|
|
{'DOWN', Ref, process, Pid, _Reason} -> ok
|
|
after 5000 -> error(push_worker_did_not_stop)
|
|
end.
|
|
|
|
reset_push_worker_state() ->
|
|
erase(push_inflight),
|
|
erase(push_inflight_workers),
|
|
ok = application:unset_env(fluxer_gateway, ?CONCURRENCY_LIMIT_KEY).
|
|
|
|
compact_test_data(Tab) ->
|
|
#{
|
|
<<"guild">> => #{
|
|
<<"id">> => <<"7">>,
|
|
<<"owner_id">> => <<"999">>,
|
|
<<"features">> => [<<"COMMUNITY">>],
|
|
<<"default_message_notifications">> => 0
|
|
},
|
|
<<"roles">> => [],
|
|
<<"role_index">> => #{},
|
|
<<"channels">> => [#{<<"id">> => <<"10">>, <<"name">> => <<"general">>}],
|
|
<<"channel_index">> => #{10 => #{<<"id">> => <<"10">>, <<"name">> => <<"general">>}},
|
|
<<"members">> => #{1 => #{}, 2 => #{}, 3 => #{}},
|
|
members_normalized => #{1 => #{}, 2 => #{}, 3 => #{}},
|
|
<<"member_role_index">> => #{},
|
|
members_ets => Tab
|
|
}.
|
|
|
|
compact_scan_state(Tab) ->
|
|
#{
|
|
id => 7,
|
|
data => compact_push_data(compact_test_data(Tab)),
|
|
virtual_channel_access => #{
|
|
1 => sets:from_list([10]),
|
|
3 => sets:from_list([10])
|
|
},
|
|
members_ets => Tab,
|
|
session_eligibility => #{1 => true, 2 => true, 3 => false},
|
|
member_count => 49435
|
|
}.
|
|
|
|
-endif.
|