feat(infra): add TTL cleanup worker for append-only tables
Periodically prune old read notifications, revoked/expired auth sessions, and admin_audit in chunked transactions; wire under eventhub config.
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
{admin_http_port, "${ADMIN_HTTP_PORT:-8445}"},
|
{admin_http_port, "${ADMIN_HTTP_PORT:-8445}"},
|
||||||
{admin_ws_port, "${ADMIN_WS_PORT:-8446}"},
|
{admin_ws_port, "${ADMIN_WS_PORT:-8446}"},
|
||||||
{jwt_secret, <<"${JWT_SECRET:-change_me_in_production}">>},
|
{jwt_secret, <<"${JWT_SECRET:-change_me_in_production}">>},
|
||||||
{admin_jwt_secret, <<"${ADMIN_JWT_SECRET:-change_me_in_production}">>}
|
{admin_jwt_secret, <<"${ADMIN_JWT_SECRET:-change_me_in_production}">>},
|
||||||
|
%% Interval between TTL cleanup runs (infra_cleanup), default 1 hour
|
||||||
|
{cleanup_interval_ms, 3600000}
|
||||||
]},
|
]},
|
||||||
{kernel, [
|
{kernel, [
|
||||||
{logger_level, info},
|
{logger_level, info},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -131,6 +131,7 @@ handle_call(init_tables, _From, State) ->
|
|||||||
end,
|
end,
|
||||||
ok = create_indices(),
|
ok = create_indices(),
|
||||||
%% stats_collector:subscribe — после wait_for_tables + migrations (eventhub_app)
|
%% stats_collector:subscribe — после wait_for_tables + migrations (eventhub_app)
|
||||||
|
%% Table fragmentation: use infra_mnesia_fragmentation:fragment_table/2 manually.
|
||||||
ok = start_cleanup_timer(),
|
ok = start_cleanup_timer(),
|
||||||
{reply, ok, State};
|
{reply, ok, State};
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ init([]) ->
|
|||||||
restart => permanent,
|
restart => permanent,
|
||||||
shutdown => 5000,
|
shutdown => 5000,
|
||||||
type => worker,
|
type => worker,
|
||||||
modules => [subscription_worker]}
|
modules => [subscription_worker]},
|
||||||
|
#{id => infra_cleanup,
|
||||||
|
start => {infra_cleanup, start_link, []},
|
||||||
|
restart => permanent,
|
||||||
|
shutdown => 5000,
|
||||||
|
type => worker,
|
||||||
|
modules => [infra_cleanup]}
|
||||||
],
|
],
|
||||||
{ok, {SupFlags, Children}}.
|
{ok, {SupFlags, Children}}.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-module(infra_cleanup_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-define(TABLES, [notification, auth_session, admin_audit]).
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
eh_test_support:start_mnesia(),
|
||||||
|
eh_test_support:ensure_tables(?TABLES),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
cleanup(_) ->
|
||||||
|
eh_test_support:clear_tables(?TABLES),
|
||||||
|
eh_test_support:delete_tables(?TABLES),
|
||||||
|
eh_test_support:stop_mnesia(),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
infra_cleanup_test_() ->
|
||||||
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
|
{"cutoff_datetime is in the past", fun test_cutoff_datetime/0},
|
||||||
|
{"keeps unread notifications", fun test_keeps_unread_notifications/0},
|
||||||
|
{"deletes old read notifications", fun test_deletes_old_read_notifications/0},
|
||||||
|
{"keeps active non-expired sessions", fun test_keeps_active_sessions/0},
|
||||||
|
{"deletes old revoked sessions", fun test_deletes_old_revoked_sessions/0},
|
||||||
|
{"deletes old admin_audit", fun test_deletes_old_admin_audit/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
test_cutoff_datetime() ->
|
||||||
|
Cutoff = infra_cleanup:cutoff_datetime(30),
|
||||||
|
NowSecs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||||
|
CutoffSecs = calendar:datetime_to_gregorian_seconds(Cutoff),
|
||||||
|
?assert(CutoffSecs < NowSecs),
|
||||||
|
%% ~30 days (± a few seconds for test runtime)
|
||||||
|
DiffDays = (NowSecs - CutoffSecs) / 86400,
|
||||||
|
?assert(DiffDays > 29.9),
|
||||||
|
?assert(DiffDays < 30.1).
|
||||||
|
|
||||||
|
test_keeps_unread_notifications() ->
|
||||||
|
Old = infra_cleanup:cutoff_datetime(60),
|
||||||
|
write_notification(<<"n_unread">>, false, Old),
|
||||||
|
write_notification(<<"n_read_recent">>, true, calendar:universal_time()),
|
||||||
|
#{notifications := N} = infra_cleanup:run_once(),
|
||||||
|
?assertEqual(0, N),
|
||||||
|
?assertMatch([_], mnesia:dirty_read(notification, <<"n_unread">>)),
|
||||||
|
?assertMatch([_], mnesia:dirty_read(notification, <<"n_read_recent">>)).
|
||||||
|
|
||||||
|
test_deletes_old_read_notifications() ->
|
||||||
|
Old = infra_cleanup:cutoff_datetime(60),
|
||||||
|
write_notification(<<"n_old_read">>, true, Old),
|
||||||
|
write_notification(<<"n_unread">>, false, Old),
|
||||||
|
#{notifications := N} = infra_cleanup:run_once(),
|
||||||
|
?assertEqual(1, N),
|
||||||
|
?assertEqual([], mnesia:dirty_read(notification, <<"n_old_read">>)),
|
||||||
|
?assertMatch([_], mnesia:dirty_read(notification, <<"n_unread">>)).
|
||||||
|
|
||||||
|
test_keeps_active_sessions() ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
Future = days_offset(Now, 7),
|
||||||
|
Recent = days_offset(Now, -1),
|
||||||
|
write_auth_session(<<"s_active">>, false, Future, Recent),
|
||||||
|
#{auth_sessions := N} = infra_cleanup:run_once(),
|
||||||
|
?assertEqual(0, N),
|
||||||
|
?assertMatch([_], mnesia:dirty_read(auth_session, <<"s_active">>)).
|
||||||
|
|
||||||
|
test_deletes_old_revoked_sessions() ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
Old = days_offset(Now, -14),
|
||||||
|
Future = days_offset(Now, 7),
|
||||||
|
write_auth_session(<<"s_revoked_old">>, true, Future, Old),
|
||||||
|
write_auth_session(<<"s_active">>, false, Future, Old),
|
||||||
|
#{auth_sessions := N} = infra_cleanup:run_once(),
|
||||||
|
?assert(N >= 1),
|
||||||
|
?assertEqual([], mnesia:dirty_read(auth_session, <<"s_revoked_old">>)),
|
||||||
|
%% Active but old created_at with future expiry must stay
|
||||||
|
?assertMatch([_], mnesia:dirty_read(auth_session, <<"s_active">>)).
|
||||||
|
|
||||||
|
test_deletes_old_admin_audit() ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
Old = days_offset(Now, -120),
|
||||||
|
write_admin_audit(<<"a_old">>, Old),
|
||||||
|
write_admin_audit(<<"a_new">>, Now),
|
||||||
|
#{admin_audit := N} = infra_cleanup:run_once(),
|
||||||
|
?assertEqual(1, N),
|
||||||
|
?assertEqual([], mnesia:dirty_read(admin_audit, <<"a_old">>)),
|
||||||
|
?assertMatch([_], mnesia:dirty_read(admin_audit, <<"a_new">>)).
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Helpers
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
write_notification(Id, IsRead, CreatedAt) ->
|
||||||
|
mnesia:dirty_write(#notification{
|
||||||
|
id = Id,
|
||||||
|
user_id = <<"u1">>,
|
||||||
|
type = custom,
|
||||||
|
title = <<"t">>,
|
||||||
|
body = <<"b">>,
|
||||||
|
is_read = IsRead,
|
||||||
|
created_at = CreatedAt
|
||||||
|
}).
|
||||||
|
|
||||||
|
write_auth_session(Id, Revoked, ExpiresAt, CreatedAt) ->
|
||||||
|
mnesia:dirty_write(#auth_session{
|
||||||
|
session_id = Id,
|
||||||
|
family_id = <<"fam">>,
|
||||||
|
subject_id = <<"subj">>,
|
||||||
|
subject_type = user,
|
||||||
|
client_type = <<"web">>,
|
||||||
|
current_jti = <<"jti">>,
|
||||||
|
expires_at = ExpiresAt,
|
||||||
|
revoked = Revoked,
|
||||||
|
created_at = CreatedAt,
|
||||||
|
updated_at = CreatedAt
|
||||||
|
}).
|
||||||
|
|
||||||
|
write_admin_audit(Id, Timestamp) ->
|
||||||
|
mnesia:dirty_write(#admin_audit{
|
||||||
|
id = Id,
|
||||||
|
admin_id = <<"adm">>,
|
||||||
|
email = <<"a@test.local">>,
|
||||||
|
role = admin,
|
||||||
|
action = <<"test">>,
|
||||||
|
entity_type = <<"user">>,
|
||||||
|
entity_id = <<"u1">>,
|
||||||
|
timestamp = Timestamp,
|
||||||
|
ip = <<"127.0.0.1">>,
|
||||||
|
reason = <<>>
|
||||||
|
}).
|
||||||
|
|
||||||
|
days_offset(Datetime, Days) ->
|
||||||
|
Secs = calendar:datetime_to_gregorian_seconds(Datetime),
|
||||||
|
calendar:gregorian_seconds_to_datetime(Secs + Days * 86400).
|
||||||
Reference in New Issue
Block a user