Files
fluxer/fluxer_gateway/src/utils/custom_status_expiry.erl
T

363 lines
12 KiB
Erlang

%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(custom_status_expiry).
-typing([eqwalizer]).
-export([
clear_if_expired/1,
clear_if_expired/2,
remaining_ms/2,
next_wakeup_ms/1,
reconcile_message/0,
repair/1
]).
-export_type([custom_status/0]).
-type custom_status() :: map() | null.
-type user_id() :: integer().
-type wakeup() :: {ok, pos_integer()} | none.
-type offset() :: {ok, integer()} | none.
-define(MAX_REPAIR_WINDOW_SECONDS, 86400).
-define(MAX_TIMER_MS, 86400000).
-define(WAKEUP_JITTER_MS, 5000).
-spec clear_if_expired(term()) -> custom_status().
clear_if_expired(CustomStatus) ->
clear_if_expired(CustomStatus, erlang:system_time(millisecond)).
-spec clear_if_expired(term(), term()) -> custom_status().
clear_if_expired(CustomStatus, NowMs) when is_map(CustomStatus), is_integer(NowMs) ->
keep_or_clear(expired(CustomStatus, NowMs), CustomStatus);
clear_if_expired(CustomStatus, _NowMs) when is_map(CustomStatus) ->
CustomStatus;
clear_if_expired(_CustomStatus, _NowMs) ->
null.
-spec keep_or_clear(boolean(), map()) -> custom_status().
keep_or_clear(true, _CustomStatus) -> null;
keep_or_clear(false, CustomStatus) -> CustomStatus.
-spec expired(map(), integer()) -> boolean().
expired(CustomStatus, NowMs) ->
case remaining_ms(CustomStatus, NowMs) of
{ok, RemainingMs} -> RemainingMs =< 0;
none -> false
end.
-spec remaining_ms(term(), term()) -> offset().
remaining_ms(CustomStatus, NowMs) when is_map(CustomStatus), is_integer(NowMs) ->
offset_from(expires_at_ms(maps:get(<<"expires_at">>, CustomStatus, undefined)), NowMs);
remaining_ms(_CustomStatus, _NowMs) ->
none.
-spec offset_from(offset(), integer()) -> offset().
offset_from({ok, ExpiryMs}, NowMs) -> {ok, ExpiryMs - NowMs};
offset_from(none, _NowMs) -> none.
-spec expires_at_ms(term()) -> offset().
expires_at_ms(Value) when is_binary(Value) ->
parse_rfc3339(binary_to_list(Value));
expires_at_ms(Value) when is_list(Value) ->
parse_rfc3339(Value);
expires_at_ms(_Value) ->
none.
-spec parse_rfc3339(term()) -> offset().
parse_rfc3339(Chars) ->
try calendar:rfc3339_to_system_time(Chars, [{unit, millisecond}]) of
Ms -> {ok, Ms}
catch
_Class:_Reason -> none
end.
-spec next_wakeup_ms(term()) -> wakeup().
next_wakeup_ms(CustomStatus) ->
wakeup_delay(remaining_ms(CustomStatus, erlang:system_time(millisecond))).
-spec wakeup_delay(offset()) -> wakeup().
wakeup_delay(none) ->
none;
wakeup_delay({ok, RemainingMs}) when RemainingMs =< 0 ->
none;
wakeup_delay({ok, RemainingMs}) when RemainingMs > ?MAX_TIMER_MS ->
{ok, jittered(?MAX_TIMER_MS)};
wakeup_delay({ok, RemainingMs}) ->
{ok, jittered(RemainingMs)}.
-spec jittered(pos_integer()) -> pos_integer().
jittered(DelayMs) ->
DelayMs + rand:uniform(?WAKEUP_JITTER_MS).
-spec reconcile_message() -> {'$gen_cast', reconcile_flattened_presence}.
reconcile_message() ->
{'$gen_cast', reconcile_flattened_presence}.
-spec repair(term()) -> map().
repair(WindowSeconds) when
is_integer(WindowSeconds), WindowSeconds >= 0, WindowSeconds =< ?MAX_REPAIR_WINDOW_SECONDS
->
Considered = local_presence_user_ids(),
Pids = expiring_presence_pids(Considered),
Total = length(Pids),
ok = schedule_reconciles(Pids, Total, WindowSeconds * 1000),
#{
scheduled => Total,
considered => length(Considered),
window_seconds => WindowSeconds
};
repair(_WindowSeconds) ->
#{error => invalid_window_seconds}.
%% Only presences whose cached payload actually has an expires_at are worth
%% nudging. Nudging every local presence would cost one payload rebuild and one
%% replicated cache write each, thousands per node, to correct a few dozen.
%% Filtering on "has an expiry" rather than "is expired" is deliberate: it also
%% arms the ongoing wakeup for statuses that have not expired yet.
-spec expiring_presence_pids([integer()]) -> [pid()].
expiring_presence_pids(UserIds) ->
[
Pid
|| UserId <- UserIds,
has_expires_at(UserId),
Pid <- [presence_pid(UserId)],
is_pid(Pid)
].
-spec has_expires_at(integer()) -> boolean().
has_expires_at(UserId) ->
try presence_cache:get(UserId) of
{ok, Presence} when is_map(Presence) ->
presence_has_expiry(Presence);
_ ->
false
catch
_:_ ->
false
end.
%% presence_cache:get/1 returns {ok, map()} | not_found, never a bare map, and
%% expires_at_ms/1 takes the timestamp value rather than the custom_status map.
-spec presence_has_expiry(map()) -> boolean().
presence_has_expiry(Presence) ->
case maps:get(<<"custom_status">>, Presence, null) of
CustomStatus when is_map(CustomStatus) ->
expires_at_ms(maps:get(<<"expires_at">>, CustomStatus, null)) =/= none;
_ ->
false
end.
-spec local_presence_user_ids() -> [user_id()].
local_presence_user_ids() ->
try presence_manager_shards:determine_count() of
{ShardCount, _Source} -> shard_user_ids(ShardCount)
catch
_Class:_Reason -> []
end.
-spec shard_user_ids(pos_integer()) -> [user_id()].
shard_user_ids(ShardCount) ->
lists:append([
presence_manager:get_shard_user_ids(Index)
|| Index <- lists:seq(0, ShardCount - 1)
]).
-spec presence_pid(user_id()) -> pid() | undefined.
presence_pid(UserId) ->
process_registry:registry_whereis(process_registry:build_process_key(presence, UserId)).
-spec schedule_reconciles([pid()], non_neg_integer(), non_neg_integer()) -> ok.
schedule_reconciles(Pids, Total, WindowMs) ->
Message = reconcile_message(),
_ = lists:foldl(
fun(Pid, Index) ->
_ = schedule_one(Pid, spread_ms(Index, Total, WindowMs), Message),
Index + 1
end,
0,
Pids
),
ok.
%% A single unreachable destination must not abort the sweep and leave the
%% operator unable to tell how many nudges were actually scheduled.
-spec schedule_one(pid(), non_neg_integer(), term()) -> ok.
schedule_one(Pid, Delay, Message) ->
try erlang:send_after(Delay, Pid, Message) of
_Ref -> ok
catch
_Class:_Reason -> ok
end.
-spec spread_ms(non_neg_integer(), non_neg_integer(), non_neg_integer()) -> non_neg_integer().
spread_ms(_Index, 0, _WindowMs) -> 0;
spread_ms(_Index, _Total, 0) -> 0;
spread_ms(Index, Total, WindowMs) -> (Index * WindowMs) div Total.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
presence_has_expiry_detects_an_expiry_test() ->
?assert(
presence_has_expiry(#{
<<"custom_status">> => #{<<"expires_at">> => <<"2026-05-13T13:02:27.497Z">>}
})
),
?assertNot(presence_has_expiry(#{<<"custom_status">> => #{<<"text">> => <<"hi">>}})),
?assertNot(presence_has_expiry(#{<<"custom_status">> => null})),
?assertNot(presence_has_expiry(#{})).
-define(LIVE_EXPIRES_AT, <<"2026-05-13T13:02:27.497Z">>).
epoch_ms(Iso) ->
calendar:rfc3339_to_system_time(binary_to_list(Iso), [{unit, millisecond}]).
rfc3339(EpochMs) ->
list_to_binary(
calendar:system_time_to_rfc3339(EpochMs, [{unit, millisecond}, {offset, "Z"}])
).
expiry_ms() ->
epoch_ms(?LIVE_EXPIRES_AT).
status(ExpiresAt) ->
#{
<<"emoji_animated">> => false,
<<"emoji_name">> => null,
<<"expires_at">> => ExpiresAt,
<<"text">> => <<"brb">>
}.
status_without_expiry() ->
#{<<"emoji_animated">> => false, <<"emoji_name">> => null, <<"text">> => <<"brb">>}.
parser_anchors_on_the_unix_epoch_test() ->
?assertEqual(0, epoch_ms(<<"1970-01-01T00:00:00.000Z">>)),
?assertEqual(1500, epoch_ms(<<"1970-01-01T00:00:01.500Z">>)).
parser_keeps_milliseconds_test() ->
?assertEqual(497, expiry_ms() rem 1000).
not_expired_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual(Status, clear_if_expired(Status, expiry_ms() - 60000)).
expires_one_second_in_the_future_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual({ok, 1000}, remaining_ms(Status, expiry_ms() - 1000)),
?assertEqual(Status, clear_if_expired(Status, expiry_ms() - 1000)).
boundary_is_expired_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual({ok, 0}, remaining_ms(Status, expiry_ms())),
?assertEqual(null, clear_if_expired(Status, expiry_ms())).
one_millisecond_before_boundary_is_kept_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual(Status, clear_if_expired(Status, expiry_ms() - 1)).
expired_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual(null, clear_if_expired(Status, expiry_ms() + 1)),
?assertEqual(null, clear_if_expired(Status, expiry_ms() + 194 * 86400000)).
absent_expires_at_is_kept_test() ->
Status = status_without_expiry(),
?assertEqual(none, remaining_ms(Status, expiry_ms())),
?assertEqual(Status, clear_if_expired(Status, expiry_ms())).
null_expires_at_is_kept_test() ->
Status = status(null),
?assertEqual(none, remaining_ms(Status, expiry_ms())),
?assertEqual(Status, clear_if_expired(Status, expiry_ms())).
malformed_expires_at_is_kept_test() ->
lists:foreach(
fun(Value) ->
Status = status(Value),
?assertEqual(none, remaining_ms(Status, expiry_ms())),
?assertEqual(Status, clear_if_expired(Status, expiry_ms()))
end,
[<<"">>, <<"not-a-date">>, <<"2026-13-45T99:99:99Z">>, 1747141347, [], #{}, true]
).
custom_status_null_test() ->
?assertEqual(none, remaining_ms(null, expiry_ms())),
?assertEqual(null, clear_if_expired(null, expiry_ms())).
custom_status_absent_test() ->
?assertEqual(none, remaining_ms(undefined, expiry_ms())),
?assertEqual(null, clear_if_expired(undefined, expiry_ms())).
non_map_custom_status_test() ->
?assertEqual(null, clear_if_expired(<<"garbage">>, expiry_ms())),
?assertEqual(null, clear_if_expired(123, expiry_ms())).
non_integer_reference_time_keeps_status_test() ->
Status = status(?LIVE_EXPIRES_AT),
?assertEqual(Status, clear_if_expired(Status, not_a_time)).
wakeup_none_without_expiry_test() ->
?assertEqual(none, wakeup_delay(remaining_ms(status_without_expiry(), expiry_ms()))),
?assertEqual(none, wakeup_delay(remaining_ms(null, expiry_ms()))).
wakeup_none_when_already_expired_test() ->
?assertEqual(none, wakeup_delay({ok, 0})),
?assertEqual(none, wakeup_delay({ok, -1000})).
wakeup_is_never_early_test() ->
{ok, DelayMs} = wakeup_delay({ok, 1000}),
?assert(DelayMs > 1000),
?assert(DelayMs =< 1000 + ?WAKEUP_JITTER_MS).
wakeup_clamps_to_the_timer_limit_test() ->
{ok, DelayMs} = wakeup_delay({ok, 400 * ?MAX_TIMER_MS}),
?assert(DelayMs > ?MAX_TIMER_MS),
?assert(DelayMs =< ?MAX_TIMER_MS + ?WAKEUP_JITTER_MS).
clears_expired_and_arms_future_test() ->
Live = future_status(),
?assertEqual(null, clear_if_expired(status(?LIVE_EXPIRES_AT))),
?assertEqual(Live, clear_if_expired(Live)),
?assertMatch({ok, _}, next_wakeup_ms(Live)).
future_status() ->
status(rfc3339(erlang:system_time(millisecond) + 3600000)).
reconcile_message_is_a_gen_server_cast_test() ->
?assertEqual({'$gen_cast', reconcile_flattened_presence}, reconcile_message()).
spread_ms_test() ->
?assertEqual(0, spread_ms(0, 0, 60000)),
?assertEqual(0, spread_ms(5, 10, 0)),
?assertEqual(0, spread_ms(0, 10, 60000)),
?assertEqual(30000, spread_ms(5, 10, 60000)),
?assertEqual(54000, spread_ms(9, 10, 60000)).
repair_rejects_a_bad_window_test() ->
?assertEqual(#{error => invalid_window_seconds}, repair(-1)),
?assertEqual(#{error => invalid_window_seconds}, repair(<<"900">>)).
repair_reports_what_it_scheduled_test() ->
Result = repair(0),
?assert(is_integer(maps:get(scheduled, Result))),
?assert(maps:get(scheduled, Result) >= 0),
?assertEqual(0, maps:get(window_seconds, Result)).
schedule_reconciles_delivers_the_cast_test() ->
flush_mailbox(),
ok = schedule_reconciles([self()], 1, 0),
receive
Message -> ?assertEqual(reconcile_message(), Message)
after 1000 -> ?assert(false)
end.
flush_mailbox() ->
receive
_Any -> flush_mailbox()
after 0 -> ok
end.
-endif.