feat(archive): month snapshots on the same node, drop extra-node and HTML /view.
Fixes EventHub/EventHubBack#76 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -404,6 +404,16 @@
|
||||
cpu_utilization :: float()
|
||||
}).
|
||||
|
||||
-record(month_snapshot, {
|
||||
id :: {binary(), binary()},
|
||||
calendar_id :: binary(),
|
||||
year_month :: binary(),
|
||||
payload :: binary(),
|
||||
storage :: mnesia | file,
|
||||
file_rel :: binary(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
-record(schema_migration, {
|
||||
version :: string(),
|
||||
applied_at :: calendar:datetime()
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
-module(archive_controller).
|
||||
-compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]).
|
||||
-include("records.hrl").
|
||||
-export([archive_day/1]).
|
||||
|
||||
archive_day(Day) ->
|
||||
ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
||||
case start_archive_node(ArchiveNode) of
|
||||
{ok, PeerOrSlave} ->
|
||||
try
|
||||
rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]),
|
||||
rpc:call(ArchiveNode, mnesia, start, []),
|
||||
rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]),
|
||||
create_archive_table(ArchiveNode, event,
|
||||
[id, calendar_id, start_time, end_time, event_type,
|
||||
master_id, specialist_id, title, description,
|
||||
attachments, edit_history, status, created_at, updated_at],
|
||||
[calendar_id, start_time, event_type, master_id,
|
||||
specialist_id, status]),
|
||||
create_archive_table(ArchiveNode, booking,
|
||||
[id, event_id, user_id, status, confirmed_at,
|
||||
created_at, updated_at, notes, reminder_sent],
|
||||
[event_id, user_id, status]),
|
||||
create_archive_table(ArchiveNode, review,
|
||||
[id, user_id, target_type, target_id, rating, comment,
|
||||
status, reason, created_at, updated_at, likes,
|
||||
dislikes, edited_at], []),
|
||||
create_archive_table(ArchiveNode, report,
|
||||
[id, reporter_id, target_type, target_id, reason,
|
||||
status, created_at, resolved_at, resolved_by], []),
|
||||
ok = transfer_data(ArchiveNode, Day),
|
||||
io:format("Archived day ~s successfully.~n", [Day])
|
||||
after
|
||||
stop_archive_node(PeerOrSlave, ArchiveNode)
|
||||
end;
|
||||
{error, Reason} ->
|
||||
io:format("Failed to start archive node: ~p~n", [Reason]),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
start_archive_node(Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" ->
|
||||
peer:start_link(#{name => Node, host => host()});
|
||||
_ ->
|
||||
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||
{ok, Slave} -> {ok, Slave};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
stop_archive_node(PeerOrSlave, Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" -> peer:stop(PeerOrSlave);
|
||||
_ -> slave:stop(Node)
|
||||
end.
|
||||
|
||||
create_archive_table(Node, Tab, Attributes, Indices) ->
|
||||
Opts = [{disc_only_copies, [Node]},
|
||||
{attributes, Attributes},
|
||||
{type, set}] ++ case Indices of
|
||||
[] -> [];
|
||||
_ -> [{index, Indices}]
|
||||
end,
|
||||
rpc:call(Node, mnesia, create_table, [Tab, Opts]).
|
||||
|
||||
transfer_data(ArchiveNode, Day) ->
|
||||
Tables = [event, booking, review, report],
|
||||
lists:foreach(fun(Tab) ->
|
||||
Records = fetch_records(Tab, Day),
|
||||
rpc:call(ArchiveNode, mnesia, transaction, [
|
||||
fun() -> [mnesia:write(Rec) || Rec <- Records] end
|
||||
]),
|
||||
mnesia:transaction(fun() ->
|
||||
[mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records]
|
||||
end)
|
||||
end, Tables).
|
||||
|
||||
fetch_records(event, Day) ->
|
||||
Start = list_to_binary(Day ++ " 00:00:00"),
|
||||
End = list_to_binary(Day ++ " 23:59:59"),
|
||||
mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'},
|
||||
[{'>=','$1', Start},{'=<','$1', End}],
|
||||
['$_']}]);
|
||||
fetch_records(booking, Day) ->
|
||||
mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]);
|
||||
fetch_records(review, Day) ->
|
||||
mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]);
|
||||
fetch_records(report, Day) ->
|
||||
mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]).
|
||||
|
||||
host() ->
|
||||
{ok, Name} = inet:gethostname(),
|
||||
Name.
|
||||
@@ -1,12 +0,0 @@
|
||||
-module(archive_fetcher).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([fetch/3]).
|
||||
|
||||
fetch(CalendarId, Year, Month) ->
|
||||
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
|
||||
start_time = '$1', _ = '_'},
|
||||
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||
['$_']}]).
|
||||
@@ -1,190 +0,0 @@
|
||||
-module(archive_manager).
|
||||
-behaviour(gen_server).
|
||||
-compile([{nowarn_deprecated_function, [{slave, start, 3}]}]).
|
||||
|
||||
%% Peer start must not run inside handle_call: peer:start_it timeout
|
||||
%% exits the gen_server and cascades via infra_sup (seen under IFT load).
|
||||
%% Starts are async single-flight; callers wait or get {error, _}.
|
||||
|
||||
-export([start_link/0, get_archive_node/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(IDLE_MS, 30000).
|
||||
-define(PEER_CONN_MS, 15000).
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
get_archive_node(Day) ->
|
||||
gen_server:call(?MODULE, {get_node, Day}).
|
||||
|
||||
init([]) ->
|
||||
process_flag(trap_exit, true),
|
||||
{ok, #{nodes => #{}, starting => #{}}}.
|
||||
|
||||
handle_call({get_node, Day}, From, State) ->
|
||||
Node = archive_node_name(Day),
|
||||
Nodes = maps:get(nodes, State),
|
||||
Starting = maps:get(starting, State),
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, _} ->
|
||||
{reply, {ok, Node}, State#{nodes := touch_node(Nodes, Node)}};
|
||||
error ->
|
||||
case is_node_alive(Node) of
|
||||
true ->
|
||||
{reply, {ok, Node}, State#{nodes := register_node(Nodes, Node)}};
|
||||
false ->
|
||||
case maps:find(Day, Starting) of
|
||||
{ok, Waiters} ->
|
||||
{noreply, State#{starting := Starting#{Day => [From | Waiters]}}};
|
||||
error ->
|
||||
spawn_starter(Day),
|
||||
{noreply, State#{starting := Starting#{Day => [From]}}}
|
||||
end
|
||||
end
|
||||
end;
|
||||
handle_call(_Req, _From, State) ->
|
||||
{reply, {error, unknown_call}, State}.
|
||||
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info({start_done, Day, Result}, State) ->
|
||||
Starting = maps:get(starting, State),
|
||||
Waiters = maps:get(Day, Starting, []),
|
||||
NewStarting = maps:remove(Day, Starting),
|
||||
Nodes = maps:get(nodes, State),
|
||||
case Result of
|
||||
{ok, Node} ->
|
||||
reply_all(Waiters, {ok, Node}),
|
||||
{noreply, State#{
|
||||
nodes := register_node(Nodes, Node),
|
||||
starting := NewStarting
|
||||
}};
|
||||
{error, Reason} ->
|
||||
reply_all(Waiters, {error, Reason}),
|
||||
{noreply, State#{starting := NewStarting}}
|
||||
end;
|
||||
handle_info({release, Node}, State) ->
|
||||
Nodes = maps:get(nodes, State),
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, #{last_access := Last, timer := _Old}} ->
|
||||
Idle = erlang:convert_time_unit(
|
||||
erlang:monotonic_time() - Last, native, millisecond),
|
||||
if Idle >= ?IDLE_MS ->
|
||||
stop_archive_node(Node),
|
||||
{noreply, State#{nodes := maps:remove(Node, Nodes)}};
|
||||
true ->
|
||||
{noreply, State#{nodes := touch_node(Nodes, Node)}}
|
||||
end;
|
||||
error ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_info({'EXIT', _Pid, _Reason}, State) ->
|
||||
{noreply, State};
|
||||
handle_info(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Internal
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
spawn_starter(Day) ->
|
||||
Parent = self(),
|
||||
spawn(fun() ->
|
||||
Result =
|
||||
try start_archive_node(Day) of
|
||||
Ok -> Ok
|
||||
catch
|
||||
exit:{timeout, _} -> {error, peer_timeout};
|
||||
exit:Reason -> {error, {exit, Reason}};
|
||||
error:Reason -> {error, {error, Reason}};
|
||||
throw:Reason -> {error, {throw, Reason}}
|
||||
end,
|
||||
Parent ! {start_done, Day, Result}
|
||||
end).
|
||||
|
||||
reply_all(Waiters, Reply) ->
|
||||
lists:foreach(fun(From) -> gen_server:reply(From, Reply) end, Waiters).
|
||||
|
||||
register_node(Nodes, Node) ->
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, #{timer := OldRef}} ->
|
||||
_ = erlang:cancel_timer(OldRef),
|
||||
ok;
|
||||
error ->
|
||||
ok
|
||||
end,
|
||||
Ref = erlang:send_after(?IDLE_MS, self(), {release, Node}),
|
||||
Nodes#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}}.
|
||||
|
||||
touch_node(Nodes, Node) ->
|
||||
register_node(Nodes, Node).
|
||||
|
||||
is_node_alive(Node) ->
|
||||
case net_adm:ping(Node) of
|
||||
pong -> true;
|
||||
pang -> false
|
||||
end.
|
||||
|
||||
start_archive_node(Day) ->
|
||||
Node = archive_node_name(Day),
|
||||
case start_archive_peer(Node) of
|
||||
{ok, _} ->
|
||||
case ensure_archive_node_ready(Node) of
|
||||
ok -> {ok, Node};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
start_archive_peer(Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" ->
|
||||
case peer:start_link(#{
|
||||
name => Node,
|
||||
host => host(),
|
||||
connection_timeout => ?PEER_CONN_MS
|
||||
}) of
|
||||
{ok, _} -> {ok, Node};
|
||||
{error, {already_started, _}} -> {ok, Node};
|
||||
Error -> Error
|
||||
end;
|
||||
_ ->
|
||||
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||
{ok, _} -> {ok, Node};
|
||||
{error, {already_running, _}} -> {ok, Node};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_archive_node_ready(Node) ->
|
||||
case rpc:call(Node, mnesia, start, [], 5000) of
|
||||
ok ->
|
||||
case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of
|
||||
{module, archive_fetcher} -> ok;
|
||||
{error, Reason} -> {error, Reason};
|
||||
{badrpc, Reason} -> {error, Reason}
|
||||
end;
|
||||
{badrpc, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
end.
|
||||
|
||||
archive_node_name(Day) ->
|
||||
list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()).
|
||||
|
||||
stop_archive_node(Node) ->
|
||||
rpc:cast(Node, init, stop, []).
|
||||
|
||||
host() ->
|
||||
{ok, Name} = inet:gethostname(),
|
||||
Name.
|
||||
@@ -1,57 +0,0 @@
|
||||
-module(calendar_html_renderer).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([render_month/3, init_cache/0]).
|
||||
|
||||
init_cache() ->
|
||||
case ets:info(archive_html_cache) of
|
||||
undefined ->
|
||||
ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
render_month(Year, Month, Events) ->
|
||||
Key = {Year, Month},
|
||||
try ets:lookup(archive_html_cache, Key) of
|
||||
[{Key, Html}] -> Html;
|
||||
[] ->
|
||||
Html = generate_html(Year, Month, Events),
|
||||
ets:insert(archive_html_cache, {Key, Html}),
|
||||
Html
|
||||
catch
|
||||
_:_ ->
|
||||
generate_html(Year, Month, Events)
|
||||
end.
|
||||
|
||||
generate_html(Year, Month, Events) ->
|
||||
DaysInMonth = calendar:last_day_of_the_month(Year, Month),
|
||||
EventsByDay = group_events_by_day(Events),
|
||||
DayList = lists:seq(1, DaysInMonth),
|
||||
DayCells = lists:map(fun(D) ->
|
||||
DayEvents = maps:get(D, EventsByDay, []),
|
||||
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
|
||||
end, DayList),
|
||||
["<html><body><table>",
|
||||
"<tr>", DayCells, "</tr>",
|
||||
"</table></body></html>"].
|
||||
|
||||
group_events_by_day(Events) ->
|
||||
lists:foldl(fun(Evt, Acc) ->
|
||||
case Evt of
|
||||
#event{start_time = {{_, _, Day}, {_, _, _}}} ->
|
||||
maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc);
|
||||
_ -> Acc
|
||||
end
|
||||
end, #{}, Events).
|
||||
|
||||
format_events(Events) ->
|
||||
case Events of
|
||||
[] -> [];
|
||||
_ ->
|
||||
["<ul>",
|
||||
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
|
||||
["<li>", Title, " (", integer_to_list(H), ":",
|
||||
io_lib:format("~2..0B", [M]), ")</li>"]
|
||||
end, Events),
|
||||
"</ul>"]
|
||||
end.
|
||||
@@ -57,7 +57,6 @@ start_application() ->
|
||||
ok = migration_engine:ensure_applied(),
|
||||
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
||||
ok = stats_collector:subscribe(),
|
||||
calendar_html_renderer:init_cache(),
|
||||
application:ensure_all_started(cowboy),
|
||||
start_http(), % Пользовательский API (8080)
|
||||
start_admin_http(), % Административный API (8445)
|
||||
@@ -127,7 +126,6 @@ start_http() ->
|
||||
{"/v1/share-invites/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/decline", handler_share_invites, []},
|
||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||
{"/v1/events/:id", handler_event_by_id, []},
|
||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик календарного представления (HTML-календарь).
|
||||
%%%
|
||||
%%% GET – возвращает HTML-страницу с календарём на указанный месяц.
|
||||
%%% Требует параметр `month` в формате YYYY-MM.
|
||||
%%% Доступно только владельцу календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_view).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-define(ARCHIVE_CALL_TIMEOUT, 8000).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:calendar_id/view">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get calendar HTML view for a specific month">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [
|
||||
#{
|
||||
name => <<"calendar_id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Calendar ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
},
|
||||
#{
|
||||
name => <<"month">>,
|
||||
in => <<"query">>,
|
||||
description => <<"Month in YYYY-MM format">>,
|
||||
required => true,
|
||||
schema => #{type => string, pattern => <<"^\\d{4}-\\d{2}$">>}
|
||||
}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"HTML calendar page">>,
|
||||
content => #{<<"text/html">> => #{schema => #{type => string}}}
|
||||
},
|
||||
400 => #{description => <<"Missing or invalid 'month' parameter">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Access denied">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Основной обработчик запроса.
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case is_owner(UserId, CalendarId) of
|
||||
true -> process_view(Req1, CalendarId);
|
||||
false -> handler_utils:send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, _Code, _Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Unauthorized">>)
|
||||
end.
|
||||
|
||||
%% @private Проверяет, является ли пользователь владельцем календаря.
|
||||
-spec is_owner(binary(), binary()) -> boolean().
|
||||
is_owner(UserId, CalendarId) ->
|
||||
case mnesia:dirty_read({calendar, CalendarId}) of
|
||||
[#calendar{owner_id = UserId}] -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% @private Обрабатывает запрос на отображение календаря.
|
||||
-spec process_view(cowboy_req:req(), binary()) -> {ok, cowboy_req:req(), any()}.
|
||||
process_view(Req, CalendarId) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
case lists:keyfind(<<"month">>, 1, Qs) of
|
||||
{<<"month">>, MonthBin} ->
|
||||
case binary:split(MonthBin, <<"-">>) of
|
||||
[YearStr, MonthStr] ->
|
||||
Year = binary_to_integer(YearStr),
|
||||
Month = binary_to_integer(MonthStr),
|
||||
Events = fetch_events(CalendarId, Year, Month),
|
||||
Html = calendar_html_renderer:render_month(Year, Month, Events),
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"text/html">>,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>
|
||||
},
|
||||
cowboy_req:reply(200, Headers, Html, Req),
|
||||
{ok, Req, undefined};
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>)
|
||||
end;
|
||||
false ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>)
|
||||
end.
|
||||
|
||||
%% @private Извлекает события для указанного месяца календаря.
|
||||
-spec fetch_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_events(CalendarId, Year, Month) ->
|
||||
case is_hot(Year, Month) of
|
||||
true -> fetch_hot_events(CalendarId, Year, Month);
|
||||
false -> fetch_archive_events(CalendarId, Year, Month)
|
||||
end.
|
||||
|
||||
%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты).
|
||||
-spec is_hot(integer(), integer()) -> boolean().
|
||||
is_hot(Year, Month) ->
|
||||
Current = calendar:local_time(),
|
||||
Target = {{Year, Month, 1}, {0, 0, 0}},
|
||||
calendar:datetime_to_gregorian_seconds(Current) -
|
||||
calendar:datetime_to_gregorian_seconds(Target) < 30 * 86400.
|
||||
|
||||
%% @private Извлекает "горячие" события из Mnesia.
|
||||
-spec fetch_hot_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_hot_events(CalendarId, Year, Month) ->
|
||||
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||
mnesia:dirty_select(event, [
|
||||
{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
|
||||
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||
['$_']}
|
||||
]).
|
||||
|
||||
%% @private Извлекает архивные события через RPC на архивный узел.
|
||||
%% При недоступности архива возвращает события из основной Mnesia.
|
||||
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_archive_events(CalendarId, Year, Month) ->
|
||||
DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])),
|
||||
case safe_get_archive_node(DayStr) of
|
||||
{ok, Node} ->
|
||||
case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month],
|
||||
?ARCHIVE_CALL_TIMEOUT) of
|
||||
Events when is_list(Events) -> Events;
|
||||
_ -> fetch_hot_events(CalendarId, Year, Month)
|
||||
end;
|
||||
{error, _} ->
|
||||
fetch_hot_events(CalendarId, Year, Month)
|
||||
end.
|
||||
|
||||
-spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}.
|
||||
safe_get_archive_node(Day) ->
|
||||
try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of
|
||||
{ok, Node} when is_atom(Node) -> {ok, Node};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
exit:{timeout, _} -> {error, timeout};
|
||||
exit:{noproc, _} -> {error, noproc}
|
||||
end.
|
||||
@@ -186,6 +186,8 @@ update_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, invalid_specialist} ->
|
||||
@@ -215,6 +217,8 @@ delete_event(Req) ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
|
||||
@@ -184,6 +184,8 @@ create_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
@@ -201,6 +203,8 @@ create_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
@@ -239,7 +243,10 @@ list_events(Req) ->
|
||||
{FromStr, ToStr} ->
|
||||
FromDt = parse_datetime_binary(FromStr),
|
||||
ToDt = parse_datetime_binary(ToStr),
|
||||
expand_recurring_events(UserId, Events, FromDt, ToDt)
|
||||
HotJson = expand_recurring_events(UserId, Events, FromDt, ToDt),
|
||||
SnapJson = logic_month_archive:events_json_in_range(
|
||||
CalendarId, FromDt, ToDt),
|
||||
merge_event_json(HotJson, SnapJson)
|
||||
end,
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
@@ -308,24 +315,36 @@ expand_recurring_events(UserId, Events, From, To) ->
|
||||
lists:flatmap(fun(Event) ->
|
||||
case Event#event.event_type of
|
||||
single ->
|
||||
case is_in_range(Event#event.start_time, From, To) of
|
||||
true -> [handler_utils:event_to_json(Event)];
|
||||
false -> []
|
||||
case logic_month_archive:month_is_closed(Event#event.start_time) of
|
||||
true -> [];
|
||||
false ->
|
||||
case is_in_range(Event#event.start_time, From, To) of
|
||||
true -> [handler_utils:event_to_json(Event)];
|
||||
false -> []
|
||||
end
|
||||
end;
|
||||
recurring ->
|
||||
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
||||
{ok, Occurrences} ->
|
||||
lists:filtermap(
|
||||
fun({virtual, Occ}) ->
|
||||
case is_in_range(Occ, From, To) of
|
||||
true -> {true, occurrence_to_json(Event, Occ)};
|
||||
false -> false
|
||||
case logic_month_archive:month_is_closed(Occ) of
|
||||
true -> false;
|
||||
false ->
|
||||
case is_in_range(Occ, From, To) of
|
||||
true -> {true, occurrence_to_json(Event, Occ)};
|
||||
false -> false
|
||||
end
|
||||
end;
|
||||
({materialized, Instance}) ->
|
||||
case is_in_range(Instance#event.start_time, From, To) of
|
||||
true -> {true, handler_utils:event_to_json(Instance)};
|
||||
false -> false
|
||||
end
|
||||
case logic_month_archive:month_is_closed(Instance#event.start_time) of
|
||||
true -> false;
|
||||
false ->
|
||||
case is_in_range(Instance#event.start_time, From, To) of
|
||||
true -> {true, handler_utils:event_to_json(Instance)};
|
||||
false -> false
|
||||
end
|
||||
end
|
||||
end, Occurrences);
|
||||
_ -> []
|
||||
end
|
||||
@@ -334,6 +353,16 @@ expand_recurring_events(UserId, Events, From, To) ->
|
||||
|
||||
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
|
||||
|
||||
merge_event_json(Hot, Snap) ->
|
||||
Ids = maps:from_list(
|
||||
[{maps:get(<<"id">>, M, maps:get(id, M, undefined)), true} || M <- Hot]),
|
||||
Extra = [M || M <- Snap,
|
||||
begin
|
||||
Id = maps:get(<<"id">>, M, maps:get(id, M, undefined)),
|
||||
Id =:= undefined orelse not maps:is_key(Id, Ids)
|
||||
end],
|
||||
Hot ++ Extra.
|
||||
|
||||
parse_datetime_binary(Str) ->
|
||||
{ok, Dt} = handler_utils:parse_datetime(Str),
|
||||
Dt.
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification, push_subscription,
|
||||
stats_counter, stats_daily, node_metric, schema_migration
|
||||
stats_counter, stats_daily, node_metric, schema_migration,
|
||||
month_snapshot
|
||||
]).
|
||||
|
||||
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
||||
@@ -211,6 +212,18 @@ ensure_schema_disc() ->
|
||||
true -> ok
|
||||
end.
|
||||
|
||||
add_local_disc_copy(month_snapshot) ->
|
||||
case lists:member(node(), mnesia:table_info(month_snapshot, disc_only_copies) ++
|
||||
mnesia:table_info(month_snapshot, disc_copies)) of
|
||||
true -> ok;
|
||||
false ->
|
||||
case mnesia:add_table_copy(month_snapshot, node(), disc_only_copies) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} ->
|
||||
io:format("Could not add disc_only copy for month_snapshot: ~p~n", [Reason])
|
||||
end
|
||||
end;
|
||||
add_local_disc_copy(Tab) ->
|
||||
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
||||
false ->
|
||||
@@ -284,7 +297,9 @@ prune_dead_nodes() ->
|
||||
lists:foreach(fun(Node) ->
|
||||
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
||||
lists:foreach(fun(Tab) ->
|
||||
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of
|
||||
HasDisc = lists:member(Node, mnesia:table_info(Tab, disc_copies)),
|
||||
HasOnly = lists:member(Node, mnesia:table_info(Tab, disc_only_copies)),
|
||||
case HasDisc orelse HasOnly of
|
||||
true -> catch mnesia:del_table_copy(Tab, Node);
|
||||
false -> ok
|
||||
end
|
||||
@@ -347,6 +362,8 @@ table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_i
|
||||
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(month_snapshot) ->
|
||||
[{disc_only_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
|
||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
||||
@@ -398,4 +415,6 @@ create_indices() ->
|
||||
mnesia:add_table_index(auth_session, subject_id),
|
||||
mnesia:add_table_index(report, resolved_by),
|
||||
mnesia:add_table_index(ticket, assigned_to),
|
||||
mnesia:add_table_index(month_snapshot, calendar_id),
|
||||
mnesia:add_table_index(month_snapshot, year_month),
|
||||
ok.
|
||||
@@ -1,5 +1,5 @@
|
||||
%% ===================================================================
|
||||
%% EventHub – инфраструктурный супервизор (с archive_manager)
|
||||
%% EventHub – инфраструктурный супервизор
|
||||
%% ===================================================================
|
||||
-module(infra_sup).
|
||||
-behaviour(supervisor).
|
||||
@@ -39,12 +39,12 @@ init([]) ->
|
||||
start => {node_monitor, start_link, []},
|
||||
restart => permanent,
|
||||
type => worker},
|
||||
#{id => archive_manager,
|
||||
start => {archive_manager, start_link, []},
|
||||
#{id => month_archive_worker,
|
||||
start => {month_archive_worker, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [archive_manager]},
|
||||
modules => [month_archive_worker]},
|
||||
#{id => migration_engine,
|
||||
start => {migration_engine, start_link, []},
|
||||
restart => permanent,
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
'20260814193000_waitlist_entry',
|
||||
'20260815200000_push_subscription',
|
||||
'20260815220000_calendar_share_invite',
|
||||
'20260816180000_auth_session_device'
|
||||
'20260816180000_auth_session_device',
|
||||
'20260817140000_month_snapshot'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
%% Periodic month snapshots (Back#76). Replaces extra-node archive_manager.
|
||||
-module(month_archive_worker).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0, run_once/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(DEFAULT_INTERVAL_MS, 3600000).
|
||||
|
||||
-record(state, {interval_ms :: pos_integer(), timer_ref :: reference() | undefined}).
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
run_once() ->
|
||||
logic_month_archive:run_once().
|
||||
|
||||
init([]) ->
|
||||
Interval = application:get_env(eventhub, archive_interval_ms, ?DEFAULT_INTERVAL_MS),
|
||||
Ref = erlang:send_after(15000, self(), run),
|
||||
logger:info(#{what => month_archive_worker_started, interval_ms => Interval}),
|
||||
{ok, #state{interval_ms = Interval, timer_ref = Ref}}.
|
||||
|
||||
handle_call(run_once, _From, State) ->
|
||||
{reply, logic_month_archive:run_once(), State};
|
||||
handle_call(_Msg, _From, State) ->
|
||||
{reply, {error, unknown_call}, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(run, State) ->
|
||||
_ = catch logic_month_archive:run_once(),
|
||||
Ref = erlang:send_after(State#state.interval_ms, self(), run),
|
||||
{noreply, State#state{timer_ref = Ref}};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, #state{timer_ref = Ref}) ->
|
||||
case Ref of
|
||||
undefined -> ok;
|
||||
_ -> erlang:cancel_timer(Ref), ok
|
||||
end.
|
||||
|
||||
code_change(_O, State, _E) ->
|
||||
{ok, State}.
|
||||
@@ -19,6 +19,9 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
case logic_month_archive:assert_writable(StartTime) of
|
||||
{error, archived} -> {error, archived};
|
||||
ok ->
|
||||
case validate_event_time(StartTime, UserId) of
|
||||
ok ->
|
||||
case logic_automoderation:evaluate_texts([Title, Description]) of
|
||||
@@ -39,6 +42,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
||||
end;
|
||||
{error, _} = Error ->
|
||||
Error
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -56,6 +60,9 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
case logic_month_archive:assert_writable(StartTime) of
|
||||
{error, archived} -> {error, archived};
|
||||
ok ->
|
||||
case validate_event_time(StartTime, UserId) of
|
||||
ok ->
|
||||
case logic_recurrence:validate_rrule(RRule) of
|
||||
@@ -81,6 +88,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
||||
end;
|
||||
{error, _} = Error ->
|
||||
Error
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -202,6 +210,13 @@ update_event(UserId, EventId, Updates) ->
|
||||
{error, _} = E ->
|
||||
E;
|
||||
ok ->
|
||||
NewStart = proplists:get_value(start_time, Updates, Event#event.start_time),
|
||||
case logic_month_archive:assert_writable(Event#event.start_time) of
|
||||
{error, archived} -> {error, archived};
|
||||
ok ->
|
||||
case logic_month_archive:assert_writable(NewStart) of
|
||||
{error, archived} -> {error, archived};
|
||||
ok ->
|
||||
ValidUpdates = validate_updates(Updates, UserId),
|
||||
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
||||
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
||||
@@ -231,6 +246,8 @@ update_event(UserId, EventId, Updates) ->
|
||||
Error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -250,7 +267,10 @@ delete_event(UserId, EventId) ->
|
||||
{ok, Calendar} ->
|
||||
case logic_calendar:can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
core_event:delete(EventId);
|
||||
case logic_month_archive:assert_writable(Event#event.start_time) of
|
||||
{error, archived} -> {error, archived};
|
||||
ok -> core_event:delete(EventId)
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
end;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Calendar month snapshots (Back#76). Same node, no extra BEAM.
|
||||
%%% Bookings stay in hot (inbox). Events of closed months move to snapshot/file.
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_month_archive).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
grace_days/0,
|
||||
warm_months/0,
|
||||
month_key/2,
|
||||
month_is_closed/1,
|
||||
month_is_closed/2,
|
||||
assert_writable/1,
|
||||
events_json_in_range/3,
|
||||
snapshot_month/3,
|
||||
run_once/0,
|
||||
run_once/1
|
||||
]).
|
||||
|
||||
-define(DEFAULT_GRACE, 7).
|
||||
-define(DEFAULT_WARM, 3).
|
||||
|
||||
grace_days() -> env_int("ARCHIVE_GRACE_DAYS", ?DEFAULT_GRACE).
|
||||
warm_months() -> env_int("ARCHIVE_WARM_MONTHS", ?DEFAULT_WARM).
|
||||
|
||||
-spec month_key(integer(), integer()) -> binary().
|
||||
month_key(Year, Month) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B", [Year, Month])).
|
||||
|
||||
-spec month_is_closed(calendar:datetime()) -> boolean().
|
||||
month_is_closed({{Y, M, _}, _}) -> month_is_closed(Y, M).
|
||||
|
||||
-spec month_is_closed(integer(), integer()) -> boolean().
|
||||
month_is_closed(Year, Month) ->
|
||||
{{CY, CM, CD}, _} = calendar:universal_time(),
|
||||
{NY, NM} = next_month(Year, Month),
|
||||
GraceEnd = add_days({NY, NM, 1}, grace_days()),
|
||||
{CY, CM, CD} >= GraceEnd.
|
||||
|
||||
-spec assert_writable(calendar:datetime()) -> ok | {error, archived}.
|
||||
assert_writable(StartTime) ->
|
||||
case month_is_closed(StartTime) of
|
||||
true -> {error, archived};
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
%% Snapshot JSON maps overlapping [From, To] (inclusive). Missing snapshot → [].
|
||||
-spec events_json_in_range(binary(), calendar:datetime(), calendar:datetime()) -> [map()].
|
||||
events_json_in_range(CalendarId, From, To) ->
|
||||
Months = months_covering(From, To),
|
||||
lists:flatmap(
|
||||
fun({Y, M}) ->
|
||||
case month_is_closed(Y, M) of
|
||||
false -> [];
|
||||
true ->
|
||||
lists:filter(
|
||||
fun(Map) -> json_in_range(Map, From, To) end,
|
||||
load_events(CalendarId, month_key(Y, M)))
|
||||
end
|
||||
end, Months).
|
||||
|
||||
-spec snapshot_month(binary(), integer(), integer()) -> ok | {error, term()}.
|
||||
snapshot_month(CalendarId, Year, Month) ->
|
||||
Key = month_key(Year, Month),
|
||||
Id = {CalendarId, Key},
|
||||
case mnesia:dirty_read(month_snapshot, Id) of
|
||||
[#month_snapshot{}] ->
|
||||
delete_hot_for_month(CalendarId, Year, Month),
|
||||
maybe_spill(CalendarId, Key),
|
||||
ok;
|
||||
[] ->
|
||||
Events = hot_events_in_month(CalendarId, Year, Month),
|
||||
Payload = encode_events(Events),
|
||||
Rec = #month_snapshot{
|
||||
id = Id,
|
||||
calendar_id = CalendarId,
|
||||
year_month = Key,
|
||||
payload = Payload,
|
||||
storage = mnesia,
|
||||
file_rel = <<>>,
|
||||
created_at = calendar:universal_time()
|
||||
},
|
||||
WriteDel = fun() ->
|
||||
ok = mnesia:write(Rec),
|
||||
lists:foreach(fun(E) -> mnesia:delete({event, E#event.id}) end,
|
||||
deletable_events(Events)),
|
||||
ok
|
||||
end,
|
||||
case mnesia:transaction(WriteDel) of
|
||||
{atomic, ok} ->
|
||||
maybe_spill(CalendarId, Key),
|
||||
ok;
|
||||
{aborted, Reason} ->
|
||||
{error, Reason}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec run_once() -> non_neg_integer().
|
||||
run_once() ->
|
||||
run_once(calendar:universal_time()).
|
||||
|
||||
-spec run_once(calendar:datetime()) -> non_neg_integer().
|
||||
run_once(_Now) ->
|
||||
Cals = mnesia:dirty_all_keys(calendar),
|
||||
lists:foldl(fun(CalId, Acc) -> Acc + snapshot_due(CalId) end, 0, Cals).
|
||||
|
||||
%%%===================================================================
|
||||
snapshot_due(CalendarId) ->
|
||||
Events = case catch mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
|
||||
List when is_list(List) -> List;
|
||||
_ -> []
|
||||
end,
|
||||
Months = lists:usort([ym(E#event.start_time) || E <- Events]),
|
||||
lists:foldl(
|
||||
fun({Y, M}, N) ->
|
||||
case month_is_closed(Y, M) of
|
||||
true ->
|
||||
case snapshot_month(CalendarId, Y, M) of
|
||||
ok -> N + 1;
|
||||
_ -> N
|
||||
end;
|
||||
false -> N
|
||||
end
|
||||
end, 0, Months).
|
||||
|
||||
ym({{Y, M, _}, _}) -> {Y, M};
|
||||
ym(_) -> {1970, 1}.
|
||||
|
||||
hot_events_in_month(CalendarId, Year, Month) ->
|
||||
{Start, End} = month_bounds(Year, Month),
|
||||
case mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
|
||||
List when is_list(List) ->
|
||||
[E || E <- List, E#event.start_time >= Start, E#event.start_time =< End];
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
deletable_events(Events) ->
|
||||
[E || E <- Events, not keep_in_hot(E)].
|
||||
|
||||
delete_hot_for_month(CalendarId, Year, Month) ->
|
||||
Events = deletable_events(hot_events_in_month(CalendarId, Year, Month)),
|
||||
lists:foreach(fun(E) -> mnesia:dirty_delete({event, E#event.id}) end, Events).
|
||||
|
||||
keep_in_hot(#event{event_type = recurring, is_instance = false}) -> true;
|
||||
keep_in_hot(_) -> false.
|
||||
|
||||
encode_events(Events) ->
|
||||
Maps = [handler_utils:event_to_json(E) || E <- Events, not keep_in_hot(E)],
|
||||
zlib:gzip(jsx:encode(#{<<"v">> => 1, <<"events">> => Maps})).
|
||||
|
||||
load_events(CalendarId, YearMonth) ->
|
||||
Id = {CalendarId, YearMonth},
|
||||
case mnesia:dirty_read(month_snapshot, Id) of
|
||||
[#month_snapshot{storage = file, file_rel = Rel}] when Rel =/= <<>> ->
|
||||
decode_file(Rel);
|
||||
[#month_snapshot{payload = Bin}] when is_binary(Bin), Bin =/= <<>> ->
|
||||
decode_bin(Bin);
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
decode_file(Rel) ->
|
||||
Path = filename:join(logic_upload:upload_dir(), binary_to_list(Rel)),
|
||||
case file:read_file(Path) of
|
||||
{ok, Bin} -> decode_bin(Bin);
|
||||
_ -> []
|
||||
end.
|
||||
|
||||
decode_bin(Bin) ->
|
||||
try jsx:decode(zlib:gunzip(Bin), [return_maps]) of
|
||||
#{<<"events">> := List} when is_list(List) -> List;
|
||||
_ -> []
|
||||
catch
|
||||
_:_ -> []
|
||||
end.
|
||||
|
||||
maybe_spill(CalendarId, YearMonth) ->
|
||||
Warm = warm_months(),
|
||||
Id = {CalendarId, YearMonth},
|
||||
case mnesia:dirty_index_read(month_snapshot, CalendarId, #month_snapshot.calendar_id) of
|
||||
List when is_list(List), length(List) > Warm ->
|
||||
Sorted = lists:sort(
|
||||
fun(#month_snapshot{year_month = A}, #month_snapshot{year_month = B}) ->
|
||||
A =< B
|
||||
end, List),
|
||||
{Old, Keep} = lists:split(length(Sorted) - Warm, Sorted),
|
||||
_ = Keep,
|
||||
lists:foreach(fun(Rec) -> spill_one(Rec) end, Old),
|
||||
ok;
|
||||
_ ->
|
||||
_ = Id,
|
||||
ok
|
||||
end.
|
||||
|
||||
spill_one(#month_snapshot{storage = file}) -> ok;
|
||||
spill_one(#month_snapshot{id = Id, calendar_id = Cal, year_month = YM,
|
||||
payload = Payload} = Rec) ->
|
||||
Rel = filename:join(["archive", binary_to_list(Cal), binary_to_list(YM) ++ ".json.gz"]),
|
||||
Abs = filename:join(logic_upload:upload_dir(), Rel),
|
||||
ok = filelib:ensure_dir(Abs),
|
||||
case file:write_file(Abs, Payload) of
|
||||
ok ->
|
||||
mnesia:dirty_write(Rec#month_snapshot{
|
||||
payload = <<>>,
|
||||
storage = file,
|
||||
file_rel = list_to_binary(Rel)
|
||||
}),
|
||||
ok;
|
||||
_ ->
|
||||
_ = Id,
|
||||
ok
|
||||
end.
|
||||
|
||||
json_in_range(Map, From, To) ->
|
||||
case maps:get(<<"start_time">>, Map, maps:get(start_time, Map, undefined)) of
|
||||
undefined -> false;
|
||||
Iso when is_binary(Iso) ->
|
||||
case handler_utils:parse_datetime(Iso) of
|
||||
{ok, Dt} -> Dt >= From andalso Dt =< To;
|
||||
_ -> false
|
||||
end;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
months_covering({{Y1, M1, _}, _}, {{Y2, M2, _}, _}) ->
|
||||
months_covering1({Y1, M1}, {Y2, M2}, []).
|
||||
|
||||
months_covering1(Cur, End, Acc) when Cur > End -> lists:reverse(Acc);
|
||||
months_covering1({Y, M} = Cur, End, Acc) ->
|
||||
months_covering1(next_month(Y, M), End, [Cur | Acc]).
|
||||
|
||||
next_month(Y, 12) -> {Y + 1, 1};
|
||||
next_month(Y, M) -> {Y, M + 1}.
|
||||
|
||||
month_bounds(Year, Month) ->
|
||||
Last = calendar:last_day_of_the_month(Year, Month),
|
||||
{{{Year, Month, 1}, {0, 0, 0}}, {{Year, Month, Last}, {23, 59, 59}}}.
|
||||
|
||||
add_days({Y, M, D}, Days) ->
|
||||
Greg = calendar:date_to_gregorian_days(Y, M, D) + Days,
|
||||
calendar:gregorian_days_to_date(Greg).
|
||||
|
||||
env_int(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N when N > 0 -> N;
|
||||
_ -> Default
|
||||
catch
|
||||
_:_ -> Default
|
||||
end
|
||||
end.
|
||||
@@ -0,0 +1,41 @@
|
||||
%% @doc month_snapshot table (Back#76). disc_only — blobs not in RAM.
|
||||
-module('20260817140000_month_snapshot').
|
||||
|
||||
-export([up/0, down/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
up() ->
|
||||
ensure_table(),
|
||||
ensure_index(month_snapshot, calendar_id),
|
||||
ensure_index(month_snapshot, year_month),
|
||||
ok.
|
||||
|
||||
down() ->
|
||||
_ = mnesia:delete_table(month_snapshot),
|
||||
ok.
|
||||
|
||||
ensure_table() ->
|
||||
case lists:member(month_snapshot, mnesia:system_info(tables)) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
Attrs = record_info(fields, month_snapshot),
|
||||
case mnesia:create_table(month_snapshot, [
|
||||
{disc_only_copies, [node()]},
|
||||
{attributes, Attrs}
|
||||
]) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, month_snapshot}} -> ok;
|
||||
{aborted, Reason} -> error({create_table_failed, month_snapshot, Reason})
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_index(Table, Attr) ->
|
||||
case mnesia:add_table_index(Table, Attr) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||
end.
|
||||
@@ -646,56 +646,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/calendars/:calendar_id/view": {
|
||||
"get": {
|
||||
"description": "Get calendar HTML view for a specific month",
|
||||
"tags": [
|
||||
"Calendars"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "calendar_id",
|
||||
"description": "Calendar ID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "month",
|
||||
"description": "Month in YYYY-MM format",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}$"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML calendar page",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing or invalid 'month' parameter"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Access denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/calendars/:id": {
|
||||
"delete": {
|
||||
"description": "Delete calendar",
|
||||
|
||||
@@ -83,7 +83,6 @@ user() ->
|
||||
handler_calendar_specialist_invites,
|
||||
handler_specialist_invites,
|
||||
handler_users_lookup,
|
||||
handler_calendar_view,
|
||||
handler_calendars,
|
||||
handler_event_by_id,
|
||||
handler_event_occurrences,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Тесты клиентского API для HTML-представления календаря.
|
||||
%%%
|
||||
%%% Покрывает эндпоинты:
|
||||
%%% GET /v1/calendars/:calendar_id/view
|
||||
%%%
|
||||
%%% Проверяет:
|
||||
%%% - успешное получение HTML-страницы (200, text/html)
|
||||
%%% - ошибку 401 без токена
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(user_calendar_view_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-export([test/0]).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Главная тестовая функция
|
||||
%%%===================================================================
|
||||
|
||||
-spec test() -> ok.
|
||||
test() ->
|
||||
ct:pal("=== User Calendar View Tests ==="),
|
||||
Token = api_test_runner:get_user_token(),
|
||||
|
||||
% Создаём календарь
|
||||
CalId = api_test_runner:create_calendar(Token, #{title => <<"ViewCal">>}),
|
||||
|
||||
test_get_calendar_view(Token, CalId),
|
||||
test_get_calendar_view_unauthorized(CalId),
|
||||
|
||||
ct:pal("=== All user calendar view tests passed ==="),
|
||||
ok.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Тестовые функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Успешный запрос HTML-представления: 200 OK, тип text/html.
|
||||
-spec test_get_calendar_view(binary(), binary()) -> ok.
|
||||
test_get_calendar_view(Token, CalId) ->
|
||||
ct:pal(" TEST: Get calendar HTML view"),
|
||||
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
|
||||
Resp = api_test_runner:client_request(get, Path, Token),
|
||||
{ok, 200, Headers, Body} = Resp,
|
||||
?assert(lists:keymember("content-type", 1, Headers)),
|
||||
{"content-type", CT} = lists:keyfind("content-type", 1, Headers),
|
||||
?assert(string:str(CT, "text/html") > 0),
|
||||
% Body может быть строкой или binary, приводим к binary и проверяем непустоту
|
||||
BodyBin = iolist_to_binary(Body),
|
||||
?assert(byte_size(BodyBin) > 0),
|
||||
ct:pal(" OK: got HTML of ~p bytes", [byte_size(BodyBin)]).
|
||||
|
||||
%% @doc Запрос без токена: 401 Unauthorized.
|
||||
-spec test_get_calendar_view_unauthorized(binary()) -> ok.
|
||||
test_get_calendar_view_unauthorized(CalId) ->
|
||||
ct:pal(" TEST: Get calendar view without token"),
|
||||
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
|
||||
Resp = api_test_runner:client_request(get, Path, <<>>),
|
||||
?assertMatch({ok, 401, _, _}, Resp),
|
||||
ct:pal(" OK: got 401").
|
||||
@@ -32,7 +32,6 @@ all() ->
|
||||
user_test_user_me,
|
||||
user_test_calendars,
|
||||
user_test_calendar_by_id,
|
||||
user_test_calendar_view,
|
||||
user_test_event_by_id,
|
||||
user_test_events,
|
||||
user_test_occurrence_cancel,
|
||||
@@ -122,9 +121,6 @@ user_test_calendars(_Config) ->
|
||||
user_test_calendar_by_id(_Config) ->
|
||||
user_calendar_by_id_tests:test().
|
||||
|
||||
user_test_calendar_view(_Config) ->
|
||||
user_calendar_view_tests:test().
|
||||
|
||||
user_test_events(_Config) ->
|
||||
user_events_tests:test().
|
||||
|
||||
|
||||
@@ -224,7 +224,11 @@ def do_random_action(bot):
|
||||
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
|
||||
if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
|
||||
cal = random.choice(resp_cal.json())
|
||||
request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers)
|
||||
request(
|
||||
"GET",
|
||||
f"{base}/v1/calendars/{cal['id']}/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z",
|
||||
headers=headers,
|
||||
)
|
||||
elif action == 14:
|
||||
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
|
||||
except Exception as e:
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
</request>
|
||||
<thinktime min="500" max="2000" random="true"/>
|
||||
|
||||
<!-- 5. GET /v1/calendars/:id/view?month=2026-06 – HTML-представление -->
|
||||
<!-- 5. GET /v1/calendars/:id/events – месяц (hot + archive merge) -->
|
||||
<request subst="true">
|
||||
<http url="/v1/calendars/%%_calendar_id%%/view?month=2026-06" method="GET">
|
||||
<http url="/v1/calendars/%%_calendar_id%%/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z" method="GET">
|
||||
<http_header name="Authorization" value="Bearer %%_token%%"/>
|
||||
</http>
|
||||
</request>
|
||||
|
||||
@@ -99,7 +99,8 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
||||
{push_subscription, [user_id, endpoint]},
|
||||
{auth_session, [family_id, subject_id]},
|
||||
{report, [resolved_by]},
|
||||
{ticket, [assigned_to]}
|
||||
{ticket, [assigned_to]},
|
||||
{month_snapshot, [calendar_id, year_month]}
|
||||
]),
|
||||
ok.
|
||||
|
||||
@@ -204,7 +205,9 @@ table_opts(node_metric) ->
|
||||
[{ram_copies, [node()]}, {local_content, true},
|
||||
{attributes, record_info(fields, node_metric)}];
|
||||
table_opts(schema_migration) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}].
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(month_snapshot) ->
|
||||
[{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}].
|
||||
|
||||
%%%===================================================================
|
||||
%%% Domain seeds
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-module(logic_month_archive_tests).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLES, [event, month_snapshot, booking, calendar]).
|
||||
-define(CAL, <<"cal_arch_1">>).
|
||||
|
||||
setup() ->
|
||||
eh_test_support:start_mnesia(),
|
||||
eh_test_support:ensure_tables(?TABLES),
|
||||
Tmp = filename:join("/tmp", "eh_arch_" ++ integer_to_list(erlang:unique_integer([positive]))),
|
||||
ok = filelib:ensure_dir(filename:join(Tmp, "dummy")),
|
||||
true = os:putenv("UPLOAD_DIR", Tmp),
|
||||
ok.
|
||||
|
||||
cleanup(_) ->
|
||||
eh_test_support:clear_tables(?TABLES),
|
||||
eh_test_support:delete_tables(?TABLES),
|
||||
eh_test_support:stop_mnesia(),
|
||||
ok.
|
||||
|
||||
logic_month_archive_test_() ->
|
||||
{foreach, fun setup/0, fun cleanup/1, [
|
||||
{"closed month is not writable", fun test_assert_writable/0},
|
||||
{"snapshot removes hot event and serves json", fun test_snapshot_roundtrip/0}
|
||||
]}.
|
||||
|
||||
test_assert_writable() ->
|
||||
{{Y, M, _}, _} = calendar:universal_time(),
|
||||
?assertEqual(ok, logic_month_archive:assert_writable({{Y, M, 15}, {12, 0, 0}})),
|
||||
?assertEqual({error, archived},
|
||||
logic_month_archive:assert_writable({{2020, 1, 10}, {12, 0, 0}})).
|
||||
|
||||
test_snapshot_roundtrip() ->
|
||||
{ok, Ev} = core_event:create(?CAL, <<"old">>, {{2020, 6, 10}, {10, 0, 0}}, 60),
|
||||
ok = logic_month_archive:snapshot_month(?CAL, 2020, 6),
|
||||
?assertEqual([], mnesia:dirty_read(event, Ev#event.id)),
|
||||
Json = logic_month_archive:events_json_in_range(
|
||||
?CAL, {{2020, 6, 1}, {0, 0, 0}}, {{2020, 6, 30}, {23, 59, 59}}),
|
||||
?assertEqual(1, length(Json)),
|
||||
[Map] = Json,
|
||||
Id = maps:get(<<"id">>, Map, maps:get(id, Map, undefined)),
|
||||
?assertEqual(Ev#event.id, Id).
|
||||
@@ -18,7 +18,8 @@
|
||||
"20260814193000_waitlist_entry",
|
||||
"20260815200000_push_subscription",
|
||||
"20260815220000_calendar_share_invite",
|
||||
"20260816180000_auth_session_device"
|
||||
"20260816180000_auth_session_device",
|
||||
"20260817140000_month_snapshot"
|
||||
]).
|
||||
|
||||
setup() ->
|
||||
|
||||
Reference in New Issue
Block a user