Mnesia migrations with global lock on startup. Refs EventHub/EventHubBack#24
This commit is contained in:
+232
-231
@@ -1,232 +1,233 @@
|
|||||||
-module(eventhub_app).
|
-module(eventhub_app).
|
||||||
-behaviour(application).
|
-behaviour(application).
|
||||||
-export([start/2, stop/1]).
|
-export([start/2, stop/1]).
|
||||||
|
|
||||||
start(_StartType, _StartArgs) ->
|
start(_StartType, _StartArgs) ->
|
||||||
case infra_sup:start_link() of
|
case infra_sup:start_link() of
|
||||||
{ok, Pid} ->
|
{ok, Pid} ->
|
||||||
% Определяем список узлов кластера, если режим CLUSTER_MODE=true
|
% Определяем список узлов кластера, если режим CLUSTER_MODE=true
|
||||||
Nodes = case os:getenv("CLUSTER_MODE", "false") of
|
Nodes = case os:getenv("CLUSTER_MODE", "false") of
|
||||||
"true" ->
|
"true" ->
|
||||||
DnsName = os:getenv("DNS_NAME", "eventhub-node"),
|
DnsName = os:getenv("DNS_NAME", "eventhub-node"),
|
||||||
try inet:getaddrs(DnsName, inet) of
|
try inet:getaddrs(DnsName, inet) of
|
||||||
{ok, IPs} when is_list(IPs), IPs /= [] ->
|
{ok, IPs} when is_list(IPs), IPs /= [] ->
|
||||||
% Получаем имена всех узлов Erlang, зарегистрированных в EPMD
|
% Получаем имена всех узлов Erlang, зарегистрированных в EPMD
|
||||||
AllNodes = lists:flatmap(fun(IP) ->
|
AllNodes = lists:flatmap(fun(IP) ->
|
||||||
case erl_epmd:names(IP) of
|
case erl_epmd:names(IP) of
|
||||||
{ok, Names} ->
|
{ok, Names} ->
|
||||||
[list_to_atom(Name ++ "@" ++ Name) || {Name, _Port} <- Names,
|
[list_to_atom(Name ++ "@" ++ Name) || {Name, _Port} <- Names,
|
||||||
lists:prefix("eventhub-node", Name)];
|
lists:prefix("eventhub-node", Name)];
|
||||||
_ -> []
|
_ -> []
|
||||||
end
|
end
|
||||||
end, IPs),
|
end, IPs),
|
||||||
% Исключаем свой узел, чтобы не подключаться к самому себе
|
% Исключаем свой узел, чтобы не подключаться к самому себе
|
||||||
AllNodes -- [node()];
|
AllNodes -- [node()];
|
||||||
_ -> []
|
_ -> []
|
||||||
catch
|
catch
|
||||||
_:_ ->
|
_:_ ->
|
||||||
io:format("DNS lookup failed, starting as first node~n"),
|
io:format("DNS lookup failed, starting as first node~n"),
|
||||||
[]
|
[]
|
||||||
end;
|
end;
|
||||||
_ -> []
|
_ -> []
|
||||||
end,
|
end,
|
||||||
case Nodes of
|
case Nodes of
|
||||||
[] ->
|
[] ->
|
||||||
io:format("~nCluster: no nodes found or first node~n");
|
io:format("~nCluster: no nodes found or first node~n");
|
||||||
_ ->
|
_ ->
|
||||||
io:format("~nCluster: discovered nodes ~p, joining cluster~n", [Nodes]),
|
io:format("~nCluster: discovered nodes ~p, joining cluster~n", [Nodes]),
|
||||||
application:set_env(eventhub, extra_db_nodes, Nodes)
|
application:set_env(eventhub, extra_db_nodes, Nodes)
|
||||||
end,
|
end,
|
||||||
application:ensure_all_started(mnesia),
|
application:ensure_all_started(mnesia),
|
||||||
ok = infra_mnesia:init_tables(),
|
ok = infra_mnesia:init_tables(),
|
||||||
ok = infra_mnesia:wait_for_tables(),
|
ok = infra_mnesia:wait_for_tables(),
|
||||||
calendar_html_renderer:init_cache(),
|
ok = migration_engine:ensure_applied(),
|
||||||
application:ensure_all_started(cowboy),
|
calendar_html_renderer:init_cache(),
|
||||||
start_http(), % Пользовательский API (8080)
|
application:ensure_all_started(cowboy),
|
||||||
start_admin_http(), % Административный API (8445)
|
start_http(), % Пользовательский API (8080)
|
||||||
start_swagger_http(), % Swagger UI и спецификация (8447)
|
start_admin_http(), % Административный API (8445)
|
||||||
application:ensure_all_started(prometheus),
|
start_swagger_http(), % Swagger UI и спецификация (8447)
|
||||||
application:ensure_all_started(prometheus_cowboy),
|
application:ensure_all_started(prometheus),
|
||||||
init_default_admins(),
|
application:ensure_all_started(prometheus_cowboy),
|
||||||
{ok, Pid};
|
init_default_admins(),
|
||||||
Error ->
|
{ok, Pid};
|
||||||
Error
|
Error ->
|
||||||
end.
|
Error
|
||||||
|
end.
|
||||||
stop(_State) -> ok.
|
|
||||||
|
stop(_State) -> ok.
|
||||||
%% ===================================================================
|
|
||||||
%% Пользовательский HTTP (порт 8080) — только публичные эндпоинты
|
%% ===================================================================
|
||||||
%% ===================================================================
|
%% Пользовательский HTTP (порт 8080) — только публичные эндпоинты
|
||||||
start_http() ->
|
%% ===================================================================
|
||||||
Port = get_env_int(http_port, 8080),
|
start_http() ->
|
||||||
Dispatch = cowboy_router:compile([
|
Port = get_env_int(http_port, 8080),
|
||||||
{'_', [
|
Dispatch = cowboy_router:compile([
|
||||||
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
{'_', [
|
||||||
{"/health", handler_health, []},
|
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
||||||
{"/v1/register", handler_register, []},
|
{"/health", handler_health, []},
|
||||||
{"/v1/verify", handler_verify, []},
|
{"/v1/register", handler_register, []},
|
||||||
{"/v1/login", handler_login, []},
|
{"/v1/verify", handler_verify, []},
|
||||||
{"/v1/refresh", handler_refresh, []},
|
{"/v1/login", handler_login, []},
|
||||||
{"/v1/user/me", handler_user_me, []},
|
{"/v1/refresh", handler_refresh, []},
|
||||||
{"/v1/user/bookings", handler_user_bookings, []},
|
{"/v1/user/me", handler_user_me, []},
|
||||||
{"/v1/user/reviews", handler_user_reviews, []},
|
{"/v1/user/bookings", handler_user_bookings, []},
|
||||||
{"/v1/search", handler_search, []},
|
{"/v1/user/reviews", handler_user_reviews, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/search", handler_search, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||||
{"/v1/events/:id/bookings", handler_bookings, []},
|
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
||||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
{"/v1/events/:id/bookings", handler_bookings, []},
|
||||||
{"/v1/reviews", handler_reviews, []},
|
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
{"/v1/reviews", handler_reviews, []},
|
||||||
{"/v1/reports", handler_reports, []},
|
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||||
{"/v1/tickets", handler_tickets, []},
|
{"/v1/reports", handler_reports, []},
|
||||||
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
{"/v1/tickets", handler_tickets, []},
|
||||||
{"/v1/subscription", handler_subscription, []}
|
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
||||||
]} %% 23
|
{"/v1/subscription", handler_subscription, []}
|
||||||
]),
|
]} %% 23
|
||||||
Middlewares = [cowboy_router, cowboy_handler],
|
]),
|
||||||
Env = #{dispatch => Dispatch},
|
Middlewares = [cowboy_router, cowboy_handler],
|
||||||
cowboy:start_clear(http, [{port, Port}],
|
Env = #{dispatch => Dispatch},
|
||||||
#{env => Env, middlewares => Middlewares,
|
cowboy:start_clear(http, [{port, Port}],
|
||||||
metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1,
|
#{env => Env, middlewares => Middlewares,
|
||||||
stream_handlers => [cowboy_metrics_h, cowboy_stream_h]
|
metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1,
|
||||||
}),
|
stream_handlers => [cowboy_metrics_h, cowboy_stream_h]
|
||||||
io:format("HTTP server started on port ~p~n", [Port]).
|
}),
|
||||||
|
io:format("HTTP server started on port ~p~n", [Port]).
|
||||||
%% ===================================================================
|
|
||||||
%% Административный HTTP (порт 8445) — все админские эндпоинты
|
%% ===================================================================
|
||||||
%% ===================================================================
|
%% Административный HTTP (порт 8445) — все админские эндпоинты
|
||||||
start_admin_http() ->
|
%% ===================================================================
|
||||||
PortAdmin = get_env_int(admin_http_port, 8445),
|
start_admin_http() ->
|
||||||
Dispatch = cowboy_router:compile([
|
PortAdmin = get_env_int(admin_http_port, 8445),
|
||||||
{'_', [
|
Dispatch = cowboy_router:compile([
|
||||||
% ================== БАЗОВЫЕ ==================
|
{'_', [
|
||||||
{"/admin/health", admin_handler_health, []},
|
% ================== БАЗОВЫЕ ==================
|
||||||
{"/v1/admin/stats", admin_handler_stats, []},
|
{"/admin/health", admin_handler_health, []},
|
||||||
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
|
{"/v1/admin/stats", admin_handler_stats, []},
|
||||||
{"/v1/admin/login", admin_handler_login, []},
|
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
|
||||||
{"/v1/admin/refresh", admin_handler_refresh, []},
|
{"/v1/admin/login", admin_handler_login, []},
|
||||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
{"/v1/admin/refresh", admin_handler_refresh, []},
|
||||||
{"/v1/admin/users", admin_handler_users, []},
|
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||||
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
{"/v1/admin/users", admin_handler_users, []},
|
||||||
{"/v1/admin/users/:id", admin_handler_user_by_id, []},
|
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
||||||
{"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []},
|
{"/v1/admin/users/:id", admin_handler_user_by_id, []},
|
||||||
% ================== КАЛЕНДАРИ ==================
|
{"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []},
|
||||||
{"/v1/admin/calendars", admin_handler_calendars, []},
|
% ================== КАЛЕНДАРИ ==================
|
||||||
{"/v1/admin/calendars/stats", admin_handler_calendar_stats, []},
|
{"/v1/admin/calendars", admin_handler_calendars, []},
|
||||||
{"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []},
|
{"/v1/admin/calendars/stats", admin_handler_calendar_stats, []},
|
||||||
% ================== СОБЫТИЯ ==================
|
{"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []},
|
||||||
{"/v1/admin/events", admin_handler_events, []},
|
% ================== СОБЫТИЯ ==================
|
||||||
{"/v1/admin/events/stats", admin_handler_event_stats, []},
|
{"/v1/admin/events", admin_handler_events, []},
|
||||||
{"/v1/admin/events/:id", admin_handler_event_by_id, []},
|
{"/v1/admin/events/stats", admin_handler_event_stats, []},
|
||||||
% ================== ОТЧЁТЫ ==================
|
{"/v1/admin/events/:id", admin_handler_event_by_id, []},
|
||||||
{"/v1/admin/reports", admin_handler_reports, []},
|
% ================== ОТЧЁТЫ ==================
|
||||||
{"/v1/admin/reports/stats", admin_handler_report_stats, []},
|
{"/v1/admin/reports", admin_handler_reports, []},
|
||||||
{"/v1/admin/reports/:id", admin_handler_report_by_id, []},
|
{"/v1/admin/reports/stats", admin_handler_report_stats, []},
|
||||||
% ================== ОТЗЫВЫ ==================
|
{"/v1/admin/reports/:id", admin_handler_report_by_id, []},
|
||||||
{"/v1/admin/reviews", admin_handler_reviews, []},
|
% ================== ОТЗЫВЫ ==================
|
||||||
{"/v1/admin/reviews/stats", admin_handler_reviews_stats, []},
|
{"/v1/admin/reviews", admin_handler_reviews, []},
|
||||||
{"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []},
|
{"/v1/admin/reviews/stats", admin_handler_reviews_stats, []},
|
||||||
% ================== БАН-СЛОВА ==================
|
{"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []},
|
||||||
{"/v1/admin/banned-words/batch", admin_handler_banned_words, []},
|
% ================== БАН-СЛОВА ==================
|
||||||
{"/v1/admin/banned-words", admin_handler_banned_words, []},
|
{"/v1/admin/banned-words/batch", admin_handler_banned_words, []},
|
||||||
{"/v1/admin/banned-words/:word", admin_handler_banned_words, []},
|
{"/v1/admin/banned-words", admin_handler_banned_words, []},
|
||||||
% ================== ТИКЕТЫ ==================
|
{"/v1/admin/banned-words/:word", admin_handler_banned_words, []},
|
||||||
{"/v1/admin/tickets/stats", admin_handler_ticket_stats, []},
|
% ================== ТИКЕТЫ ==================
|
||||||
{"/v1/admin/tickets/:id", admin_handler_ticket_by_id, []},
|
{"/v1/admin/tickets/stats", admin_handler_ticket_stats, []},
|
||||||
{"/v1/admin/tickets", admin_handler_tickets, []},
|
{"/v1/admin/tickets/:id", admin_handler_ticket_by_id, []},
|
||||||
% ================== ПОДПИСКИ ==================
|
{"/v1/admin/tickets", admin_handler_tickets, []},
|
||||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
% ================== ПОДПИСКИ ==================
|
||||||
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
||||||
% ================== Управление ролями (только для superadmin) ==================
|
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||||
{"/v1/admin/me", admin_handler_me, []},
|
% ================== Управление ролями (только для superadmin) ==================
|
||||||
{"/v1/admin/admins", admin_handler_admins, []},
|
{"/v1/admin/me", admin_handler_me, []},
|
||||||
{"/v1/admin/admins/:id", admin_handler_admins_by_id, []},
|
{"/v1/admin/admins", admin_handler_admins, []},
|
||||||
{"/v1/admin/audit", admin_handler_audit, []},
|
{"/v1/admin/admins/:id", admin_handler_admins_by_id, []},
|
||||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
{"/v1/admin/audit", admin_handler_audit, []},
|
||||||
{"/v1/admin/:target_type/:id", admin_handler_moderation, []}
|
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||||
]}
|
{"/v1/admin/:target_type/:id", admin_handler_moderation, []}
|
||||||
]),
|
]}
|
||||||
|
]),
|
||||||
Middlewares = [cowboy_router, cowboy_handler],
|
|
||||||
Env = #{dispatch => Dispatch},
|
Middlewares = [cowboy_router, cowboy_handler],
|
||||||
cowboy:start_clear(admin_http, [{port, PortAdmin}],
|
Env = #{dispatch => Dispatch},
|
||||||
#{env => Env, middlewares => Middlewares,
|
cowboy:start_clear(admin_http, [{port, PortAdmin}],
|
||||||
metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1,
|
#{env => Env, middlewares => Middlewares,
|
||||||
stream_handlers => [cowboy_metrics_h, cowboy_stream_h]
|
metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1,
|
||||||
}),
|
stream_handlers => [cowboy_metrics_h, cowboy_stream_h]
|
||||||
io:format("Admin HTTP server started on port ~p~n", [PortAdmin]),
|
}),
|
||||||
|
io:format("Admin HTTP server started on port ~p~n", [PortAdmin]),
|
||||||
% WebSocket для пользователей
|
|
||||||
WsDispatch = cowboy_router:compile([{'_', [{"/ws", ws_handler, []}]}]),
|
% WebSocket для пользователей
|
||||||
PortWs = get_env_int(ws_port, 8081),
|
WsDispatch = cowboy_router:compile([{'_', [{"/ws", ws_handler, []}]}]),
|
||||||
cowboy:start_clear(ws, [{port, PortWs}], #{env => #{dispatch => WsDispatch}}),
|
PortWs = get_env_int(ws_port, 8081),
|
||||||
|
cowboy:start_clear(ws, [{port, PortWs}], #{env => #{dispatch => WsDispatch}}),
|
||||||
% WebSocket для админов
|
|
||||||
AdminWsDispatch = cowboy_router:compile([{'_', [
|
% WebSocket для админов
|
||||||
{"/admin/ws/metrics", admin_ws_handler, []},
|
AdminWsDispatch = cowboy_router:compile([{'_', [
|
||||||
{"/admin/ws", admin_ws_handler, []}
|
{"/admin/ws/metrics", admin_ws_handler, []},
|
||||||
]}]),
|
{"/admin/ws", admin_ws_handler, []}
|
||||||
PortAdminWs = get_env_int(admin_ws_port, 8446),
|
]}]),
|
||||||
cowboy:start_clear(admin_ws, [{port, PortAdminWs}], #{env => #{dispatch => AdminWsDispatch}}),
|
PortAdminWs = get_env_int(admin_ws_port, 8446),
|
||||||
|
cowboy:start_clear(admin_ws, [{port, PortAdminWs}], #{env => #{dispatch => AdminWsDispatch}}),
|
||||||
io:format("WebSocket started on ports ~p (user) and ~p (admin)~n", [PortWs, PortAdminWs]).
|
|
||||||
|
io:format("WebSocket started on ports ~p (user) and ~p (admin)~n", [PortWs, PortAdminWs]).
|
||||||
%% ===================================================================
|
|
||||||
%% Swagger HTTP (порт 8447) — документация API
|
%% ===================================================================
|
||||||
%% ===================================================================
|
%% Swagger HTTP (порт 8447) — документация API
|
||||||
start_swagger_http() ->
|
%% ===================================================================
|
||||||
PortSwagger = get_env_int(swagger_http_port, 8447),
|
start_swagger_http() ->
|
||||||
Dispatch = cowboy_router:compile([
|
PortSwagger = get_env_int(swagger_http_port, 8447),
|
||||||
{'_', [
|
Dispatch = cowboy_router:compile([
|
||||||
{"/", swagger_docs_handler, []},
|
{'_', [
|
||||||
{"/[...]", swagger_docs_handler, []}
|
{"/", swagger_docs_handler, []},
|
||||||
]}
|
{"/[...]", swagger_docs_handler, []}
|
||||||
]),
|
]}
|
||||||
Middlewares = [cowboy_router, cowboy_handler],
|
]),
|
||||||
Env = #{dispatch => Dispatch},
|
Middlewares = [cowboy_router, cowboy_handler],
|
||||||
cowboy:start_clear(swagger_http, [{port, PortSwagger}], #{env => Env, middlewares => Middlewares}),
|
Env = #{dispatch => Dispatch},
|
||||||
io:format("Swagger HTTP server started on port ~p~n", [PortSwagger]).
|
cowboy:start_clear(swagger_http, [{port, PortSwagger}], #{env => Env, middlewares => Middlewares}),
|
||||||
|
io:format("Swagger HTTP server started on port ~p~n", [PortSwagger]).
|
||||||
%% ---------- Инициализация администраторов ----------
|
|
||||||
init_default_admins() ->
|
%% ---------- Инициализация администраторов ----------
|
||||||
case core_admin:list_all() of
|
init_default_admins() ->
|
||||||
[] ->
|
case core_admin:list_all() of
|
||||||
% Суперадмин
|
[] ->
|
||||||
SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")),
|
% Суперадмин
|
||||||
SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")),
|
SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")),
|
||||||
{ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin),
|
SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")),
|
||||||
io:format("Default superadmin created: ~s~n", [SuperEmail]),
|
{ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin),
|
||||||
|
io:format("Default superadmin created: ~s~n", [SuperEmail]),
|
||||||
% Админ
|
|
||||||
AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")),
|
% Админ
|
||||||
AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")),
|
AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")),
|
||||||
{ok, _} = core_admin:create(AdminEmail, AdminPass, admin),
|
AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")),
|
||||||
io:format("Default admin created: ~s~n", [AdminEmail]),
|
{ok, _} = core_admin:create(AdminEmail, AdminPass, admin),
|
||||||
|
io:format("Default admin created: ~s~n", [AdminEmail]),
|
||||||
% Модератор
|
|
||||||
ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")),
|
% Модератор
|
||||||
ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")),
|
ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")),
|
||||||
{ok, _} = core_admin:create(ModerEmail, ModerPass, moderator),
|
ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")),
|
||||||
io:format("Default moderator created: ~s~n", [ModerEmail]),
|
{ok, _} = core_admin:create(ModerEmail, ModerPass, moderator),
|
||||||
|
io:format("Default moderator created: ~s~n", [ModerEmail]),
|
||||||
% Поддержка
|
|
||||||
SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")),
|
% Поддержка
|
||||||
SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")),
|
SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")),
|
||||||
{ok, _} = core_admin:create(SupportEmail, SupportPass, support),
|
SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")),
|
||||||
io:format("Default support created: ~s~n", [SupportEmail]);
|
{ok, _} = core_admin:create(SupportEmail, SupportPass, support),
|
||||||
_ ->
|
io:format("Default support created: ~s~n", [SupportEmail]);
|
||||||
io:format("Admins already exist. Skipping creation.~n")
|
_ ->
|
||||||
end.
|
io:format("Admins already exist. Skipping creation.~n")
|
||||||
|
end.
|
||||||
get_env_int(Key, Default) ->
|
|
||||||
case application:get_env(eventhub, Key, Default) of
|
get_env_int(Key, Default) ->
|
||||||
Val when is_list(Val) -> list_to_integer(Val);
|
case application:get_env(eventhub, Key, Default) of
|
||||||
Val when is_integer(Val) -> Val
|
Val when is_list(Val) -> list_to_integer(Val);
|
||||||
|
Val when is_integer(Val) -> Val
|
||||||
end.
|
end.
|
||||||
+270
-272
@@ -1,273 +1,271 @@
|
|||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% EventHub – infra_mnesia (финальная версия с автоочисткой кластера)
|
%% EventHub – infra_mnesia (финальная версия с автоочисткой кластера)
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
-module(infra_mnesia).
|
-module(infra_mnesia).
|
||||||
-behaviour(gen_server).
|
-behaviour(gen_server).
|
||||||
|
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
-export([start_link/0, init_tables/0, wait_for_tables/0, wait_for_table/1]).
|
-export([start_link/0, init_tables/0, wait_for_tables/0, wait_for_table/1]).
|
||||||
-export([add_cluster_nodes/1]).
|
-export([add_cluster_nodes/1]).
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||||
terminate/2, code_change/3]).
|
terminate/2, code_change/3]).
|
||||||
|
|
||||||
-define(TABLES, [
|
-define(TABLES, [
|
||||||
user, session, verification, admin, admin_session, auth_session,
|
user, session, verification, admin, admin_session, auth_session,
|
||||||
calendar, calendar_share, calendar_specialist,
|
calendar, calendar_share, calendar_specialist,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking,
|
||||||
review, report, banned_word,
|
review, report, banned_word,
|
||||||
ticket, subscription,
|
ticket, subscription,
|
||||||
admin_audit, notification,
|
admin_audit, notification,
|
||||||
stats, node_metric, schema_migration
|
stats, node_metric, schema_migration
|
||||||
]).
|
]).
|
||||||
|
|
||||||
-define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]).
|
-define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]).
|
||||||
-define(TABLE_WAIT_TIMEOUT, 5000).
|
-define(TABLE_WAIT_TIMEOUT, 5000).
|
||||||
-define(CLEANUP_INTERVAL, 30000). % 30 секунд
|
-define(CLEANUP_INTERVAL, 30000). % 30 секунд
|
||||||
|
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% API
|
%% API
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
|
|
||||||
start_link() ->
|
start_link() ->
|
||||||
% Счётчики для метрик (сессии, ws-соединения)
|
% Счётчики для метрик (сессии, ws-соединения)
|
||||||
ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
|
ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]),
|
||||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||||
|
|
||||||
init_tables() ->
|
init_tables() ->
|
||||||
gen_server:call(?MODULE, init_tables).
|
gen_server:call(?MODULE, init_tables).
|
||||||
|
|
||||||
wait_for_tables() ->
|
wait_for_tables() ->
|
||||||
gen_server:call(?MODULE, wait_for_tables).
|
gen_server:call(?MODULE, wait_for_tables).
|
||||||
|
|
||||||
add_cluster_nodes(Nodes) ->
|
add_cluster_nodes(Nodes) ->
|
||||||
gen_server:call(?MODULE, {add_nodes, Nodes}).
|
gen_server:call(?MODULE, {add_nodes, Nodes}).
|
||||||
|
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% gen_server callbacks
|
%% gen_server callbacks
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
|
|
||||||
init([]) ->
|
init([]) ->
|
||||||
{ok, #{}}.
|
{ok, #{}}.
|
||||||
|
|
||||||
handle_call(init_tables, _From, State) ->
|
handle_call(init_tables, _From, State) ->
|
||||||
ExtraNodes = application:get_env(eventhub, extra_db_nodes, []),
|
ExtraNodes = application:get_env(eventhub, extra_db_nodes, []),
|
||||||
case ExtraNodes of
|
case ExtraNodes of
|
||||||
[] ->
|
[] ->
|
||||||
ok = maybe_recreate_schema();
|
ok = maybe_recreate_schema();
|
||||||
%% ok = migration_engine:init_migrations_table(),
|
_ ->
|
||||||
%% _ = migration_engine:apply_pending(); //todo выключил - обваливает кластер, нужно разбираться
|
ok = join_cluster(ExtraNodes)
|
||||||
_ ->
|
end,
|
||||||
ok = join_cluster(ExtraNodes)
|
lists:foreach(fun create_table/1, ?TABLES),
|
||||||
end,
|
% Принудительное создание node_metric на каждом узле
|
||||||
lists:foreach(fun create_table/1, ?TABLES),
|
case mnesia:create_table(node_metric, [
|
||||||
% Принудительное создание node_metric на каждом узле
|
{disc_copies, [node()]}, % хранить на диске
|
||||||
case mnesia:create_table(node_metric, [
|
{local_content, true}, % не реплицировать
|
||||||
{disc_copies, [node()]}, % хранить на диске
|
{attributes, record_info(fields, node_metric)}
|
||||||
{local_content, true}, % не реплицировать
|
]) of
|
||||||
{attributes, record_info(fields, node_metric)}
|
{atomic, ok} -> ok;
|
||||||
]) of
|
{aborted, {already_exists, node_metric}} -> ok;
|
||||||
{atomic, ok} -> ok;
|
_ -> ok
|
||||||
{aborted, {already_exists, node_metric}} -> ok;
|
end,
|
||||||
_ -> ok
|
% ГАРАНТИРУЕМ, что узел имеет локальную копию node_metric (критично для присоединяющихся узлов)
|
||||||
end,
|
case lists:member(node(), mnesia:table_info(node_metric, disc_copies) ++
|
||||||
% ГАРАНТИРУЕМ, что узел имеет локальную копию node_metric (критично для присоединяющихся узлов)
|
mnesia:table_info(node_metric, ram_copies)) of
|
||||||
case lists:member(node(), mnesia:table_info(node_metric, disc_copies) ++
|
false -> mnesia:add_table_copy(node_metric, node(), disc_copies);
|
||||||
mnesia:table_info(node_metric, ram_copies)) of
|
true -> ok
|
||||||
false -> mnesia:add_table_copy(node_metric, node(), disc_copies);
|
end,
|
||||||
true -> ok
|
ok = create_indices(),
|
||||||
end,
|
ok = stats_collector:subscribe(),
|
||||||
ok = create_indices(),
|
ok = start_cleanup_timer(),
|
||||||
ok = stats_collector:subscribe(),
|
{reply, ok, State};
|
||||||
ok = start_cleanup_timer(),
|
|
||||||
{reply, ok, State};
|
handle_call({add_nodes, Nodes}, _From, State) ->
|
||||||
|
ok = do_add_nodes(Nodes),
|
||||||
handle_call({add_nodes, Nodes}, _From, State) ->
|
{reply, ok, State};
|
||||||
ok = do_add_nodes(Nodes),
|
|
||||||
{reply, ok, State};
|
handle_call(wait_for_tables, _From, State) ->
|
||||||
|
mnesia:wait_for_tables(?TABLES, ?TABLE_WAIT_TIMEOUT),
|
||||||
handle_call(wait_for_tables, _From, State) ->
|
{reply, ok, State}.
|
||||||
mnesia:wait_for_tables(?TABLES, ?TABLE_WAIT_TIMEOUT),
|
|
||||||
{reply, ok, State}.
|
handle_cast(_Msg, State) -> {noreply, State}.
|
||||||
|
handle_info({cleanup}, State) ->
|
||||||
handle_cast(_Msg, State) -> {noreply, State}.
|
prune_dead_nodes(),
|
||||||
handle_info({cleanup}, State) ->
|
erlang:send_after(?CLEANUP_INTERVAL, self(), {cleanup}),
|
||||||
prune_dead_nodes(),
|
{noreply, State};
|
||||||
erlang:send_after(?CLEANUP_INTERVAL, self(), {cleanup}),
|
handle_info(_Info, State) -> {noreply, State}.
|
||||||
{noreply, State};
|
|
||||||
handle_info(_Info, State) -> {noreply, State}.
|
terminate(_Reason, _State) -> ok.
|
||||||
|
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
||||||
terminate(_Reason, _State) -> ok.
|
|
||||||
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
%% ===================================================================
|
||||||
|
%% Управление схемой
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Управление схемой
|
|
||||||
%% ===================================================================
|
maybe_recreate_schema() ->
|
||||||
|
MnesiaDir = mnesia:system_info(directory),
|
||||||
maybe_recreate_schema() ->
|
case filelib:is_dir(MnesiaDir) of
|
||||||
MnesiaDir = mnesia:system_info(directory),
|
false ->
|
||||||
case filelib:is_dir(MnesiaDir) of
|
io:format("Mnesia directory (~s) not found. Creating fresh schema...~n", [MnesiaDir]),
|
||||||
false ->
|
mnesia:stop(),
|
||||||
io:format("Mnesia directory (~s) not found. Creating fresh schema...~n", [MnesiaDir]),
|
mnesia:delete_schema([node()]),
|
||||||
mnesia:stop(),
|
mnesia:create_schema([node()]),
|
||||||
mnesia:delete_schema([node()]),
|
mnesia:start(),
|
||||||
mnesia:create_schema([node()]),
|
ok;
|
||||||
mnesia:start(),
|
true ->
|
||||||
ok;
|
io:format("Mnesia directory exists (~s). Reusing existing schema.~n", [MnesiaDir]),
|
||||||
true ->
|
case mnesia:system_info(is_running) of
|
||||||
io:format("Mnesia directory exists (~s). Reusing existing schema.~n", [MnesiaDir]),
|
yes -> ok;
|
||||||
case mnesia:system_info(is_running) of
|
_ -> mnesia:start()
|
||||||
yes -> ok;
|
end
|
||||||
_ -> mnesia:start()
|
end.
|
||||||
end
|
|
||||||
end.
|
join_cluster(Nodes) ->
|
||||||
|
case mnesia:system_info(is_running) of
|
||||||
join_cluster(Nodes) ->
|
yes -> mnesia:stop();
|
||||||
case mnesia:system_info(is_running) of
|
no -> ok
|
||||||
yes -> mnesia:stop();
|
end,
|
||||||
no -> ok
|
application:set_env(mnesia, extra_db_nodes, Nodes),
|
||||||
end,
|
mnesia:start(),
|
||||||
application:set_env(mnesia, extra_db_nodes, Nodes),
|
ensure_schema_disc(),
|
||||||
mnesia:start(),
|
wait_for_tables_available(),
|
||||||
ensure_schema_disc(),
|
lists:foreach(fun add_local_disc_copy/1, ?DISC_TABLES).
|
||||||
wait_for_tables_available(),
|
|
||||||
lists:foreach(fun add_local_disc_copy/1, ?DISC_TABLES).
|
do_add_nodes(Nodes) ->
|
||||||
|
ExtraNodes = application:get_env(eventhub, extra_db_nodes, []),
|
||||||
do_add_nodes(Nodes) ->
|
application:set_env(eventhub, extra_db_nodes, Nodes ++ ExtraNodes),
|
||||||
ExtraNodes = application:get_env(eventhub, extra_db_nodes, []),
|
{ok, _} = mnesia:change_config(extra_db_nodes, Nodes),
|
||||||
application:set_env(eventhub, extra_db_nodes, Nodes ++ ExtraNodes),
|
ensure_schema_disc(),
|
||||||
{ok, _} = mnesia:change_config(extra_db_nodes, Nodes),
|
wait_for_tables_available(),
|
||||||
ensure_schema_disc(),
|
lists:foreach(fun add_local_disc_copy/1, ?DISC_TABLES).
|
||||||
wait_for_tables_available(),
|
|
||||||
lists:foreach(fun add_local_disc_copy/1, ?DISC_TABLES).
|
ensure_schema_disc() ->
|
||||||
|
case lists:member(node(), mnesia:table_info(schema, disc_copies)) of
|
||||||
ensure_schema_disc() ->
|
false ->
|
||||||
case lists:member(node(), mnesia:table_info(schema, disc_copies)) of
|
io:format("Changing schema copy to disc...~n"),
|
||||||
false ->
|
case mnesia:change_table_copy_type(schema, node(), disc_copies) of
|
||||||
io:format("Changing schema copy to disc...~n"),
|
{atomic, ok} -> ok;
|
||||||
case mnesia:change_table_copy_type(schema, node(), disc_copies) of
|
{aborted, {already_exists, _, _}} -> ok;
|
||||||
{atomic, ok} -> ok;
|
{aborted, Reason} -> error({failed_schema_disc, Reason})
|
||||||
{aborted, {already_exists, _, _}} -> ok;
|
end;
|
||||||
{aborted, Reason} -> error({failed_schema_disc, Reason})
|
true -> ok
|
||||||
end;
|
end.
|
||||||
true -> ok
|
|
||||||
end.
|
add_local_disc_copy(Tab) ->
|
||||||
|
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
||||||
add_local_disc_copy(Tab) ->
|
false ->
|
||||||
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
io:format("Adding local disc copy of table ~p...~n", [Tab]),
|
||||||
false ->
|
case mnesia:add_table_copy(Tab, node(), disc_copies) of
|
||||||
io:format("Adding local disc copy of table ~p...~n", [Tab]),
|
{atomic, ok} -> ok;
|
||||||
case mnesia:add_table_copy(Tab, node(), disc_copies) of
|
{aborted, {already_exists, _}} -> ok;
|
||||||
{atomic, ok} -> ok;
|
{aborted, Reason} ->
|
||||||
{aborted, {already_exists, _}} -> ok;
|
io:format("Could not add disc copy for ~p: ~p~n", [Tab, Reason])
|
||||||
{aborted, Reason} ->
|
end;
|
||||||
io:format("Could not add disc copy for ~p: ~p~n", [Tab, Reason])
|
true -> ok
|
||||||
end;
|
end.
|
||||||
true -> ok
|
|
||||||
end.
|
wait_for_tables_available() ->
|
||||||
|
lists:foreach(fun(Tab) -> wait_for_table(Tab) end, ?DISC_TABLES).
|
||||||
wait_for_tables_available() ->
|
|
||||||
lists:foreach(fun(Tab) -> wait_for_table(Tab) end, ?DISC_TABLES).
|
wait_for_table(Tab) ->
|
||||||
|
case lists:member(Tab, mnesia:system_info(tables)) of
|
||||||
wait_for_table(Tab) ->
|
true -> ok;
|
||||||
case lists:member(Tab, mnesia:system_info(tables)) of
|
false ->
|
||||||
true -> ok;
|
timer:sleep(100),
|
||||||
false ->
|
wait_for_table(Tab)
|
||||||
timer:sleep(100),
|
end.
|
||||||
wait_for_table(Tab)
|
|
||||||
end.
|
%% ===================================================================
|
||||||
|
%% Автоматическая очистка мёртвых узлов
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Автоматическая очистка мёртвых узлов
|
|
||||||
%% ===================================================================
|
start_cleanup_timer() ->
|
||||||
|
erlang:send_after(?CLEANUP_INTERVAL, self(), {cleanup}),
|
||||||
start_cleanup_timer() ->
|
ok.
|
||||||
erlang:send_after(?CLEANUP_INTERVAL, self(), {cleanup}),
|
|
||||||
ok.
|
prune_dead_nodes() ->
|
||||||
|
AliveNodes = lists:filter(fun(Node) ->
|
||||||
prune_dead_nodes() ->
|
Node =/= node() andalso net_adm:ping(Node) =:= pong
|
||||||
AliveNodes = lists:filter(fun(Node) ->
|
end, mnesia:system_info(db_nodes)),
|
||||||
Node =/= node() andalso net_adm:ping(Node) =:= pong
|
DeadNodes = mnesia:system_info(db_nodes) -- [node() | AliveNodes],
|
||||||
end, mnesia:system_info(db_nodes)),
|
lists:foreach(fun(Node) ->
|
||||||
DeadNodes = mnesia:system_info(db_nodes) -- [node() | AliveNodes],
|
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
||||||
lists:foreach(fun(Node) ->
|
lists:foreach(fun(Tab) ->
|
||||||
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of
|
||||||
lists:foreach(fun(Tab) ->
|
true -> catch mnesia:del_table_copy(Tab, Node);
|
||||||
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of
|
false -> ok
|
||||||
true -> catch mnesia:del_table_copy(Tab, Node);
|
end
|
||||||
false -> ok
|
end, ?DISC_TABLES),
|
||||||
end
|
catch mnesia:del_table_copy(schema, Node)
|
||||||
end, ?DISC_TABLES),
|
end, DeadNodes).
|
||||||
catch mnesia:del_table_copy(schema, Node)
|
|
||||||
end, DeadNodes).
|
%% ===================================================================
|
||||||
|
%% Создание / открытие таблиц
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Создание / открытие таблиц
|
|
||||||
%% ===================================================================
|
create_table(Table) ->
|
||||||
|
Opts = table_opts(Table),
|
||||||
create_table(Table) ->
|
case mnesia:create_table(Table, Opts) of
|
||||||
Opts = table_opts(Table),
|
{atomic, ok} -> ok;
|
||||||
case mnesia:create_table(Table, Opts) of
|
{aborted, {already_exists, _}} -> ok;
|
||||||
{atomic, ok} -> ok;
|
{aborted, Reason} ->
|
||||||
{aborted, {already_exists, _}} -> ok;
|
error({table_creation_failed, Table, Reason})
|
||||||
{aborted, Reason} ->
|
end.
|
||||||
error({table_creation_failed, Table, Reason})
|
|
||||||
end.
|
%% ===================================================================
|
||||||
|
%% Опции хранения таблиц
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Опции хранения таблиц
|
|
||||||
%% ===================================================================
|
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
||||||
|
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||||
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
||||||
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||||
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
||||||
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
|
||||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields, ticket)}];
|
||||||
table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
|
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
||||||
table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields, ticket)}];
|
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||||
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||||
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
table_opts(stats) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats)}];
|
||||||
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||||
table_opts(stats) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats)}];
|
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
||||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
table_opts(auth_session) -> [{disc_copies, [node()]}, {attributes, record_info(fields, auth_session)}];
|
||||||
table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}].
|
||||||
table_opts(auth_session) -> [{disc_copies, [node()]}, {attributes, record_info(fields, auth_session)}];
|
|
||||||
table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}].
|
%% ===================================================================
|
||||||
|
%% Индексы
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Индексы
|
|
||||||
%% ===================================================================
|
create_indices() ->
|
||||||
|
mnesia:add_table_index(event, calendar_id),
|
||||||
create_indices() ->
|
mnesia:add_table_index(event, title),
|
||||||
mnesia:add_table_index(event, calendar_id),
|
mnesia:add_table_index(event, created_at),
|
||||||
mnesia:add_table_index(event, title),
|
mnesia:add_table_index(event, start_time),
|
||||||
mnesia:add_table_index(event, created_at),
|
mnesia:add_table_index(event, event_type),
|
||||||
mnesia:add_table_index(event, start_time),
|
mnesia:add_table_index(event, master_id),
|
||||||
mnesia:add_table_index(event, event_type),
|
mnesia:add_table_index(event, specialist_id),
|
||||||
mnesia:add_table_index(event, master_id),
|
mnesia:add_table_index(event, status),
|
||||||
mnesia:add_table_index(event, specialist_id),
|
mnesia:add_table_index(booking, event_id),
|
||||||
mnesia:add_table_index(event, status),
|
mnesia:add_table_index(booking, user_id),
|
||||||
mnesia:add_table_index(booking, event_id),
|
mnesia:add_table_index(booking, status),
|
||||||
mnesia:add_table_index(booking, user_id),
|
mnesia:add_table_index(calendar, owner_id),
|
||||||
mnesia:add_table_index(booking, status),
|
mnesia:add_table_index(calendar, status),
|
||||||
mnesia:add_table_index(calendar, owner_id),
|
mnesia:add_table_index(calendar, short_name),
|
||||||
mnesia:add_table_index(calendar, status),
|
mnesia:add_table_index(calendar, category),
|
||||||
mnesia:add_table_index(calendar, short_name),
|
mnesia:add_table_index(calendar_specialist, calendar_id),
|
||||||
mnesia:add_table_index(calendar, category),
|
mnesia:add_table_index(calendar_specialist, user_id),
|
||||||
mnesia:add_table_index(calendar_specialist, calendar_id),
|
mnesia:add_table_index(user, nickname),
|
||||||
mnesia:add_table_index(calendar_specialist, user_id),
|
mnesia:add_table_index(user, email),
|
||||||
mnesia:add_table_index(user, nickname),
|
mnesia:add_table_index(notification, user_id),
|
||||||
mnesia:add_table_index(user, email),
|
mnesia:add_table_index(notification, is_read),
|
||||||
mnesia:add_table_index(notification, user_id),
|
mnesia:add_table_index(auth_session, family_id),
|
||||||
mnesia:add_table_index(notification, is_read),
|
mnesia:add_table_index(auth_session, subject_id),
|
||||||
mnesia:add_table_index(auth_session, family_id),
|
|
||||||
mnesia:add_table_index(auth_session, subject_id),
|
|
||||||
ok.
|
ok.
|
||||||
+198
-121
@@ -1,121 +1,198 @@
|
|||||||
-module(migration_engine).
|
-module(migration_engine).
|
||||||
-behaviour(gen_server).
|
-behaviour(gen_server).
|
||||||
|
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([start_link/0, init_migrations_table/0, apply_pending/0,
|
-export([start_link/0, ensure_applied/0, apply_pending/0,
|
||||||
rollback/1, status/0]).
|
rollback/1, status/0]).
|
||||||
|
|
||||||
%% gen_server callbacks
|
%% gen_server callbacks
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||||
terminate/2, code_change/3]).
|
terminate/2, code_change/3]).
|
||||||
|
|
||||||
-define(TABLE, schema_migration).
|
-define(TABLE, schema_migration).
|
||||||
|
-define(LOCK_VERSION, "__migration_lock__").
|
||||||
%% ------------------------------
|
-define(LOCK_STALE_SECONDS, 300).
|
||||||
%% API
|
-define(WAIT_TIMEOUT_MS, 120000).
|
||||||
%% ------------------------------
|
-define(WAIT_INTERVAL_MS, 200).
|
||||||
|
|
||||||
start_link() ->
|
%% Упорядоченный реестр миграций (добавлять новые модули в конец списка).
|
||||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
-define(ALL_MIGRATIONS, [
|
||||||
|
'20260501120000_base_schema',
|
||||||
init_migrations_table() ->
|
'20260504150000_test_migration'
|
||||||
gen_server:call(?MODULE, init_table).
|
]).
|
||||||
|
|
||||||
apply_pending() ->
|
%% ------------------------------
|
||||||
gen_server:call(?MODULE, apply_pending).
|
%% API
|
||||||
|
%% ------------------------------
|
||||||
rollback(Version) ->
|
|
||||||
gen_server:call(?MODULE, {rollback, Version}).
|
start_link() ->
|
||||||
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||||
status() ->
|
|
||||||
gen_server:call(?MODULE, status).
|
%% @doc Безопасно применить pending-миграции при старте (с global lock в Mnesia).
|
||||||
|
-spec ensure_applied() -> ok | {error, term()}.
|
||||||
%% ------------------------------
|
ensure_applied() ->
|
||||||
%% gen_server callbacks
|
gen_server:call(?MODULE, ensure_applied, infinity).
|
||||||
%% ------------------------------
|
|
||||||
|
apply_pending() ->
|
||||||
init([]) ->
|
gen_server:call(?MODULE, apply_pending).
|
||||||
{ok, #{}}.
|
|
||||||
|
rollback(Version) ->
|
||||||
handle_call(init_table, _From, State) ->
|
gen_server:call(?MODULE, {rollback, Version}).
|
||||||
case lists:member(?TABLE, mnesia:system_info(tables)) of
|
|
||||||
true -> ok;
|
status() ->
|
||||||
false ->
|
gen_server:call(?MODULE, status).
|
||||||
mnesia:create_table(?TABLE, [
|
|
||||||
{disc_copies, [node()]},
|
%% ------------------------------
|
||||||
{attributes, record_info(fields, schema_migration)},
|
%% gen_server callbacks
|
||||||
{type, set}
|
%% ------------------------------
|
||||||
])
|
|
||||||
end,
|
init([]) ->
|
||||||
infra_mnesia:wait_for_table(?TABLE),
|
{ok, #{}}.
|
||||||
{reply, ok, State};
|
|
||||||
|
handle_call(ensure_applied, _From, State) ->
|
||||||
handle_call(apply_pending, _From, State) ->
|
Result = do_ensure_applied(),
|
||||||
Result = do_apply_pending(),
|
{reply, Result, State};
|
||||||
{reply, Result, State};
|
|
||||||
|
handle_call(apply_pending, _From, State) ->
|
||||||
handle_call({rollback, Version}, _From, State) ->
|
Result = do_apply_pending(),
|
||||||
Result = do_rollback(Version),
|
{reply, Result, State};
|
||||||
{reply, Result, State};
|
|
||||||
|
handle_call({rollback, Version}, _From, State) ->
|
||||||
handle_call(status, _From, State) ->
|
Result = do_rollback(Version),
|
||||||
Applied = applied_versions(),
|
{reply, Result, State};
|
||||||
Pending = pending_versions() -- Applied,
|
|
||||||
{reply, #{applied => lists:map(fun atom_to_list/1, Applied),
|
handle_call(status, _From, State) ->
|
||||||
pending => lists:map(fun atom_to_list/1, Pending)}, State}.
|
Applied = applied_versions(),
|
||||||
|
Pending = pending_versions() -- Applied,
|
||||||
handle_cast(_Msg, State) -> {noreply, State}.
|
{reply, #{applied => [atom_to_list(V) || V <- Applied],
|
||||||
handle_info(_Msg, State) -> {noreply, State}.
|
pending => [atom_to_list(V) || V <- Pending]}, State}.
|
||||||
terminate(_Reason, _State) -> ok.
|
|
||||||
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
handle_cast(_Msg, State) -> {noreply, State}.
|
||||||
|
handle_info(_Msg, State) -> {noreply, State}.
|
||||||
%% ------------------------------
|
terminate(_Reason, _State) -> ok.
|
||||||
%% Внутренняя логика
|
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
||||||
%% ------------------------------
|
|
||||||
|
%% ------------------------------
|
||||||
do_apply_pending() ->
|
%% Координация при старте
|
||||||
Pending = pending_versions() -- applied_versions(),
|
%% ------------------------------
|
||||||
lists:foreach(fun(Module) -> code:ensure_loaded(Module) end, Pending),
|
|
||||||
lists:foldl(fun(Version, Acc) ->
|
do_ensure_applied() ->
|
||||||
try Version:up() of
|
case all_applied() of
|
||||||
_ -> mark_applied(Version), Acc
|
true -> ok;
|
||||||
catch _:Reason ->
|
false ->
|
||||||
[{error, atom_to_list(Version), Reason} | Acc]
|
case acquire_lock() of
|
||||||
end
|
ok ->
|
||||||
end, [], Pending).
|
try
|
||||||
|
case do_apply_pending() of
|
||||||
do_rollback(TargetStr) ->
|
ok -> ok;
|
||||||
Target = list_to_atom(TargetStr),
|
{error, Reason} -> {error, Reason}
|
||||||
Applied = applied_versions(),
|
end
|
||||||
ToRollback = lists:sort(fun(A,B) -> A > B end,
|
after
|
||||||
[V || V <- Applied, V > Target]),
|
release_lock()
|
||||||
lists:foreach(fun(Version) ->
|
end;
|
||||||
code:ensure_loaded(Version),
|
{error, locked} ->
|
||||||
try Version:down() of
|
wait_until_applied(?WAIT_TIMEOUT_MS)
|
||||||
_ -> unmark_applied(Version)
|
end
|
||||||
catch _:Reason ->
|
end.
|
||||||
throw({rollback_failed, atom_to_list(Version), Reason})
|
|
||||||
end
|
all_applied() ->
|
||||||
end, ToRollback).
|
pending_versions() -- applied_versions() =:= [].
|
||||||
|
|
||||||
applied_versions() ->
|
wait_until_applied(TimeoutMs) when TimeoutMs =< 0 ->
|
||||||
[list_to_atom(V) || #schema_migration{version = V} <-
|
{error, migration_timeout};
|
||||||
mnesia:dirty_match_object(#schema_migration{_ = '_'})].
|
wait_until_applied(TimeoutMs) ->
|
||||||
|
case all_applied() of
|
||||||
pending_versions() ->
|
true -> ok;
|
||||||
AllMods = code:all_available(),
|
false ->
|
||||||
[list_to_atom(Module) || Module <- extract_module_names(AllMods), lists:prefix("20", Module)].
|
timer:sleep(?WAIT_INTERVAL_MS),
|
||||||
|
wait_until_applied(TimeoutMs - ?WAIT_INTERVAL_MS)
|
||||||
extract_module_names(ModInfoList) ->
|
end.
|
||||||
[Name || {Name, _, _} <- ModInfoList].
|
|
||||||
|
acquire_lock() ->
|
||||||
mark_applied(Version) ->
|
Now = calendar:universal_time(),
|
||||||
mnesia:dirty_write(#schema_migration{
|
case mnesia:sync_transaction(fun() -> try_acquire_lock(Now) end) of
|
||||||
version = atom_to_list(Version),
|
{atomic, ok} -> ok;
|
||||||
applied_at = calendar:local_time()
|
{aborted, locked} -> {error, locked};
|
||||||
}).
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
unmark_applied(Version) ->
|
|
||||||
mnesia:dirty_delete({?TABLE, atom_to_list(Version)}).
|
try_acquire_lock(Now) ->
|
||||||
|
case mnesia:read(?TABLE, ?LOCK_VERSION, read) of
|
||||||
|
[] ->
|
||||||
|
mnesia:write(#schema_migration{version = ?LOCK_VERSION, applied_at = Now}),
|
||||||
|
ok;
|
||||||
|
[#schema_migration{applied_at = At}] ->
|
||||||
|
case lock_stale(At, Now) of
|
||||||
|
true ->
|
||||||
|
mnesia:write(#schema_migration{version = ?LOCK_VERSION, applied_at = Now}),
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
mnesia:abort(locked)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
lock_stale(At, Now) ->
|
||||||
|
SecAt = calendar:datetime_to_gregorian_seconds(At),
|
||||||
|
SecNow = calendar:datetime_to_gregorian_seconds(Now),
|
||||||
|
SecNow - SecAt > ?LOCK_STALE_SECONDS.
|
||||||
|
|
||||||
|
release_lock() ->
|
||||||
|
mnesia:sync_transaction(fun() ->
|
||||||
|
mnesia:delete({?TABLE, ?LOCK_VERSION})
|
||||||
|
end),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%% ------------------------------
|
||||||
|
%% Применение / откат
|
||||||
|
%% ------------------------------
|
||||||
|
|
||||||
|
do_apply_pending() ->
|
||||||
|
Pending = pending_versions() -- applied_versions(),
|
||||||
|
lists:foreach(fun(Module) -> code:ensure_loaded(Module) end, Pending),
|
||||||
|
lists:foldl(fun(Version, ok) ->
|
||||||
|
try Version:up() of
|
||||||
|
_ ->
|
||||||
|
mark_applied(Version),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
_:Reason ->
|
||||||
|
{error, {migration_failed, atom_to_list(Version), Reason}}
|
||||||
|
end;
|
||||||
|
(_, Acc) -> Acc
|
||||||
|
end, ok, Pending).
|
||||||
|
|
||||||
|
do_rollback(TargetStr) ->
|
||||||
|
Target = list_to_atom(TargetStr),
|
||||||
|
Applied = applied_versions(),
|
||||||
|
ToRollback = lists:sort(fun(A, B) -> A > B end,
|
||||||
|
[V || V <- Applied, V > Target]),
|
||||||
|
lists:foreach(fun(Version) ->
|
||||||
|
code:ensure_loaded(Version),
|
||||||
|
try Version:down() of
|
||||||
|
_ -> unmark_applied(Version)
|
||||||
|
catch _:Reason ->
|
||||||
|
throw({rollback_failed, atom_to_list(Version), Reason})
|
||||||
|
end
|
||||||
|
end, ToRollback).
|
||||||
|
|
||||||
|
applied_versions() ->
|
||||||
|
[list_to_atom(V) || #schema_migration{version = V} <-
|
||||||
|
mnesia:dirty_match_object(#schema_migration{_ = '_'}),
|
||||||
|
not is_lock_version(V)].
|
||||||
|
|
||||||
|
pending_versions() ->
|
||||||
|
?ALL_MIGRATIONS.
|
||||||
|
|
||||||
|
is_lock_version(?LOCK_VERSION) -> true;
|
||||||
|
is_lock_version(_) -> false.
|
||||||
|
|
||||||
|
mark_applied(Version) ->
|
||||||
|
mnesia:dirty_write(#schema_migration{
|
||||||
|
version = atom_to_list(Version),
|
||||||
|
applied_at = calendar:universal_time()
|
||||||
|
}).
|
||||||
|
|
||||||
|
unmark_applied(Version) ->
|
||||||
|
mnesia:dirty_delete({?TABLE, atom_to_list(Version)}).
|
||||||
|
|||||||
+43
-23
@@ -1,23 +1,43 @@
|
|||||||
# Миграции схемы данных EventHub
|
# Миграции схемы данных EventHub
|
||||||
|
|
||||||
## Применение миграций
|
## Когда применяются
|
||||||
При старте приложения автоматически выполняются все неприменённые миграции.
|
|
||||||
Для ручного запуска можно вызвать:
|
После `infra_mnesia:init_tables()` и `wait_for_tables()` приложение вызывает
|
||||||
migration_engine:apply_pending().
|
`migration_engine:ensure_applied/0`:
|
||||||
|
|
||||||
## Создание новой миграции
|
1. Узел, захвативший **глобальную блокировку** в Mnesia (`schema_migration`, ключ `__migration_lock__`), применяет все pending-миграции.
|
||||||
1. Создайте файл в `priv/migrations/` с именем вида `YYYYMMDDHHMMSS_описание.erl`.
|
2. Остальные узлы (join кластера) **ждут**, пока pending-список станет пустым, и не стартуют HTTP до синхронизации.
|
||||||
2. Реализуйте поведение `db_migration` (функции `up/0` и `down/0`).
|
|
||||||
3. При очередном запуске приложения миграция будет применена.
|
Базовые таблицы создаёт `infra_mnesia` при старте. Миграции — только для **инкрементальных** изменений (индексы, трансформация данных, новые поля).
|
||||||
|
|
||||||
## Откат миграций
|
## Создание новой миграции
|
||||||
migration_engine:rollback("20260501120000_base_schema").
|
|
||||||
|
1. Создайте файл в `src/migrations/` с именем `YYYYMMDDHHMMSS_описание.erl`.
|
||||||
## Плавающее обновление (rolling update)
|
2. Реализуйте `up/0` и `down/0`.
|
||||||
1. Переведите узел в режим обслуживания.
|
3. Добавьте модуль в список `?ALL_MIGRATIONS` в `src/infra/migration_engine.erl` (в конец, по возрастанию версии).
|
||||||
2. Выполните бэкап: `mnesia:backup("backup_node.bak")`.
|
4. При следующем старте миграция применится автоматически (на узле с lock).
|
||||||
3. Обновите код приложения (git pull / rsync).
|
|
||||||
4. Перезапустите узел – миграции применятся автоматически.
|
## Ручной запуск
|
||||||
5. Убедитесь в согласованности данных.
|
|
||||||
6. Верните узел в работу.
|
```erlang
|
||||||
7. Повторите для остальных узлов.
|
migration_engine:apply_pending().
|
||||||
|
migration_engine:status().
|
||||||
|
migration_engine:rollback("20260501120000_base_schema").
|
||||||
|
```
|
||||||
|
|
||||||
|
## Плавающее обновление (rolling update)
|
||||||
|
|
||||||
|
1. Переведите узел в режим обслуживания.
|
||||||
|
2. Выполните бэкап: `mnesia:backup("backup_node.bak")`.
|
||||||
|
3. Обновите код приложения (git pull / rsync).
|
||||||
|
4. Перезапустите узел — `ensure_applied/0` применит только новые миграции (один узел с lock).
|
||||||
|
5. Убедитесь: `migration_engine:status()` — `pending => []` на всех узлах.
|
||||||
|
6. Верните узел в работу.
|
||||||
|
7. Повторите для остальных узлов.
|
||||||
|
|
||||||
|
## Восстановление после сбоя
|
||||||
|
|
||||||
|
Если узел упал во время миграции, lock в `schema_migration` может остаться.
|
||||||
|
Он считается устаревшим через **5 минут** (`?LOCK_STALE_SECONDS`) — следующий стартующий узел перехватит lock и продолжит.
|
||||||
|
|
||||||
|
При ошибке в `up/0` приложение не стартует (`{error, {migration_failed, ...}}`). Откатите код или исправьте миграцию, при необходимости восстановите из `mnesia:backup/1`.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-module(migration_engine_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-define(LOCK_VERSION, "__migration_lock__").
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
mnesia:stop(),
|
||||||
|
mnesia:delete_schema([node()]),
|
||||||
|
mnesia:create_schema([node()]),
|
||||||
|
mnesia:start(),
|
||||||
|
mnesia:create_table(schema_migration, [
|
||||||
|
{disc_copies, [node()]},
|
||||||
|
{attributes, record_info(fields, schema_migration)},
|
||||||
|
{type, set}
|
||||||
|
]),
|
||||||
|
{ok, _} = migration_engine:start_link(),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
cleanup(_) ->
|
||||||
|
catch gen_server:stop(migration_engine),
|
||||||
|
catch mnesia:delete_table(schema_migration),
|
||||||
|
mnesia:stop(),
|
||||||
|
mnesia:delete_schema([node()]),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
migration_engine_test_() ->
|
||||||
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
|
{"ensure_applied applies pending migrations", fun test_ensure_applied/0},
|
||||||
|
{"ensure_applied is idempotent", fun test_ensure_applied_idempotent/0},
|
||||||
|
{"lock record is not treated as migration", fun test_lock_not_applied/0},
|
||||||
|
{"join node waits until migrations applied", fun test_join_wait/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
test_ensure_applied() ->
|
||||||
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
||||||
|
Status = migration_engine:status(),
|
||||||
|
?assertEqual([], maps:get(pending, Status)),
|
||||||
|
Applied = maps:get(applied, Status),
|
||||||
|
?assert(lists:member("20260501120000_base_schema", Applied)),
|
||||||
|
?assert(lists:member("20260504150000_test_migration", Applied)).
|
||||||
|
|
||||||
|
test_ensure_applied_idempotent() ->
|
||||||
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
||||||
|
?assertEqual(ok, migration_engine:ensure_applied()).
|
||||||
|
|
||||||
|
test_lock_not_applied() ->
|
||||||
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
||||||
|
Status = migration_engine:status(),
|
||||||
|
?assertNot(lists:member(?LOCK_VERSION, maps:get(applied, Status))).
|
||||||
|
|
||||||
|
test_join_wait() ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
mnesia:dirty_write(#schema_migration{version = ?LOCK_VERSION, applied_at = Now}),
|
||||||
|
spawn(fun() ->
|
||||||
|
timer:sleep(100),
|
||||||
|
mnesia:dirty_write(#schema_migration{
|
||||||
|
version = "20260501120000_base_schema", applied_at = Now}),
|
||||||
|
mnesia:dirty_write(#schema_migration{
|
||||||
|
version = "20260504150000_test_migration", applied_at = Now}),
|
||||||
|
mnesia:dirty_delete({schema_migration, ?LOCK_VERSION})
|
||||||
|
end),
|
||||||
|
?assertEqual(ok, migration_engine:ensure_applied()).
|
||||||
Reference in New Issue
Block a user