|
|
|
@@ -0,0 +1,204 @@
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% EventHub – периодическая очистка append-only таблиц (TTL cleanup)
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% gen_server, который раз в N миллисекунд (по умолчанию 1 час) запускает
|
|
|
|
|
%% фоновый процесс очистки устаревших записей:
|
|
|
|
|
%% • notification — прочитанные старше 30 дней
|
|
|
|
|
%% • auth_session — отозванные (revoked) или истёкшие старше 7 дней
|
|
|
|
|
%% • admin_audit — записи старше 90 дней
|
|
|
|
|
%%
|
|
|
|
|
%% Deletes run in bounded chunks so a single tick does not hold long
|
|
|
|
|
%% Mnesia locks on large tables.
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
-module(infra_cleanup).
|
|
|
|
|
-behaviour(gen_server).
|
|
|
|
|
|
|
|
|
|
-include("records.hrl").
|
|
|
|
|
-include_lib("stdlib/include/ms_transform.hrl").
|
|
|
|
|
|
|
|
|
|
%% API
|
|
|
|
|
-export([start_link/0, run_once/0, cutoff_datetime/1]).
|
|
|
|
|
|
|
|
|
|
%% gen_server callbacks
|
|
|
|
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
|
|
|
|
terminate/2, code_change/3]).
|
|
|
|
|
|
|
|
|
|
%% TTL thresholds in days
|
|
|
|
|
-define(NOTIFICATION_TTL_DAYS, 30).
|
|
|
|
|
-define(AUTH_SESSION_TTL_DAYS, 7).
|
|
|
|
|
-define(ADMIN_AUDIT_TTL_DAYS, 90).
|
|
|
|
|
|
|
|
|
|
%% Default cleanup interval: 1 hour
|
|
|
|
|
-define(DEFAULT_INTERVAL_MS, 3600000).
|
|
|
|
|
|
|
|
|
|
%% Chunked delete limits
|
|
|
|
|
-define(CHUNK_SIZE, 500).
|
|
|
|
|
-define(MAX_CHUNKS_PER_TICK, 20).
|
|
|
|
|
|
|
|
|
|
-record(state, {
|
|
|
|
|
interval_ms :: pos_integer(),
|
|
|
|
|
timer_ref :: reference() | undefined
|
|
|
|
|
}).
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% API
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
start_link() ->
|
|
|
|
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
|
|
|
|
|
|
|
|
|
%% @doc Run one cleanup pass (used by tests and manual ops).
|
|
|
|
|
-spec run_once() ->
|
|
|
|
|
#{notifications := non_neg_integer(),
|
|
|
|
|
auth_sessions := non_neg_integer(),
|
|
|
|
|
admin_audit := non_neg_integer()}.
|
|
|
|
|
run_once() ->
|
|
|
|
|
cleanup_worker().
|
|
|
|
|
|
|
|
|
|
%% @doc Returns a calendar:datetime() that is `Days` days before now.
|
|
|
|
|
-spec cutoff_datetime(non_neg_integer()) -> calendar:datetime().
|
|
|
|
|
cutoff_datetime(Days) ->
|
|
|
|
|
Now = calendar:universal_time(),
|
|
|
|
|
Secs = calendar:datetime_to_gregorian_seconds(Now),
|
|
|
|
|
calendar:gregorian_seconds_to_datetime(Secs - Days * 86400).
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% gen_server callbacks
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
init([]) ->
|
|
|
|
|
process_flag(trap_exit, true),
|
|
|
|
|
IntervalMs = application:get_env(eventhub, cleanup_interval_ms, ?DEFAULT_INTERVAL_MS),
|
|
|
|
|
%% Schedule first run after a short delay to let the system stabilise
|
|
|
|
|
Ref = erlang:send_after(5000, self(), run_cleanup),
|
|
|
|
|
logger:info(#{what => infra_cleanup_started, interval_ms => IntervalMs}),
|
|
|
|
|
{ok, #state{interval_ms = IntervalMs, timer_ref = Ref}}.
|
|
|
|
|
|
|
|
|
|
handle_call(run_once, _From, State) ->
|
|
|
|
|
{reply, cleanup_worker(), State};
|
|
|
|
|
handle_call(_Msg, _From, State) ->
|
|
|
|
|
{reply, {error, unknown_call}, State}.
|
|
|
|
|
|
|
|
|
|
handle_cast(_Msg, State) ->
|
|
|
|
|
{noreply, State}.
|
|
|
|
|
|
|
|
|
|
handle_info(run_cleanup, #state{interval_ms = IntervalMs} = State) ->
|
|
|
|
|
spawn_link(fun cleanup_worker/0),
|
|
|
|
|
Ref = erlang:send_after(IntervalMs, self(), run_cleanup),
|
|
|
|
|
{noreply, State#state{timer_ref = Ref}};
|
|
|
|
|
|
|
|
|
|
handle_info({'EXIT', _Pid, normal}, State) ->
|
|
|
|
|
{noreply, State};
|
|
|
|
|
handle_info({'EXIT', _Pid, Reason}, State) ->
|
|
|
|
|
logger:warning(#{what => infra_cleanup_worker_exit, reason => Reason}),
|
|
|
|
|
{noreply, State};
|
|
|
|
|
|
|
|
|
|
handle_info(_Msg, State) ->
|
|
|
|
|
{noreply, State}.
|
|
|
|
|
|
|
|
|
|
terminate(_Reason, #state{timer_ref = Ref}) ->
|
|
|
|
|
case Ref of
|
|
|
|
|
undefined -> ok;
|
|
|
|
|
_ -> erlang:cancel_timer(Ref)
|
|
|
|
|
end,
|
|
|
|
|
ok.
|
|
|
|
|
|
|
|
|
|
code_change(_OldVsn, State, _Extra) ->
|
|
|
|
|
{ok, State}.
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% Cleanup worker (runs in a spawned process or via run_once/0)
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
cleanup_worker() ->
|
|
|
|
|
CutoffNotif = cutoff_datetime(?NOTIFICATION_TTL_DAYS),
|
|
|
|
|
CutoffAuth = cutoff_datetime(?AUTH_SESSION_TTL_DAYS),
|
|
|
|
|
CutoffAudit = cutoff_datetime(?ADMIN_AUDIT_TTL_DAYS),
|
|
|
|
|
|
|
|
|
|
N1 = cleanup_notifications(CutoffNotif),
|
|
|
|
|
N2 = cleanup_auth_sessions(CutoffAuth),
|
|
|
|
|
N3 = cleanup_admin_audit(CutoffAudit),
|
|
|
|
|
|
|
|
|
|
logger:info(#{what => infra_cleanup_done,
|
|
|
|
|
notifications => N1,
|
|
|
|
|
auth_sessions => N2,
|
|
|
|
|
admin_audit => N3}),
|
|
|
|
|
#{notifications => N1, auth_sessions => N2, admin_audit => N3}.
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% Cleanup: notifications
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
cleanup_notifications(Cutoff) ->
|
|
|
|
|
Ms = ets:fun2ms(fun(#notification{id = Id, is_read = true, created_at = CreatedAt})
|
|
|
|
|
when CreatedAt < Cutoff ->
|
|
|
|
|
Id
|
|
|
|
|
end),
|
|
|
|
|
delete_matching(notification, Ms).
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% Cleanup: auth_sessions
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
cleanup_auth_sessions(Cutoff) ->
|
|
|
|
|
MsRevoked = ets:fun2ms(fun(#auth_session{session_id = Id,
|
|
|
|
|
revoked = true,
|
|
|
|
|
created_at = CreatedAt})
|
|
|
|
|
when CreatedAt < Cutoff ->
|
|
|
|
|
Id
|
|
|
|
|
end),
|
|
|
|
|
NRevoked = delete_matching(auth_session, MsRevoked),
|
|
|
|
|
Now = calendar:universal_time(),
|
|
|
|
|
MsExpired = ets:fun2ms(fun(#auth_session{session_id = Id,
|
|
|
|
|
expires_at = ExpiresAt,
|
|
|
|
|
created_at = CreatedAt})
|
|
|
|
|
when CreatedAt < Cutoff,
|
|
|
|
|
ExpiresAt < Now ->
|
|
|
|
|
Id
|
|
|
|
|
end),
|
|
|
|
|
NExpired = delete_matching(auth_session, MsExpired),
|
|
|
|
|
NRevoked + NExpired.
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% Cleanup: admin_audit
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
cleanup_admin_audit(Cutoff) ->
|
|
|
|
|
Ms = ets:fun2ms(fun(#admin_audit{id = Id, timestamp = Ts})
|
|
|
|
|
when Ts < Cutoff ->
|
|
|
|
|
Id
|
|
|
|
|
end),
|
|
|
|
|
delete_matching(admin_audit, Ms).
|
|
|
|
|
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
%% Chunked delete helpers
|
|
|
|
|
%% ===================================================================
|
|
|
|
|
|
|
|
|
|
%% @doc Delete matching keys in separate transactions of ?CHUNK_SIZE,
|
|
|
|
|
%% up to ?MAX_CHUNKS_PER_TICK per call. Re-selects after each chunk
|
|
|
|
|
%% (continuation is not valid across transactions).
|
|
|
|
|
delete_matching(Table, Ms) ->
|
|
|
|
|
delete_matching_loop(Table, Ms, 0, ?MAX_CHUNKS_PER_TICK).
|
|
|
|
|
|
|
|
|
|
delete_matching_loop(_Table, _Ms, Acc, 0) ->
|
|
|
|
|
Acc;
|
|
|
|
|
delete_matching_loop(Table, Ms, Acc, ChunksLeft) ->
|
|
|
|
|
case mnesia:transaction(fun() ->
|
|
|
|
|
case mnesia:select(Table, Ms, ?CHUNK_SIZE, write) of
|
|
|
|
|
'$end_of_table' ->
|
|
|
|
|
[];
|
|
|
|
|
{Ids, _Cont} ->
|
|
|
|
|
lists:foreach(fun(Id) -> mnesia:delete({Table, Id}) end, Ids),
|
|
|
|
|
Ids
|
|
|
|
|
end
|
|
|
|
|
end) of
|
|
|
|
|
{atomic, []} ->
|
|
|
|
|
Acc;
|
|
|
|
|
{atomic, Ids} when is_list(Ids) ->
|
|
|
|
|
delete_matching_loop(Table, Ms, Acc + length(Ids), ChunksLeft - 1);
|
|
|
|
|
{aborted, Reason} ->
|
|
|
|
|
logger:warning(#{what => infra_cleanup_tx_aborted,
|
|
|
|
|
table => Table,
|
|
|
|
|
reason => Reason}),
|
|
|
|
|
Acc
|
|
|
|
|
end.
|