Mnesia migrations with global lock on startup. Refs EventHub/EventHubBack#24
This commit is contained in:
@@ -40,6 +40,7 @@ start(_StartType, _StartArgs) ->
|
|||||||
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(),
|
||||||
|
ok = migration_engine:ensure_applied(),
|
||||||
calendar_html_renderer:init_cache(),
|
calendar_html_renderer:init_cache(),
|
||||||
application:ensure_all_started(cowboy),
|
application:ensure_all_started(cowboy),
|
||||||
start_http(), % Пользовательский API (8080)
|
start_http(), % Пользовательский API (8080)
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ handle_call(init_tables, _From, State) ->
|
|||||||
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)
|
ok = join_cluster(ExtraNodes)
|
||||||
end,
|
end,
|
||||||
|
|||||||
+108
-31
@@ -4,7 +4,7 @@
|
|||||||
-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
|
||||||
@@ -12,6 +12,16 @@
|
|||||||
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).
|
||||||
|
-define(WAIT_TIMEOUT_MS, 120000).
|
||||||
|
-define(WAIT_INTERVAL_MS, 200).
|
||||||
|
|
||||||
|
%% Упорядоченный реестр миграций (добавлять новые модули в конец списка).
|
||||||
|
-define(ALL_MIGRATIONS, [
|
||||||
|
'20260501120000_base_schema',
|
||||||
|
'20260504150000_test_migration'
|
||||||
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
%% API
|
%% API
|
||||||
@@ -20,8 +30,10 @@
|
|||||||
start_link() ->
|
start_link() ->
|
||||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||||
|
|
||||||
init_migrations_table() ->
|
%% @doc Безопасно применить pending-миграции при старте (с global lock в Mnesia).
|
||||||
gen_server:call(?MODULE, init_table).
|
-spec ensure_applied() -> ok | {error, term()}.
|
||||||
|
ensure_applied() ->
|
||||||
|
gen_server:call(?MODULE, ensure_applied, infinity).
|
||||||
|
|
||||||
apply_pending() ->
|
apply_pending() ->
|
||||||
gen_server:call(?MODULE, apply_pending).
|
gen_server:call(?MODULE, apply_pending).
|
||||||
@@ -39,18 +51,9 @@ status() ->
|
|||||||
init([]) ->
|
init([]) ->
|
||||||
{ok, #{}}.
|
{ok, #{}}.
|
||||||
|
|
||||||
handle_call(init_table, _From, State) ->
|
handle_call(ensure_applied, _From, State) ->
|
||||||
case lists:member(?TABLE, mnesia:system_info(tables)) of
|
Result = do_ensure_applied(),
|
||||||
true -> ok;
|
{reply, Result, State};
|
||||||
false ->
|
|
||||||
mnesia:create_table(?TABLE, [
|
|
||||||
{disc_copies, [node()]},
|
|
||||||
{attributes, record_info(fields, schema_migration)},
|
|
||||||
{type, set}
|
|
||||||
])
|
|
||||||
end,
|
|
||||||
infra_mnesia:wait_for_table(?TABLE),
|
|
||||||
{reply, ok, State};
|
|
||||||
|
|
||||||
handle_call(apply_pending, _From, State) ->
|
handle_call(apply_pending, _From, State) ->
|
||||||
Result = do_apply_pending(),
|
Result = do_apply_pending(),
|
||||||
@@ -63,8 +66,8 @@ handle_call({rollback, Version}, _From, State) ->
|
|||||||
handle_call(status, _From, State) ->
|
handle_call(status, _From, State) ->
|
||||||
Applied = applied_versions(),
|
Applied = applied_versions(),
|
||||||
Pending = pending_versions() -- Applied,
|
Pending = pending_versions() -- Applied,
|
||||||
{reply, #{applied => lists:map(fun atom_to_list/1, Applied),
|
{reply, #{applied => [atom_to_list(V) || V <- Applied],
|
||||||
pending => lists:map(fun atom_to_list/1, Pending)}, State}.
|
pending => [atom_to_list(V) || V <- Pending]}, State}.
|
||||||
|
|
||||||
handle_cast(_Msg, State) -> {noreply, State}.
|
handle_cast(_Msg, State) -> {noreply, State}.
|
||||||
handle_info(_Msg, State) -> {noreply, State}.
|
handle_info(_Msg, State) -> {noreply, State}.
|
||||||
@@ -72,24 +75,98 @@ terminate(_Reason, _State) -> ok.
|
|||||||
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
code_change(_OldVsn, State, _Extra) -> {ok, State}.
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
%% Внутренняя логика
|
%% Координация при старте
|
||||||
|
%% ------------------------------
|
||||||
|
|
||||||
|
do_ensure_applied() ->
|
||||||
|
case all_applied() of
|
||||||
|
true -> ok;
|
||||||
|
false ->
|
||||||
|
case acquire_lock() of
|
||||||
|
ok ->
|
||||||
|
try
|
||||||
|
case do_apply_pending() of
|
||||||
|
ok -> ok;
|
||||||
|
{error, Reason} -> {error, Reason}
|
||||||
|
end
|
||||||
|
after
|
||||||
|
release_lock()
|
||||||
|
end;
|
||||||
|
{error, locked} ->
|
||||||
|
wait_until_applied(?WAIT_TIMEOUT_MS)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
all_applied() ->
|
||||||
|
pending_versions() -- applied_versions() =:= [].
|
||||||
|
|
||||||
|
wait_until_applied(TimeoutMs) when TimeoutMs =< 0 ->
|
||||||
|
{error, migration_timeout};
|
||||||
|
wait_until_applied(TimeoutMs) ->
|
||||||
|
case all_applied() of
|
||||||
|
true -> ok;
|
||||||
|
false ->
|
||||||
|
timer:sleep(?WAIT_INTERVAL_MS),
|
||||||
|
wait_until_applied(TimeoutMs - ?WAIT_INTERVAL_MS)
|
||||||
|
end.
|
||||||
|
|
||||||
|
acquire_lock() ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
case mnesia:sync_transaction(fun() -> try_acquire_lock(Now) end) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, locked} -> {error, locked};
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
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() ->
|
do_apply_pending() ->
|
||||||
Pending = pending_versions() -- applied_versions(),
|
Pending = pending_versions() -- applied_versions(),
|
||||||
lists:foreach(fun(Module) -> code:ensure_loaded(Module) end, Pending),
|
lists:foreach(fun(Module) -> code:ensure_loaded(Module) end, Pending),
|
||||||
lists:foldl(fun(Version, Acc) ->
|
lists:foldl(fun(Version, ok) ->
|
||||||
try Version:up() of
|
try Version:up() of
|
||||||
_ -> mark_applied(Version), Acc
|
_ ->
|
||||||
catch _:Reason ->
|
mark_applied(Version),
|
||||||
[{error, atom_to_list(Version), Reason} | Acc]
|
ok
|
||||||
end
|
catch
|
||||||
end, [], Pending).
|
_:Reason ->
|
||||||
|
{error, {migration_failed, atom_to_list(Version), Reason}}
|
||||||
|
end;
|
||||||
|
(_, Acc) -> Acc
|
||||||
|
end, ok, Pending).
|
||||||
|
|
||||||
do_rollback(TargetStr) ->
|
do_rollback(TargetStr) ->
|
||||||
Target = list_to_atom(TargetStr),
|
Target = list_to_atom(TargetStr),
|
||||||
Applied = applied_versions(),
|
Applied = applied_versions(),
|
||||||
ToRollback = lists:sort(fun(A,B) -> A > B end,
|
ToRollback = lists:sort(fun(A, B) -> A > B end,
|
||||||
[V || V <- Applied, V > Target]),
|
[V || V <- Applied, V > Target]),
|
||||||
lists:foreach(fun(Version) ->
|
lists:foreach(fun(Version) ->
|
||||||
code:ensure_loaded(Version),
|
code:ensure_loaded(Version),
|
||||||
@@ -102,19 +179,19 @@ do_rollback(TargetStr) ->
|
|||||||
|
|
||||||
applied_versions() ->
|
applied_versions() ->
|
||||||
[list_to_atom(V) || #schema_migration{version = V} <-
|
[list_to_atom(V) || #schema_migration{version = V} <-
|
||||||
mnesia:dirty_match_object(#schema_migration{_ = '_'})].
|
mnesia:dirty_match_object(#schema_migration{_ = '_'}),
|
||||||
|
not is_lock_version(V)].
|
||||||
|
|
||||||
pending_versions() ->
|
pending_versions() ->
|
||||||
AllMods = code:all_available(),
|
?ALL_MIGRATIONS.
|
||||||
[list_to_atom(Module) || Module <- extract_module_names(AllMods), lists:prefix("20", Module)].
|
|
||||||
|
|
||||||
extract_module_names(ModInfoList) ->
|
is_lock_version(?LOCK_VERSION) -> true;
|
||||||
[Name || {Name, _, _} <- ModInfoList].
|
is_lock_version(_) -> false.
|
||||||
|
|
||||||
mark_applied(Version) ->
|
mark_applied(Version) ->
|
||||||
mnesia:dirty_write(#schema_migration{
|
mnesia:dirty_write(#schema_migration{
|
||||||
version = atom_to_list(Version),
|
version = atom_to_list(Version),
|
||||||
applied_at = calendar:local_time()
|
applied_at = calendar:universal_time()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
unmark_applied(Version) ->
|
unmark_applied(Version) ->
|
||||||
|
|||||||
+31
-11
@@ -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-миграции.
|
||||||
|
2. Остальные узлы (join кластера) **ждут**, пока pending-список станет пустым, и не стартуют HTTP до синхронизации.
|
||||||
|
|
||||||
|
Базовые таблицы создаёт `infra_mnesia` при старте. Миграции — только для **инкрементальных** изменений (индексы, трансформация данных, новые поля).
|
||||||
|
|
||||||
## Создание новой миграции
|
## Создание новой миграции
|
||||||
1. Создайте файл в `priv/migrations/` с именем вида `YYYYMMDDHHMMSS_описание.erl`.
|
|
||||||
2. Реализуйте поведение `db_migration` (функции `up/0` и `down/0`).
|
|
||||||
3. При очередном запуске приложения миграция будет применена.
|
|
||||||
|
|
||||||
## Откат миграций
|
1. Создайте файл в `src/migrations/` с именем `YYYYMMDDHHMMSS_описание.erl`.
|
||||||
migration_engine:rollback("20260501120000_base_schema").
|
2. Реализуйте `up/0` и `down/0`.
|
||||||
|
3. Добавьте модуль в список `?ALL_MIGRATIONS` в `src/infra/migration_engine.erl` (в конец, по возрастанию версии).
|
||||||
|
4. При следующем старте миграция применится автоматически (на узле с lock).
|
||||||
|
|
||||||
|
## Ручной запуск
|
||||||
|
|
||||||
|
```erlang
|
||||||
|
migration_engine:apply_pending().
|
||||||
|
migration_engine:status().
|
||||||
|
migration_engine:rollback("20260501120000_base_schema").
|
||||||
|
```
|
||||||
|
|
||||||
## Плавающее обновление (rolling update)
|
## Плавающее обновление (rolling update)
|
||||||
|
|
||||||
1. Переведите узел в режим обслуживания.
|
1. Переведите узел в режим обслуживания.
|
||||||
2. Выполните бэкап: `mnesia:backup("backup_node.bak")`.
|
2. Выполните бэкап: `mnesia:backup("backup_node.bak")`.
|
||||||
3. Обновите код приложения (git pull / rsync).
|
3. Обновите код приложения (git pull / rsync).
|
||||||
4. Перезапустите узел – миграции применятся автоматически.
|
4. Перезапустите узел — `ensure_applied/0` применит только новые миграции (один узел с lock).
|
||||||
5. Убедитесь в согласованности данных.
|
5. Убедитесь: `migration_engine:status()` — `pending => []` на всех узлах.
|
||||||
6. Верните узел в работу.
|
6. Верните узел в работу.
|
||||||
7. Повторите для остальных узлов.
|
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