feat(threads): gateway, messages and push thread support (#3250)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:24:40 +02:00
1 parent 4e730832c7
commit 5f4295e399
108 files changed
+15857 -176

No files matched your search

@@ -0,0 +1,331 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(channel_threads_config_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(KEY, channel_threads_config).
-define(PULLED_KEY, {channel_threads_config, pulled}).
config_fails_closed_when_nothing_is_stored_test() ->
with_stored(undefined, fun() ->
?assertEqual(channel_threads_config:default_config(), channel_threads_config:config()),
?assertEqual(0, channel_threads_config:version()),
?assertNot(channel_threads_config:ever_enabled()),
?assertNot(channel_threads_config:guild_active(1)),
?assertNot(channel_threads_config:user_active(1)),
?assertNot(channel_threads_config:user_excluded(1))
end).
validate_accepts_a_full_wire_config_test() ->
{ok, Config} = channel_threads_config:validate_config(full_wire()),
?assertMatch(
#{
enabled := true,
config_version := 7,
ever_enabled := true,
guild_basis_points := 250,
guild_salt := <<"guild salt">>,
user_basis_points := 10000,
user_salt := <<"user-salt">>
},
Config
),
?assertEqual(#{<<"10">> => true, <<"11">> => true}, maps:get(enabled_guilds, Config)),
?assertEqual(#{<<"12">> => true}, maps:get(disabled_guilds, Config)),
?assertEqual(#{<<"20">> => true}, maps:get(included_users, Config)),
?assertEqual(#{<<"21">> => true}, maps:get(excluded_users, Config)).
validate_fills_defaults_and_ignores_unknown_keys_test() ->
{ok, Config} = channel_threads_config:validate_config(#{
<<"enabled">> => true, <<"future_field">> => 1
}),
Default = channel_threads_config:default_config(),
?assertEqual(Default#{enabled := true}, Config),
?assertEqual(<<"channel-threads-guild-v1">>, maps:get(guild_salt, Config)),
?assertEqual(<<"channel-threads-user-v1">>, maps:get(user_salt, Config)).
validate_rejects_every_malformed_field_test() ->
Invalid = [
{<<"enabled">>, <<"true">>},
{<<"ever_enabled">>, 1},
{<<"config_version">>, -1},
{<<"config_version">>, 1.5},
{<<"guild_basis_points">>, 10001},
{<<"user_basis_points">>, -1},
{<<"guild_salt">>, <<>>},
{<<"user_salt">>, binary:copy(<<"a">>, 65)},
{<<"guild_salt">>, <<"caf", 16#c3, 16#a9>>},
{<<"user_salt">>, <<"tab\there">>},
{<<"enabled_guild_ids">>, <<"1">>},
{<<"disabled_guild_ids">>, [1]},
{<<"included_user_ids">>, [<<"12a">>]},
{<<"excluded_user_ids">>, [<<>>]},
{<<"included_user_ids">>, [binary:copy(<<"1">>, 21)]},
{<<"enabled_guild_ids">>, [integer_to_binary(N) || N <- lists:seq(1, 1001)]}
],
lists:foreach(
fun({Key, Value} = Case) ->
?assertMatch(
{Case, {error, {invalid_field, Key, _}}},
{Case, channel_threads_config:validate_config(#{Key => Value})}
)
end,
Invalid
).
validate_rejection_never_carries_the_ids_test() ->
Ids = [integer_to_binary(N) || N <- lists:seq(1, 1001)],
?assertEqual(
{error, {invalid_field, <<"excluded_user_ids">>, 1001}},
channel_threads_config:validate_config(#{<<"excluded_user_ids">> => Ids})
),
?assertEqual(
{error, {invalid_field, <<"enabled_guild_ids">>, 2}},
channel_threads_config:validate_config(#{<<"enabled_guild_ids">> => [<<"1">>, <<"x">>]})
),
?assertEqual(
{error, {invalid_field, <<"guild_salt">>, undefined}},
channel_threads_config:validate_config(#{<<"guild_salt">> => <<>>})
).
validate_accepts_the_boundaries_test() ->
{ok, _} = channel_threads_config:validate_config(#{
<<"guild_basis_points">> => 10000,
<<"user_basis_points">> => 0,
<<"guild_salt">> => binary:copy(<<"~">>, 64),
<<"included_user_ids">> => [<<"18446744073709551615">>],
<<"disabled_guild_ids">> => [integer_to_binary(N) || N <- lists:seq(1, 1000)]
}).
guild_precedence_is_excluded_then_included_then_bucket_test() ->
Config = config(#{
<<"guild_basis_points">> => 10000,
<<"enabled_guild_ids">> => [<<"5">>],
<<"disabled_guild_ids">> => [<<"5">>, <<"6">>]
}),
?assertNot(channel_threads_config:guild_active(Config, 5)),
?assertNot(channel_threads_config:guild_active(Config, 6)),
?assert(channel_threads_config:guild_active(Config, 7)),
Included = config(#{<<"enabled_guild_ids">> => [<<"8">>]}),
?assert(channel_threads_config:guild_active(Included, 8)),
?assertNot(channel_threads_config:guild_active(Included, 9)).
guild_bucket_threshold_matches_the_shared_vectors_test() ->
?assertNot(
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 6741}), 1)
),
?assert(
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 6742}), 1)
),
?assertNot(
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 9122}), 0)
),
?assert(
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 9123}), 0)
).
user_bucket_threshold_matches_the_shared_vectors_test() ->
?assertNot(
channel_threads_config:user_active(config(#{<<"user_basis_points">> => 3403}), 1)
),
?assert(channel_threads_config:user_active(config(#{<<"user_basis_points">> => 3404}), 1)).
user_precedence_and_exclusion_test() ->
Config = config(#{
<<"user_basis_points">> => 10000,
<<"included_user_ids">> => [<<"1">>],
<<"excluded_user_ids">> => [<<"1">>, <<"2">>]
}),
?assertNot(channel_threads_config:user_active(Config, 1)),
?assertNot(channel_threads_config:user_active(Config, 2)),
?assert(channel_threads_config:user_active(Config, 3)),
?assert(channel_threads_config:user_excluded(Config, 2)),
?assertNot(channel_threads_config:user_excluded(Config, 3)).
disabled_config_is_inactive_for_everyone_test() ->
{ok, Config} = channel_threads_config:validate_config(
(full_wire())#{<<"enabled">> => false}
),
?assertNot(channel_threads_config:guild_active(Config, 10)),
?assertNot(channel_threads_config:user_active(Config, 20)),
?assert(channel_threads_config:user_excluded(Config, 21)).
zero_basis_points_never_buckets_in_test() ->
Config = config(#{}),
?assertEqual([], [
Id
|| Id <- lists:seq(1, 2000), channel_threads_config:guild_active(Config, Id)
]),
?assertEqual([], [
Id
|| Id <- lists:seq(1, 2000), channel_threads_config:user_active(Config, Id)
]).
store_keeps_the_version_monotonic_test() ->
with_stored(undefined, fun() ->
V5 = config(#{<<"config_version">> => 5}),
V4 = config(#{<<"config_version">> => 4, <<"enabled">> => false}),
Default = channel_threads_config:default_config(),
?assertEqual(updated, channel_threads_config:store_validated_config(Default, V5, nats)),
?assertEqual(5, channel_threads_config:version()),
?assertEqual(stale, channel_threads_config:store_validated_config(V5, V4, api)),
?assertEqual(5, channel_threads_config:version()),
?assert(maps:get(enabled, channel_threads_config:config())),
?assertEqual(unchanged, channel_threads_config:store_validated_config(V5, V5, api))
end).
store_keeps_ever_enabled_sticky_test() ->
with_stored(undefined, fun() ->
Enabled = config(#{<<"config_version">> => 1, <<"ever_enabled">> => true}),
Killed = config(#{
<<"config_version">> => 2, <<"enabled">> => false, <<"ever_enabled">> => false
}),
Default = channel_threads_config:default_config(),
updated = channel_threads_config:store_validated_config(Default, Enabled, api),
updated = channel_threads_config:store_validated_config(Enabled, Killed, api),
?assert(channel_threads_config:ever_enabled()),
?assertNot(maps:get(enabled, channel_threads_config:config())),
?assertEqual(
unchanged,
channel_threads_config:store_validated_config(
channel_threads_config:config(), Killed, api
)
)
end).
nats_payload_is_validated_before_it_is_stored_test() ->
with_stored(undefined, fun() ->
Payload = iolist_to_binary(
json:encode(#{
<<"type">> => <<"channel_threads_config">>,
<<"config">> => #{<<"enabled">> => true, <<"config_version">> => 3}
})
),
?assertEqual(updated, channel_threads_config:apply_nats_payload(Payload)),
?assertEqual(3, channel_threads_config:version()),
Bad = iolist_to_binary(
json:encode(#{
<<"config">> => #{<<"enabled">> => <<"yes">>, <<"config_version">> => 9}
})
),
?assertEqual(rejected, channel_threads_config:apply_nats_payload(Bad)),
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"not json">>)),
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"[1]">>)),
?assertEqual(3, channel_threads_config:version())
end).
a_default_pull_counts_as_loaded_test() ->
with_stored(undefined, fun() ->
?assertNot(channel_threads_config:loaded()),
Default = iolist_to_binary(
json:encode(#{<<"config">> => #{<<"enabled">> => false, <<"config_version">> => 0}})
),
?assertEqual(unchanged, channel_threads_config:apply_nats_payload(Default)),
?assertEqual(channel_threads_config:default_config(), channel_threads_config:config()),
?assert(channel_threads_config:loaded()),
?assertNot(channel_threads_config:enabled())
end).
a_rejected_pull_is_not_loaded_test() ->
with_stored(undefined, fun() ->
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"not json">>)),
?assertNot(channel_threads_config:loaded())
end).
fields_changed_are_split_by_dimension_test() ->
Base = config(#{}),
GuildOnly = config(#{<<"enabled_guild_ids">> => [<<"1">>]}),
UserOnly = config(#{<<"user_basis_points">> => 5}),
VersionOnly = config(#{<<"config_version">> => 9}),
Disabled = config(#{<<"enabled">> => false}),
?assert(channel_threads_config:guild_fields_changed(Base, GuildOnly)),
?assertNot(channel_threads_config:user_fields_changed(Base, GuildOnly)),
?assert(channel_threads_config:user_fields_changed(Base, UserOnly)),
?assertNot(channel_threads_config:guild_fields_changed(Base, UserOnly)),
?assertNot(channel_threads_config:guild_fields_changed(Base, VersionOnly)),
?assertNot(channel_threads_config:user_fields_changed(Base, VersionOnly)),
?assert(channel_threads_config:guild_fields_changed(Base, Disabled)),
?assert(channel_threads_config:user_fields_changed(Base, Disabled)).
identify_os_label_buckets_client_strings_test() ->
Cases = [
{<<"Windows">>, <<"windows">>},
{<<"Mac OS X">>, <<"macos">>},
{<<"macOS">>, <<"macos">>},
{<<"Darwin">>, <<"macos">>},
{<<"Linux">>, <<"linux">>},
{<<"Android">>, <<"android">>},
{<<"iOS">>, <<"ios">>},
{<<"iPadOS">>, <<"ios">>},
{<<"FreeBSD">>, <<"linux">>},
{<<"TempleOS">>, <<"other">>},
{<<>>, <<"other">>},
{undefined, <<"other">>}
],
lists:foreach(
fun({Input, Expected}) ->
?assertEqual(
{Input, Expected}, {Input, channel_threads_config:identify_os_label(Input)}
)
end,
Cases
).
note_identify_counts_by_capability_and_os_test() ->
Key = {channel_threads_config, identify_counts},
Previous = persistent_term:get(Key, undefined),
persistent_term:put(Key, counters:new(12, [write_concurrency])),
try
ok = channel_threads_config:note_identify(true, #{<<"os">> => <<"Windows">>}),
ok = channel_threads_config:note_identify(true, #{<<"os">> => <<"Windows">>}),
ok = channel_threads_config:note_identify(false, #{<<"os">> => <<"Android">>}),
ok = channel_threads_config:note_identify(false, #{}),
Counts = channel_threads_config:identify_counts(),
?assertEqual(12, map_size(Counts)),
?assertEqual(2, maps:get({true, <<"windows">>}, Counts)),
?assertEqual(1, maps:get({false, <<"android">>}, Counts)),
?assertEqual(1, maps:get({false, <<"other">>}, Counts)),
?assertEqual(0, maps:get({true, <<"android">>}, Counts))
after
restore(Key, Previous)
end.
config(Overrides) ->
{ok, Config} = channel_threads_config:validate_config(
maps:merge(#{<<"enabled">> => true}, Overrides)
),
Config.
full_wire() ->
#{
<<"enabled">> => true,
<<"config_version">> => 7,
<<"ever_enabled">> => true,
<<"guild_basis_points">> => 250,
<<"guild_salt">> => <<"guild salt">>,
<<"enabled_guild_ids">> => [<<"10">>, <<"11">>],
<<"disabled_guild_ids">> => [<<"12">>],
<<"user_basis_points">> => 10000,
<<"user_salt">> => <<"user-salt">>,
<<"included_user_ids">> => [<<"20">>],
<<"excluded_user_ids">> => [<<"21">>]
}.
with_stored(Value, Fun) ->
Previous = persistent_term:get(?KEY, undefined),
PreviousPulled = persistent_term:get(?PULLED_KEY, undefined),
restore(?KEY, Value),
restore(?PULLED_KEY, undefined),
try
Fun()
after
restore(?KEY, Previous),
restore(?PULLED_KEY, PreviousPulled)
end.
restore(Key, undefined) ->
_ = persistent_term:erase(Key),
ok;
restore(Key, Value) ->
persistent_term:put(Key, Value).
@@ -0,0 +1,296 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(channel_threads_flip_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
guild_fan_out_touches_only_guilds_whose_activity_changed_test() ->
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>, <<"2">>]}),
Current = config(#{<<"enabled_guild_ids">> => [<<"2">>, <<"3">>]}),
Candidates = [{guild, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{user, 1, self()}],
?assertEqual(
[{guild, 1, self()}, {guild, 3, self()}],
channel_threads_flip:affected_targets(Previous, Current, Candidates)
).
user_fan_out_touches_only_users_whose_activity_or_exclusion_changed_test() ->
Previous = config(#{<<"included_user_ids">> => [<<"1">>]}),
Current = config(#{
<<"included_user_ids">> => [<<"1">>, <<"2">>], <<"excluded_user_ids">> => [<<"3">>]
}),
Candidates = [{user, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{guild, 2, self()}],
?assertEqual(
[{user, 2, self()}, {user, 3, self()}],
channel_threads_flip:affected_targets(Previous, Current, Candidates)
).
user_only_change_also_recomputes_active_guilds_test() ->
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>]}),
Current = Previous#{excluded_users := #{<<"7">> => true}, config_version := 2},
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
?assertEqual(
[{viewers, 1, self()}, {user, 7, self()}],
channel_threads_flip:affected_targets(Previous, Current, Candidates)
).
guild_flip_is_not_queued_behind_a_viewer_sweep_test() ->
Sweep = [{viewers, Id, self()} || Id <- lists:seq(1, 100)],
Pending0 = channel_threads_flip:schedule(Sweep, 0, empty(), fun() -> 0 end),
Pending1 = channel_threads_flip:schedule([{guild, 500, self()}], 10, Pending0, fun() ->
0
end),
{Due, _Pending2} = channel_threads_flip:take_due(Pending1, 10, limits(10, 1000)),
?assert(lists:member({guild, 500, self()}, Due)),
?assertEqual(10, length([T || {viewers, _, _} = T <- Due])).
first_load_flips_guilds_but_not_users_test() ->
Previous = channel_threads_config:default_config(),
Current = config(#{
<<"config_version">> => 3,
<<"enabled_guild_ids">> => [<<"1">>],
<<"user_basis_points">> => 10000
}),
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
?assertEqual(
[{guild, 1, self()}],
channel_threads_flip:affected_targets(Previous, Current, Candidates)
).
version_only_change_touches_nothing_test() ->
Previous = config(#{<<"guild_basis_points">> => 10000, <<"user_basis_points">> => 10000}),
Current = Previous#{config_version := 42},
Candidates =
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
[{user, Id, self()} || Id <- lists:seq(1, 50)],
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
changes_while_disabled_touch_nothing_test() ->
Previous = config(#{<<"enabled">> => false}),
Current = config(#{
<<"enabled">> => false,
<<"enabled_guild_ids">> => [<<"1">>],
<<"included_user_ids">> => [<<"1">>],
<<"excluded_user_ids">> => [<<"2">>]
}),
Candidates = [{guild, 1, self()}, {user, 1, self()}, {user, 2, self()}],
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
disabled_config_change_skips_registry_scan_test() ->
process_registry:init(),
Entries = [{{guild, 9200001}, self()}, {{presence, 9200001}, self()}],
ets:insert(process_registry_table, Entries),
try
Previous = config(#{<<"enabled">> => false}),
Current = config(#{
<<"enabled">> => false,
<<"enabled_guild_ids">> => [<<"9200001">>],
<<"included_user_ids">> => [<<"9200001">>]
}),
State0 = #{pending => empty(), timer => undefined, healed => #{}},
?assertEqual(
{noreply, State0},
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0)
)
after
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries]
end.
global_kill_flips_every_active_guild_test() ->
Previous = config(#{<<"guild_basis_points">> => 10000}),
Current = Previous#{enabled := false, config_version := 2},
Candidates = [{guild, Id, self()} || Id <- lists:seq(1, 20)],
?assertEqual(
Candidates, channel_threads_flip:affected_targets(Previous, Current, Candidates)
).
schedule_applies_jitter_and_dedupes_targets_test() ->
Jitters = counter_jitter([500, 100, 900]),
Pending0 = channel_threads_flip:schedule(
[{guild, 1, self()}, {guild, 2, self()}, {guild, 1, self()}, {user, 1, self()}],
1000,
empty(),
Jitters
),
?assertEqual(#{{guild, 1} => 1500, {guild, 2} => 1100, {user, 1} => 1900}, keys(Pending0)),
{Due, Pending1} = channel_threads_flip:take_due(Pending0, 1600, limits(10, 10)),
?assertEqual([{guild, 2, self()}, {guild, 1, self()}], Due),
?assertEqual(#{{user, 1} => 1900}, keys(Pending1)).
rescheduling_a_restarted_process_keeps_its_slot_and_takes_the_new_pid_test() ->
Stale = spawn(fun() -> ok end),
Pending0 = channel_threads_flip:schedule([{guild, 1, Stale}], 0, empty(), fun() -> 500 end),
Pending1 = channel_threads_flip:schedule([{guild, 1, self()}], 100, Pending0, fun() ->
900
end),
?assertEqual(#{{guild, 1} => 500}, keys(Pending1)),
?assertEqual(
{[{guild, 1, self()}], empty()},
channel_threads_flip:take_due(Pending1, 500, limits(10, 10))
).
take_due_respects_the_batch_size_test() ->
Pending0 = channel_threads_flip:schedule(
[{guild, Id, self()} || Id <- lists:seq(1, 25)], 0, empty(), fun() -> 0 end
),
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
?assertEqual([10, 10, 5], [length(First), length(Second), length(Third)]),
?assertEqual(#{}, keys(Pending3)),
?assertEqual(
lists:seq(1, 25), lists:sort([Id || {guild, Id, _} <- First ++ Second ++ Third])
).
user_flips_drain_in_large_batches_behind_throttled_guilds_test() ->
Targets =
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
[{user, Id, self()} || Id <- lists:seq(1, 2500)],
Pending0 = channel_threads_flip:schedule(Targets, 0, empty(), fun() -> 0 end),
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
?assertEqual({10, 1000}, kind_counts(First)),
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
?assertEqual([{10, 1000}, {10, 500}], [kind_counts(Second), kind_counts(Third)]),
?assertEqual(20, map_size(keys(Pending3))),
?assert(lists:all(fun({Kind, _Id}) -> Kind =:= guild end, maps:keys(keys(Pending3)))).
self_heal_flips_immediately_once_per_cooldown_test() ->
Pending0 = channel_threads_flip:schedule(
[{guild, 7, self()}, {guild, 8, self()}], 0, empty(), fun() -> 50000 end
),
State0 = #{pending => Pending0, timer => undefined, healed => #{}},
{Healed1, Pending1} = channel_threads_flip:heal(7, self(), 1000, State0),
?assertEqual(ok, receive_flip()),
?assertEqual(#{{guild, 8} => 50000}, keys(Pending1)),
State1 = State0#{healed := Healed1, pending := Pending1},
{Healed2, Pending2} = channel_threads_flip:heal(7, self(), 20000, State1),
?assertEqual(none, receive_flip()),
?assertEqual(Pending1, Pending2),
{_Healed3, _Pending3} = channel_threads_flip:heal(
7, self(), 40000, State1#{healed := Healed2}
),
?assertEqual(ok, receive_flip()).
config_change_flips_only_local_registry_targets_test() ->
process_registry:init(),
Parent = self(),
[LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session] =
[spawn(fun() -> forward(Parent) end) || _ <- lists:seq(1, 5)],
Remote = remote_pid(),
Entries = [
{{guild, 9100001}, LocalGuild},
{{guild, 9100002}, Remote},
{{guild, 9100003}, OtherGuild},
{{guild, 9100004}, loading},
{{guild, <<"9100005">>}, OtherGuild},
{{presence, 9100001}, LocalUser},
{{presence, 9100002}, ExcludedUser},
{{presence, 9100003}, Remote},
{{session, 9100001}, Session}
],
ets:insert(process_registry_table, Entries),
try
Previous = config(#{}),
Current = config(#{
<<"enabled_guild_ids">> => [<<"9100001">>, <<"9100002">>, <<"9100004">>],
<<"included_user_ids">> => [<<"9100001">>, <<"9100003">>],
<<"excluded_user_ids">> => [<<"9100002">>]
}),
State0 = #{pending => empty(), timer => undefined, healed => #{}},
{noreply, #{pending := Scheduled, timer := Timer}} =
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0),
_ = erlang:cancel_timer(Timer),
Expected = [
{guild, 9100001, LocalGuild},
{user, 9100001, LocalUser},
{user, 9100002, ExcludedUser}
],
?assertEqual(lists:sort(Expected), lists:sort(ours(Scheduled))),
Due = channel_threads_flip:schedule(
Expected, erlang:monotonic_time(millisecond), empty(), fun() -> 0 end
),
{noreply, #{pending := Rest}} =
channel_threads_flip:handle_info(flip_tick, State0#{pending := Due}),
?assertEqual(#{}, keys(Rest)),
Version = channel_threads_config:version(),
?assertEqual(
lists:sort([
{LocalGuild, {thread_gate_flip, Version}},
{LocalUser, {thread_user_flip, Version}},
{ExcludedUser, {thread_user_flip, Version}}
]),
lists:sort(collect(3))
),
?assertEqual([], collect(1))
after
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries],
[exit(Pid, kill) || Pid <- [LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session]]
end.
ours(#{due := Due}) ->
[
{Kind, Id, Pid}
|| KindDue <- maps:values(Due),
{_At, {Kind, Id}, Pid} <- gb_sets:to_list(KindDue),
Id div 100 =:= 91000
].
limits(Guilds, Users) ->
#{guild => Guilds, viewers => Guilds, user => Users}.
kind_counts(Targets) ->
{length([T || {guild, _, _} = T <- Targets]), length([T || {user, _, _} = T <- Targets])}.
forward(Parent) ->
receive
Msg ->
Parent ! {self(), Msg},
forward(Parent)
end.
collect(0) ->
[];
collect(N) ->
receive
{Pid, {Tag, _} = Msg} when
is_pid(Pid), (Tag =:= thread_gate_flip orelse Tag =:= thread_user_flip)
->
[{Pid, Msg} | collect(N - 1)]
after 200 -> []
end.
remote_pid() ->
Node = <<"remote@nowhere">>,
binary_to_term(
<<131, 88, 119, (byte_size(Node)), Node/binary, 1:32, 0:32, 1:32>>
).
receive_flip() ->
receive
{thread_gate_flip, Version} when is_integer(Version) -> ok
after 0 -> none
end.
counter_jitter(Values) ->
Ref = make_ref(),
put(Ref, Values),
fun() ->
[Next | Rest] = get(Ref),
put(Ref, Rest),
Next
end.
empty() ->
#{
due => #{guild => gb_sets:new(), viewers => gb_sets:new(), user => gb_sets:new()},
keys => #{}
}.
keys(#{keys := Keys}) ->
maps:map(fun(_Key, {At, _Pid}) -> At end, Keys).
config(Overrides) ->
{ok, Config} = channel_threads_config:validate_config(
maps:merge(#{<<"enabled">> => true}, Overrides)
),
Config.
@@ -0,0 +1,41 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(experiment_bucket_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(VECTORS_PATH, "../packages/schema/src/domains/experiment/ExperimentBucketVectors.json").
bucket_matches_shared_vectors_test() ->
Vectors = read_vectors(),
?assertMatch([_ | _], Vectors),
lists:foreach(fun run_vector/1, Vectors).
bucket_stays_below_resolution_test() ->
Resolution = experiment_bucket:resolution(),
lists:foreach(
fun(N) ->
Bucket = experiment_bucket:bucket(integer_to_binary(N), <<"range-check">>),
?assert(Bucket >= 0 andalso Bucket < Resolution)
end,
lists:seq(0, 500)
).
read_vectors() ->
case file:read_file(?VECTORS_PATH) of
{ok, Contents} -> decode_vectors(Contents);
{error, Reason} -> erlang:error({bucket_vectors_unreadable, ?VECTORS_PATH, Reason})
end.
decode_vectors(Contents) ->
case json:decode(Contents) of
[_ | _] = Vectors -> Vectors;
_ -> erlang:error({bucket_vectors_empty, ?VECTORS_PATH})
end.
run_vector([Salt, Id, Expected] = Vector) when
is_binary(Salt), is_binary(Id), is_integer(Expected)
->
?assertEqual({Vector, Expected}, {Vector, experiment_bucket:bucket(Id, Salt)});
run_vector(Vector) ->
erlang:error({bucket_vector_malformed, Vector}).
@@ -518,6 +518,125 @@ websocket_handle_rejects_compressed_frame_past_max_payload_test() ->
?assertEqual(constants:close_code_to_num(decode_error), CloseCode),
?assertEqual(<<"Payload too large">>, Reason).
forum_unreads_gate_off_closes_like_an_unknown_opcode_test() ->
Previous = persistent_term:get(channel_threads_config, undefined),
persistent_term:put(
channel_threads_config, (channel_threads_config:default_config())#{config_version => 1}
),
try
State = (new_json_state())#{session_pid => self()},
Unknown = gateway_handler_dispatch:handle_opcode(
not_an_opcode, #{<<"d">> => #{}}, State
),
?assertEqual({[{close, 4001, <<"Unknown opcode">>}], State}, Unknown),
?assertEqual(
Unknown,
gateway_handler_dispatch:handle_opcode(
request_forum_unreads,
#{<<"d">> => #{<<"guild_id">> => <<"1">>, <<"channel_id">> => <<"2">>}},
State
)
),
?assertEqual(
Unknown, gateway_handler_dispatch:handle_request_forum_unreads(#{}, self(), State)
),
?assertEqual(
Unknown, gateway_handler:websocket_info(forum_unreads_unknown_opcode, State)
)
after
case Previous of
undefined -> persistent_term:erase(channel_threads_config);
_ -> persistent_term:put(channel_threads_config, Previous)
end
end.
forum_unreads_closes_like_an_unknown_opcode_once_a_default_config_is_pulled_test() ->
Key = channel_threads_config,
PulledKey = {channel_threads_config, pulled},
Previous = persistent_term:get(Key, undefined),
PreviousPulled = persistent_term:get(PulledKey, undefined),
persistent_term:put(Key, channel_threads_config:default_config()),
persistent_term:put(PulledKey, true),
try
Bot = fake_session(#{user_id => <<"6">>, bot => true, thread_channels_capable => true}),
State = (new_json_state())#{session_pid => Bot},
Unknown = {[{close, 4001, <<"Unknown opcode">>}], State},
?assertEqual(
Unknown,
gateway_handler_dispatch:handle_opcode(
request_forum_unreads,
#{<<"d">> => #{<<"guild_id">> => <<"1">>, <<"channel_id">> => <<"2">>}},
State
)
),
?assertEqual(
Unknown, gateway_handler_dispatch:handle_request_forum_unreads(#{}, Bot, State)
),
?assertNot(received_unknown_opcode())
after
case Previous of
undefined -> persistent_term:erase(Key);
_ -> persistent_term:put(Key, Previous)
end,
case PreviousPulled of
undefined -> persistent_term:erase(PulledKey);
_ -> persistent_term:put(PulledKey, PreviousPulled)
end
end.
forum_unreads_gate_on_closes_non_viewers_on_every_path_test() ->
with_threads_enabled(fun() ->
NonViewer = fake_session(#{user_id => <<"5">>, thread_channels_capable => false}),
State = (new_json_state())#{session_pid => NonViewer},
{ok, _} = gateway_handler_dispatch:handle_request_forum_unreads(#{}, NonViewer, State),
?assert(received_unknown_opcode()),
Full = State#{request_worker_max => 1, request_workers => #{make_ref() => #{}}},
{ok, Full} = gateway_handler_dispatch:handle_request_forum_unreads(
#{}, NonViewer, Full
),
?assert(received_unknown_opcode()),
Dead = spawn(fun() -> ok end),
DeadState = State#{session_pid => Dead},
{ok, _} = gateway_handler_dispatch:handle_request_forum_unreads(#{}, Dead, DeadState),
?assert(received_unknown_opcode()),
Viewer = fake_session(#{user_id => <<"6">>, bot => true}),
ViewerFull = Full#{session_pid => Viewer},
{ok, ViewerFull} = gateway_handler_dispatch:handle_request_forum_unreads(
#{}, Viewer, ViewerFull
),
?assertNot(received_unknown_opcode())
end).
with_threads_enabled(Fun) ->
Previous = persistent_term:get(channel_threads_config, undefined),
persistent_term:put(
channel_threads_config, (channel_threads_config:default_config())#{enabled => true}
),
try
Fun()
after
case Previous of
undefined -> persistent_term:erase(channel_threads_config);
_ -> persistent_term:put(channel_threads_config, Previous)
end
end.
fake_session(SessionState) ->
spawn(fun Loop() ->
receive
{'$gen_call', From, {get_state}} ->
gen_server:reply(From, SessionState),
Loop()
after 2000 -> ok
end
end).
received_unknown_opcode() ->
receive
forum_unreads_unknown_opcode -> true
after 300 -> false
end.
new_json_state() ->
(gateway_handler:new_state())#{
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
@@ -25,3 +25,31 @@ resolve_channel_mentions_skips_invalid_channel_ids_test() ->
#{<<"channels">> => []},
gateway_rpc_guild_channels:handle(<<"guild.resolve_channel_mentions">>, Params)
).
resolve_channel_mentions_from_snapshot_skips_forum_and_media_test() ->
GuildId = 7301,
ok = guild_permission_cache:put_data(GuildId, #{
<<"guild">> => #{<<"owner_id">> => <<"1">>},
<<"roles">> => [#{<<"id">> => <<"7301">>, <<"permissions">> => <<"1024">>}],
<<"members">> => [],
<<"channels">> => [
#{<<"id">> => <<"10">>, <<"name">> => <<"text">>, <<"type">> => 0},
#{<<"id">> => <<"11">>, <<"name">> => <<"forum">>, <<"type">> => 15},
#{<<"id">> => <<"12">>, <<"name">> => <<"media">>, <<"type">> => 16}
]
}),
try
Params = #{
<<"guild_id">> => <<"7301">>, <<"channel_ids">> => [<<"10">>, <<"11">>, <<"12">>]
},
?assertEqual(
#{
<<"channels">> => [
#{<<"id">> => <<"10">>, <<"name">> => <<"text">>, <<"type">> => 0}
]
},
gateway_rpc_guild_channels:handle(<<"guild.resolve_channel_mentions">>, Params)
)
after
guild_permission_cache:delete(GuildId)
end.
@@ -0,0 +1,115 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_rpc_guild_lifecycle_tests).
-include_lib("eunit/include/eunit.hrl").
with_guild_pid(Fun) ->
meck:new(gateway_rpc_guild_infra, [passthrough, no_link]),
meck:expect(gateway_rpc_guild_infra, with_guild_unchecked, fun(_GuildId, GuildFun) ->
GuildFun(self())
end),
try
Fun()
after
meck:unload(gateway_rpc_guild_infra)
end.
dispatch_many_casts_every_event_in_order_test() ->
with_guild_pid(fun() ->
Params = #{
<<"guild_id">> => <<"1">>,
<<"events">> => [
#{<<"event">> => <<"THREAD_UPDATE">>, <<"data">> => #{<<"id">> => <<"5">>}},
#{
<<"event">> => <<"THREAD_MEMBER_UPDATE">>,
<<"data">> => #{<<"id">> => <<"5">>}
},
#{<<"event">> => <<"MESSAGE_CREATE">>, <<"data">> => #{<<"id">> => <<"6">>}}
]
},
?assertEqual(
true, gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
),
receive
{'$gen_cast', {dispatch_many, Requests}} ->
?assertEqual(
[
#{event => thread_update, data => #{<<"id">> => <<"5">>}},
#{event => thread_member_update, data => #{<<"id">> => <<"5">>}},
#{event => message_create, data => #{<<"id">> => <<"6">>}}
],
Requests
)
after 1000 -> ?assert(false)
end
end).
dispatch_many_rejects_malformed_payloads_without_casting_test() ->
with_guild_pid(fun() ->
Malformed = [
#{
<<"guild_id">> => <<"1">>,
<<"events">> => [#{<<"event">> => <<"THREAD_UPDATE">>}]
},
#{
<<"guild_id">> => <<"1">>,
<<"events">> => [
#{<<"event">> => <<"THREAD_UPDATE">>, <<"data">> => #{}},
#{<<"event">> => 7, <<"data">> => #{}}
]
},
#{<<"guild_id">> => <<"1">>, <<"events">> => #{}},
#{<<"guild_id">> => <<"1">>}
],
lists:foreach(
fun(Params) ->
?assertError(
{gateway_rpc_error, validation_invalid_params},
gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
)
end,
Malformed
),
?assertError(
{validation, validation_invalid_snowflake},
gateway_rpc_guild_lifecycle:handle(
<<"guild.dispatch_many">>, #{<<"guild_id">> => <<"0">>, <<"events">> => []}
)
),
receive
{'$gen_cast', {dispatch_many, _}} -> ?assert(false)
after 50 -> ok
end
end).
dispatch_many_reaches_an_overloaded_guild_test() ->
GuildId = 987654321,
guild_manager_cache:ensure_guild_pid_cache(),
ok = guild_ets_owner:ensure_table(guild_health_status, [named_table, public, set]),
Pending = {make_ref(), erlang:monotonic_time(millisecond), 2500},
true = ets:insert(guild_pid_cache, {GuildId, self()}),
true = ets:insert(guild_health_status, {self(), GuildId, true, undefined, Pending}),
try
?assert(guild_health:is_overloaded(self())),
Params = #{
<<"guild_id">> => integer_to_binary(GuildId),
<<"events">> => [
#{<<"event">> => <<"THREAD_CREATE">>, <<"data">> => #{<<"id">> => <<"5">>}},
#{
<<"event">> => <<"THREAD_MEMBER_UPDATE">>,
<<"data">> => #{<<"id">> => <<"5">>}
}
]
},
?assertEqual(
true, gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
),
receive
{'$gen_cast', {dispatch_many, Requests}} -> ?assertEqual(2, length(Requests))
after 1000 -> ?assert(false)
end
after
ets:delete(guild_health_status, self()),
ets:delete(guild_pid_cache, GuildId)
end.
@@ -276,3 +276,36 @@ state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid) ->
}.
-endif.
guild_create_resend_carries_threads_only_for_viewers_test() ->
Tab = guild_thread_store:new(),
try
Data = guild_data_index:normalize_map(#{
<<"guild">> => #{<<"id">> => <<"77">>, <<"owner_id">> => <<"1">>},
<<"roles">> => [#{<<"id">> => <<"77">>, <<"permissions">> => <<"1024">>}],
<<"channels">> => [#{<<"id">> => <<"5">>, <<"type">> => 0}],
<<"members">> => [#{<<"user">> => #{<<"id">> => <<"9">>}, <<"roles">> => []}],
<<"emojis">> => [],
<<"stickers">> => []
}),
State = #{
id => 77,
data => Data#{thread_store => Tab, thread_gate => #{active => true, version => 1}}
},
Viewer = #{user_id => 9, pid => self(), thread_viewer => true},
ok = guild_availability:send_guild_create_to_session(Viewer, 77, State, []),
?assert(maps:is_key(<<"threads">>, received_guild_create())),
ok = guild_availability:send_guild_create_to_session(
Viewer#{thread_viewer => false}, 77, State, []
),
?assertNot(maps:is_key(<<"threads">>, received_guild_create()))
after
guild_thread_store:destroy(Tab)
end.
received_guild_create() ->
receive
{'$gen_cast', {dispatch, guild_create, GuildState}} when is_map(GuildState) ->
GuildState
after 1000 -> erlang:error(guild_create_not_sent)
end.
@@ -16,6 +16,38 @@ finalize_pending_session_tracks_connected_user_test() ->
?assertEqual(2, maps:get(UserId, maps:get(presence_subscriptions, State1))),
flush_connect_result().
finalize_records_the_thread_viewer_flag_test() ->
Key = channel_threads_config,
Previous = persistent_term:get(Key, undefined),
persistent_term:put(Key, (channel_threads_config:default_config())#{
enabled => true, included_users => #{<<"10">> => true}
}),
try
SessionId = <<"s-thread">>,
Attempt = 2,
State0 = finalize_state(SessionId, 10, Attempt, true, #{}, sets:new(), #{}),
State1 = State0#{data => #{thread_gate => #{active => true, version => 1}}},
Computed0 = finalize_computed(SessionId, 10),
Request = (maps:get(request, Computed0))#{thread_channels_capable => true},
Computed = Computed0#{request => Request, thread_viewer => true},
State2 = guild_connect_async:finalize_session_connect_async(
SessionId, Attempt, {ok, #{}}, Computed, State1
),
Session = maps:get(SessionId, maps:get(sessions, State2)),
?assertEqual(true, maps:get(thread_viewer, Session)),
?assertEqual(true, maps:get(thread_capable, Session)),
receive
{thread_flip_resend, SessionId} -> ?assert(false)
after 0 -> ok
end,
flush_connect_result()
after
case Previous of
undefined -> persistent_term:erase(Key);
_ -> persistent_term:put(Key, Previous)
end
end.
finalize_connected_session_does_not_double_count_test() ->
SessionId = <<"s1">>,
UserId = 10,
+25
View File
@@ -31,6 +31,31 @@ read_model_preserves_query_results_test() ->
cleanup_read_model(State)
end.
read_model_resolves_thread_channels_test() ->
State0 = read_model_state(),
Store = guild_thread_store:new(),
ok = guild_thread_store:put_thread(Store, #{
<<"id">> => <<"600">>, <<"parent_id">> => <<"500">>, <<"type">> => 11
}),
#{data := Data} = State0,
State = State0#{
data => Data#{thread_gate => #{active => true, version => 1}, thread_store => Store}
},
Request = #{user_id => 200, channel_id => 600},
try
ok = guild_read_model:put_state(State),
{reply, Expected, _} = guild_data:get_auth_context(Request, State),
?assertMatch(
#{auth_context := #{<<"parent_channel">> := #{<<"id">> := 500}}}, Expected
),
?assertEqual(
{ok, Expected}, guild_read_model:query(100, {get_guild_auth_context, Request})
)
after
cleanup_read_model(State),
guild_thread_store:destroy(Store)
end.
read_model_uses_live_member_rows_without_copying_members_test() ->
State = read_model_state(),
#{data := #{members_ets := Tab}} = State,
@@ -26,7 +26,9 @@ transfer_test_() ->
instantiate(fun abort_after_forwarding_reports_the_forwarded_count/1),
instantiate(fun late_source_stop_keeps_the_committed_target/1),
instantiate(fun controller_death_after_commit_keeps_the_target/1),
instantiate(fun lost_guard_aborts_before_other_nodes_route_to_the_target/1)
instantiate(fun lost_guard_aborts_before_other_nodes_route_to_the_target/1),
instantiate(fun thread_state_survives_the_frozen_export/1),
instantiate(fun stale_thread_sessions_do_not_break_a_sessionless_restore/1)
]}.
instantiate(Test) ->
@@ -242,6 +244,47 @@ lost_guard_aborts_before_other_nodes_route_to_the_target(#{
?assertEqual(false, guild_handoff_freeze:is_frozen(Src)),
?assertEqual([a], gen_server:call(Src, get_log, 5000)).
thread_state_survives_the_frozen_export(#{src := Src, src_shard := SrcShard}) ->
ok = gen_server:call(Src, {put_session, <<"s1">>, #{pid => self(), user_id => 7}}),
ok = gen_server:call(Src, install_threads),
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), #{}),
?assertMatch({ok, #{new_pid := _}}, Result),
{ok, #{new_pid := NewPid}} = Result,
?assertEqual(ok, wait_dead(Src)),
State = guild_init:init_base_state(gen_server:call(NewPid, get_export)),
try
?assertMatch(#{<<"id">> := 10}, guild_thread_gate:thread(10, State)),
?assert(guild_thread_gate:active(State)),
?assertEqual([<<"s1">>], maps:keys(maps:get(thread_resend_pending, State))),
?assertEqual([<<"s1">>], maps:keys(maps:get(thread_sync_pending, State))),
?assertEqual([10], maps:keys(maps:get(thread_list_dirty, State))),
?assertEqual(
thread_member_list_flush, receive_tagged(thread_member_list_flush, 2000)
)
after
guild_thread_store:destroy(guild_thread_gate:store(State))
end.
stale_thread_sessions_do_not_break_a_sessionless_restore(#{src := Src, src_shard := SrcShard}) ->
ok = gen_server:call(Src, {put_session, <<"s1">>, #{pid => self(), user_id => 7}}),
ok = gen_server:call(Src, install_threads),
Opts = #{transfer_sessions => false},
{ok, #{new_pid := NewPid}} =
guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
Export = gen_server:call(NewPid, get_export),
?assertEqual(#{}, maps:get(sessions, Export)),
?assertEqual([<<"s1">>], maps:get(thread_handoff_resends, Export)),
State = guild_init:init_base_state(Export),
try
?assertMatch(#{<<"id">> := 10}, guild_thread_gate:thread(10, State)),
Resent = guild_thread_flip:resend(<<"s1">>, State),
?assertNot(maps:is_key(thread_resend_pending, Resent)),
Synced = guild_thread_subscriptions:handle_tick(<<"s1">>, Resent),
?assertNot(maps:is_key(thread_sync_pending, Synced))
after
guild_thread_store:destroy(guild_thread_gate:store(State))
end.
hold(Pid, Ms) ->
Test = self(),
spawn(fun() ->
@@ -373,6 +416,18 @@ handle_call(flush_down_on_terminate, _From, #{role := guild} = State) ->
{reply, ok, State#{flush_down => {Helper, erlang:monitor(process, Helper)}}};
handle_call(cast_late_on_terminate, _From, #{role := guild} = State) ->
{reply, ok, State#{cast_late => true}};
handle_call(install_threads, _From, #{role := guild, data := Data} = State) ->
Tab = guild_thread_store:new(),
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
{reply, ok, State#{
data => Data#{thread_store => Tab, thread_gate => #{active => true, version => 1}},
thread_load => #{status => ready, queue => [], queued => 0, attempt => 0},
thread_resend_pending => #{<<"s1">> => true},
thread_sync_pending => #{<<"s1">> => scheduled},
thread_list_dirty => #{10 => true}
}};
handle_call(get_export, _From, #{role := guild} = State) ->
{reply, maps:remove(role, State), State};
handle_call(get_sessions, _From, #{role := guild, sessions := Sessions} = State) ->
{reply, Sessions, State};
handle_call(get_log, _From, #{role := guild} = State) ->
@@ -126,3 +126,59 @@ session_loop() ->
after infinity ->
ok
end.
export_handoff_state_replaces_the_thread_store_with_rows_test() ->
Tab = guild_thread_store:new(),
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
try
State = #{
id => 12345,
data => #{thread_store => Tab, thread_gate => #{active => true, version => 1}},
sessions => #{}
},
Data = maps:get(data, guild_handoff:export_handoff_state(State)),
?assertNot(maps:is_key(thread_store, Data)),
?assertEqual(#{active => true, version => 1}, maps:get(thread_gate, Data)),
?assertEqual(guild_thread_store:export(Tab), maps:get(thread_rows, Data)),
?assert(is_integer(maps:get(thread_rows_at, Data)))
after
guild_thread_store:destroy(Tab)
end.
init_from_export_queues_a_flip_when_the_gate_disagrees_with_config_test() ->
Tab = guild_thread_store:new(),
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
Key = channel_threads_config,
Previous = persistent_term:get(Key, undefined),
try
Exported = guild_handoff:export_handoff_state(#{
id => 12345,
data => #{thread_store => Tab, thread_gate => #{active => true, version => 4}},
sessions => #{}
}),
persistent_term:put(Key, (channel_threads_config:default_config())#{
enabled => true, config_version => 5
}),
_ = guild_init:init_base_state(Exported),
?assertEqual([5], drain_flips([])),
persistent_term:put(Key, (channel_threads_config:default_config())#{
enabled => true, config_version => 5, enabled_guilds => #{<<"12345">> => true}
}),
_ = guild_init:init_base_state(Exported),
?assertEqual([], drain_flips([])),
persistent_term:erase(Key),
_ = guild_init:init_base_state(Exported),
?assertEqual([], drain_flips([]))
after
guild_thread_store:destroy(Tab),
case Previous of
undefined -> persistent_term:erase(Key);
_ -> persistent_term:put(Key, Previous)
end
end.
drain_flips(Acc) ->
receive
{thread_gate_flip, Version} -> drain_flips([Version | Acc])
after 0 -> lists:reverse(Acc)
end.
@@ -47,6 +47,10 @@ normalize_transferred_guild_state_keeps_only_transferable_fields_test() ->
sessions => #{<<"s1">> => #{pid => self()}},
voice_states => #{<<"v1">> => #{}},
virtual_channel_access => #{10 => sets:from_list([20])},
thread_handoff_resends => [<<"s1">>],
thread_handoff_syncs => [<<"s2">>],
thread_handoff_lists => [30],
thread_resend_pending => #{<<"s1">> => true},
member_list_engine => make_ref(),
channel_member_list_engines => #{<<"500">> => make_ref()},
voice_server_pid => self()
@@ -60,6 +64,10 @@ normalize_transferred_guild_state_keeps_only_transferable_fields_test() ->
?assertEqual(#{<<"v1">> => #{}}, maps:get(voice_states, Normalized)),
?assertNot(maps:is_key(member_list_engine, Normalized)),
?assertNot(maps:is_key(channel_member_list_engines, Normalized)),
?assertEqual([<<"s1">>], maps:get(thread_handoff_resends, Normalized)),
?assertEqual([<<"s2">>], maps:get(thread_handoff_syncs, Normalized)),
?assertEqual([30], maps:get(thread_handoff_lists, Normalized)),
?assertNot(maps:is_key(thread_resend_pending, Normalized)),
?assertNot(maps:is_key(voice_server_pid, Normalized)).
register_and_monitor_race_kills_duplicate_test_() ->
@@ -205,7 +205,7 @@ fresh_pending_sibling_keeps_mutual_members() ->
?assertEqual(
[?USER_C],
guild_subscription_mutual_channels:filter_member_ids(
?USER_A, [?USER_C], State
#{}, ?USER_A, [?USER_C], State
)
)
end)
@@ -0,0 +1,194 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_thread_push_tests).
-include_lib("eunit/include/eunit.hrl").
-define(G, 1).
-define(A, 10).
-define(B, 11).
-define(C, 12).
-define(OUT, 13).
-define(AUTHOR, 14).
-define(TEXT, 100).
-define(THREAD, 500).
-define(VIEW, 1024).
-define(SEND, 2048).
-define(CONFIG_KEY, channel_threads_config).
with_config(Fun) ->
Previous = persistent_term:get(?CONFIG_KEY, undefined),
Config = (channel_threads_config:default_config())#{
enabled => true,
included_users =>
#{integer_to_binary(Id) => true || Id <- [?A, ?B, ?C, ?AUTHOR]}
},
persistent_term:put(?CONFIG_KEY, Config),
try
Fun()
after
case Previous of
undefined -> persistent_term:erase(?CONFIG_KEY);
_ -> persistent_term:put(?CONFIG_KEY, Previous)
end
end.
raw_data() ->
Gid = integer_to_binary(?G),
Member = fun(Id) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(Id)},
<<"roles">> => [],
<<"joined_at">> => <<"2026-01-01T00:00:00Z">>
}
end,
#{
<<"guild">> => #{<<"id">> => Gid, <<"owner_id">> => <<"999">>, <<"features">> => []},
<<"roles">> => [
#{
<<"id">> => Gid,
<<"permissions">> => integer_to_binary(?VIEW bor ?SEND),
<<"position">> => 0
}
],
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"permission_overwrites">> => []}
],
<<"members">> => [Member(Id) || Id <- [?A, ?B, ?C, ?OUT, ?AUTHOR]],
<<"emojis">> => [],
<<"stickers">> => []
}.
thread_member(UserId) ->
#{
<<"id">> => integer_to_binary(?THREAD),
<<"user_id">> => integer_to_binary(UserId),
<<"join_timestamp">> => <<"2026-01-01T00:00:00.000Z">>,
<<"flags">> => 1
}.
state(Active) ->
Payload = guild_thread_load:load_payload(#{
<<"threads">> => [
#{
<<"id">> => integer_to_binary(?THREAD),
<<"guild_id">> => integer_to_binary(?G),
<<"parent_id">> => integer_to_binary(?TEXT),
<<"type">> => 11,
<<"name">> => <<"ideas">>,
<<"thread_metadata">> => #{<<"archived">> => false, <<"locked">> => false}
}
],
<<"thread_members">> => [thread_member(?A), thread_member(?OUT)]
}),
Base = #{
id => ?G,
member_count => 5,
data => guild_data_index:normalize_map(raw_data()),
sessions => #{
<<"b">> => #{session_id => <<"b">>, user_id => ?B, pid => self()},
<<"a">> => #{session_id => <<"a">>, user_id => ?A, pid => self()}
}
},
Gate =
case Active of
true ->
#{
thread_gate => #{active => true, version => 1},
thread_tainted => true,
thread_load => {ok, Payload}
};
false ->
#{}
end,
guild_thread_load:install(Gate, Base).
with_state(Active, Fun) ->
with_config(fun() ->
State = state(Active),
try
Fun(State)
after
guild_thread_store:destroy(guild_thread_gate:store(State))
end
end).
mention_request(ChannelId, Overrides) ->
maps:merge(
#{
channel_id => ChannelId,
author_id => ?AUTHOR,
mention_everyone => false,
mention_here => false,
role_ids => [],
user_ids => []
},
Overrides
).
everyone(Request, State) ->
{reply, #{everyone_user_ids := Ids}, _} =
guild_members_mutation:resolve_mention_sources(Request, State),
lists:sort(Ids).
everyone_in_a_thread_reaches_recipient_active_members_only_test() ->
with_state(true, fun(State) ->
Request = mention_request(?THREAD, #{mention_everyone => true}),
?assertEqual([?A], everyone(Request, State)),
?assertEqual(
[?A, ?B, ?C, ?OUT],
everyone(mention_request(?TEXT, #{mention_everyone => true}), State)
)
end).
here_in_a_thread_needs_membership_and_a_connection_test() ->
with_state(true, fun(State) ->
Request = mention_request(?THREAD, #{mention_here => true}),
?assertEqual([?A], everyone(Request, State)),
?assertEqual([?A, ?B], everyone(mention_request(?TEXT, #{mention_here => true}), State))
end).
direct_mentions_in_a_thread_skip_inactive_recipients_test() ->
with_state(true, fun(State) ->
{reply, #{direct_user_ids := Ids}, _} =
guild_members_mutation:resolve_mention_sources(
mention_request(?THREAD, #{user_ids => [?B, ?OUT]}), State
),
?assertEqual([?B], lists:sort(Ids))
end).
paged_everyone_in_a_thread_lists_members_only_test() ->
with_state(true, fun(State) ->
Request = (mention_request(?THREAD, #{mention_everyone => true}))#{
limit => 100, cursor => undefined
},
{reply, #{mentions := Entries}, _} =
guild_members_mutation:resolve_mention_sources_page(Request, State),
?assertEqual([?A], lists:sort([entry_user_id(E) || E <- Entries]))
end).
inactive_guild_mentions_are_unchanged_test() ->
with_state(false, fun(State) ->
?assertEqual(undefined, guild_thread_push:mention_universe(?THREAD, State)),
?assertEqual(
[?A, ?B, ?C, ?OUT],
everyone(mention_request(?TEXT, #{mention_everyone => true}), State)
)
end).
passive_threads_follow_membership_test() ->
with_state(true, fun(State) ->
?assertMatch(
[#{<<"name">> := <<"ideas">>}],
guild_thread_push:passive_threads(#{thread_viewer => true}, ?A, State)
),
?assertEqual(
[], guild_thread_push:passive_threads(#{thread_viewer => true}, ?B, State)
),
?assertEqual([], guild_thread_push:passive_threads(#{}, ?A, State))
end).
entry_user_id(#{user_id := UserId}) -> UserId;
entry_user_id(#{<<"user_id">> := UserId}) -> snowflake_id:parse_optional(UserId);
entry_user_id(UserId) when is_integer(UserId) -> UserId;
entry_user_id(Other) -> Other.
File diff suppressed because it is too large. Load diff
@@ -31,6 +31,9 @@ serialize_state_test() ->
?assertEqual({2, 8}, maps:get(shard, Serialized)),
?assert(sets:is_element(123, maps:get(active_guilds, Serialized))),
?assertEqual(10, maps:get(seq, Serialized)),
?assertEqual(false, maps:get(thread_channels_capable, Serialized)),
Capable = session_lifecycle:serialize_state(State#{thread_channels_capable => true}),
?assertEqual(true, maps:get(thread_channels_capable, Capable)),
ok.
serialize_transfer_state_includes_resume_fields_test() ->
@@ -55,6 +58,7 @@ serialize_transfer_state_includes_resume_fields_test() ->
ignored_events => #{<<"TYPING_START">> => true},
initial_guild_id => 123,
debounce_reactions => true,
thread_channels_capable => true,
channels => #{},
relationships => #{},
seq => 10,
@@ -73,7 +77,8 @@ serialize_transfer_state_includes_resume_fields_test() ->
?assertEqual(10, maps:get(seq, TransferState)),
?assertEqual(8, maps:get(ack_seq, TransferState)),
?assertEqual(8, maps:get(replay_floor, TransferState)),
?assertEqual([#{seq => 9}], maps:get(buffer, TransferState)).
?assertEqual([#{seq => 9}], maps:get(buffer, TransferState)),
?assertEqual(true, maps:get(thread_channels_capable, TransferState)).
serialize_transfer_state_strips_socket_pid_test() ->
State = #{
@@ -108,7 +113,8 @@ serialize_transfer_state_strips_socket_pid_test() ->
collected_presences => []
},
TransferState = session_lifecycle:serialize_transfer_state(State),
?assertEqual(undefined, maps:get(socket_pid, TransferState)).
?assertEqual(undefined, maps:get(socket_pid, TransferState)),
?assertEqual(false, maps:get(thread_channels_capable, TransferState)).
serialize_transfer_state_preserves_deque_replay_buffer_test() ->
Deque = lists:foldl(
@@ -15,6 +15,62 @@ should_debounce_reactions_test() ->
?assertEqual(false, session_manager_shard_drain:should_debounce_reactions(#{flags => -1})),
ok.
thread_channels_capable_reads_identify_flag_bit_two_test() ->
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{})),
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => 0})),
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => 2})),
?assertEqual(true, session_manager_shard_drain:thread_channels_capable(#{flags => 4})),
?assertEqual(true, session_manager_shard_drain:thread_channels_capable(#{flags => 6})),
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => -4})),
?assertEqual(
false, session_manager_shard_drain:thread_channels_capable(#{flags => <<"4">>})
).
build_session_data_carries_thread_channels_capability_test() ->
UserDataMap = #{
<<"id">> => <<"123">>,
<<"username">> => <<"tester">>,
<<"discriminator">> => <<"0001">>,
<<"avatar">> => null,
<<"flags">> => 0
},
Data = #{<<"guilds">> => [], <<"user">> => UserDataMap, <<"user_settings">> => null},
Base = #{properties => #{}, token => <<"token">>, presence => null},
Capable = build_test_session_data(Data, Base#{flags => 6}, UserDataMap),
Incapable = build_test_session_data(Data, Base#{flags => 2}, UserDataMap),
Unflagged = build_test_session_data(Data, Base, UserDataMap),
?assertEqual(true, maps:get(thread_channels_capable, Capable)),
?assertEqual(true, maps:get(debounce_reactions, Capable)),
?assertEqual(false, maps:get(thread_channels_capable, Incapable)),
?assertEqual(false, maps:get(thread_channels_capable, Unflagged)).
session_rpc_sends_thread_channels_capable_only_when_flagged_test() ->
meck:new(api_rpc_client, [passthrough, no_link]),
Self = self(),
meck:expect(api_rpc_client, call_with_retry, fun(Request, _Config) ->
Self ! {session_rpc_request, Request},
{ok, #{}}
end),
try
Identify = #{token => <<"token">>, properties => #{}},
{ok, _} = session_manager_shard_drain:fetch_rpc_data(
#{identify_data => Identify#{flags => 4}, version => 1}, <<"127.0.0.1">>
),
?assertMatch(#{<<"thread_channels_capable">> := true}, receive_session_rpc_request()),
{ok, _} = session_manager_shard_drain:fetch_rpc_data(
#{identify_data => Identify#{flags => 2}, version => 1}, <<"127.0.0.1">>
),
?assertNot(maps:is_key(<<"thread_channels_capable">>, receive_session_rpc_request()))
after
meck:unload(api_rpc_client)
end.
receive_session_rpc_request() ->
receive
{session_rpc_request, Request} -> Request
after 1000 -> erlang:error(session_rpc_request_not_sent)
end.
reconnect_drain_casts_each_active_session_test() ->
TestPid = self(),
SessionPidA = spawn(fun() -> reconnect_drain_test_session(TestPid) end),
File diff suppressed because it is too large. Load diff