fix(mnesia): per-slot volumes hint and harden verification lookup.
CI / test (push) Failing after 5m10s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

Avoid shared Mnesia dir across replicas; make get_or_create_token and CT verify_user resilient to brief cross-node lag.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 09:54:10 +03:00
parent a696d25486
commit 5e569da1d8
4 changed files with 62 additions and 16 deletions
+8 -3
View File
@@ -76,12 +76,17 @@ services:
- eventhub - eventhub
- eventhub-node - eventhub-node
volumes: volumes:
# Per-task volume when scaling replicas > 1 (never share one Mnesia dir).
- type: volume - type: volume
source: eventhub-data source: eventhub-data-{{.Task.Slot}}
target: /app/data target: /app/data
deploy: deploy:
replicas: 1 replicas: 1
endpoint_mode: dnsrr endpoint_mode: dnsrr
update_config:
parallelism: 1
delay: 30s
order: stop-first
restart_policy: restart_policy:
condition: any condition: any
labels: labels:
@@ -222,8 +227,8 @@ networks:
driver: overlay driver: overlay
volumes: volumes:
eventhub-data: # eventhub-data-{{.Task.Slot}} created by Swarm from service mount template
# name: 'eventhub-data-{{.Task.Slot}}' # (do not use a single shared volume for multi-replica Mnesia)
prometheus-data: prometheus-data:
grafana-data: grafana-data:
traefik-logs: traefik-logs:
+28 -6
View File
@@ -34,17 +34,19 @@ verify_token(Token) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Возвращает существующий токен пользователя или создаёт новый. %%% @doc Возвращает существующий токен пользователя или создаёт новый.
%%% Допускает несколько токенов на user_id (register + повторные вызовы).
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec get_or_create_token(UserId :: binary()) -> -spec get_or_create_token(UserId :: binary()) ->
{ok, Token :: binary(), ExpiresAt :: calendar:datetime()} | {error, not_found}. {ok, Token :: binary(), ExpiresAt :: calendar:datetime()} | {error, not_found}.
get_or_create_token(UserId) -> get_or_create_token(UserId) ->
case mnesia:dirty_match_object(#verification{user_id = UserId, _ = '_'}) of case find_token(UserId) of
[V] -> {ok, V#verification.token, V#verification.expires_at}; {ok, Token, ExpiresAt} ->
[] -> {ok, Token, ExpiresAt};
case core_user:get_by_id(UserId) of not_found ->
{ok, _} -> create_token(UserId); case user_exists(UserId) of
Error -> Error true -> create_token(UserId);
false -> {error, not_found}
end end
end. end.
@@ -56,3 +58,23 @@ get_or_create_token(UserId) ->
delete_token(Token) -> delete_token(Token) ->
mnesia:dirty_delete(verification, Token), mnesia:dirty_delete(verification, Token),
ok. ok.
%%--------------------------------------------------------------------
find_token(UserId) ->
case mnesia:dirty_match_object(#verification{user_id = UserId, _ = '_'}) of
[#verification{token = Token, expires_at = ExpiresAt} | _] ->
{ok, Token, ExpiresAt};
[] ->
not_found
end.
%% dirty_read first; transactional read as fallback for cluster replica lag.
user_exists(UserId) ->
case core_user:get_by_id(UserId) of
{ok, _} -> true;
{error, not_found} ->
case mnesia:transaction(fun() -> mnesia:read(user, UserId) end) of
{atomic, [_ | _]} -> true;
_ -> false
end
end.
+8 -1
View File
@@ -229,7 +229,7 @@ add_local_ram_copy(Tab) ->
end. end.
wait_for_tables_available() -> wait_for_tables_available() ->
lists:foreach(fun(Tab) -> wait_for_table(Tab) end, ?DISC_TABLES). lists:foreach(fun(Tab) -> wait_for_table(Tab) end, ?DISC_TABLES ++ ?RAM_TABLES).
wait_for_table(Tab) -> wait_for_table(Tab) ->
case lists:member(Tab, mnesia:system_info(tables)) of case lists:member(Tab, mnesia:system_info(tables)) of
@@ -260,6 +260,12 @@ prune_dead_nodes() ->
false -> ok false -> ok
end end
end, ?DISC_TABLES), end, ?DISC_TABLES),
lists:foreach(fun(Tab) ->
case lists:member(Node, mnesia:table_info(Tab, ram_copies)) of
true -> catch mnesia:del_table_copy(Tab, Node);
false -> ok
end
end, ?RAM_TABLES),
catch mnesia:del_table_copy(schema, Node) catch mnesia:del_table_copy(schema, Node)
end, DeadNodes). end, DeadNodes).
@@ -327,6 +333,7 @@ create_indices() ->
mnesia:add_table_index(calendar_specialist, user_id), mnesia:add_table_index(calendar_specialist, user_id),
mnesia:add_table_index(user, nickname), mnesia:add_table_index(user, nickname),
mnesia:add_table_index(user, email), mnesia:add_table_index(user, email),
mnesia:add_table_index(verification, user_id),
mnesia:add_table_index(notification, user_id), mnesia:add_table_index(notification, user_id),
mnesia:add_table_index(notification, is_read), mnesia:add_table_index(notification, is_read),
mnesia:add_table_index(auth_session, family_id), mnesia:add_table_index(auth_session, family_id),
+17 -5
View File
@@ -402,13 +402,25 @@ create_event(Token, CalId, Params) ->
%% @doc Подтверждает email пользователя, используя админский эндпоинт %% @doc Подтверждает email пользователя, используя админский эндпоинт
%% для получения верификационного токена и публичный /v1/verify. %% для получения верификационного токена и публичный /v1/verify.
%% Retries briefly: IFT Traefik may hit another replica before Mnesia settles.
-spec verify_user(AdminToken :: binary(), UserId :: binary()) -> ok. -spec verify_user(AdminToken :: binary(), UserId :: binary()) -> ok.
verify_user(AdminToken, UserId) -> verify_user(AdminToken, UserId) ->
#{<<"token">> := Token} = admin_get( verify_user(AdminToken, UserId, 8).
<<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken),
{ok, 200, _, _} = client_request(post, <<"/v1/verify">>, <<>>, verify_user(AdminToken, UserId, RetriesLeft) ->
jsx:encode(#{<<"token">> => Token})), Path = <<"/v1/admin/users/", UserId/binary, "/verification-token">>,
ok. case admin_request(get, Path, AdminToken) of
{ok, 200, _, Body} ->
#{<<"token">> := Token} = jsx:decode(list_to_binary(Body), [return_maps]),
{ok, 200, _, _} = client_request(post, <<"/v1/verify">>, <<>>,
jsx:encode(#{<<"token">> => Token})),
ok;
{ok, 404, _, _} when RetriesLeft > 1 ->
timer:sleep(100),
verify_user(AdminToken, UserId, RetriesLeft - 1);
Other ->
error({verify_user_failed, UserId, Other})
end.
%%%=================================================================== %%%===================================================================
%%% Внутренние функции %%% Внутренние функции