Mnesia migrations with global lock on startup. Refs EventHub/EventHubBack#24

This commit is contained in:
2026-07-07 17:54:29 +03:00
parent 6e1157134c
commit 7c5f2d26b8
5 changed files with 806 additions and 647 deletions
+232 -231
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+63
View File
@@ -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()).