perf(mnesia): use secondary indexes in booking, event, search, review

Replace hot-path dirty_match_object scans with dirty_index_read and batch
calendar lookup in search to cut N+1 enrichment cost.
This commit is contained in:
2026-08-03 22:55:50 +03:00
parent 104892bdde
commit e3467e7412
4 changed files with 84 additions and 28 deletions
+9 -6
View File
@@ -54,8 +54,8 @@ get_by_id(Id) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec list_by_event(EventId :: binary()) -> {ok, [#booking{}]}. -spec list_by_event(EventId :: binary()) -> {ok, [#booking{}]}.
list_by_event(EventId) -> list_by_event(EventId) ->
Match = #booking{event_id = EventId, _ = '_'}, %% Optimized: use event_id index instead of full table scan.
Bookings = mnesia:dirty_match_object(Match), Bookings = mnesia:dirty_index_read(booking, EventId, #booking.event_id),
{ok, Bookings}. {ok, Bookings}.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -64,8 +64,8 @@ list_by_event(EventId) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> {ok, [#booking{}]}. -spec list_by_user(UserId :: binary()) -> {ok, [#booking{}]}.
list_by_user(UserId) -> list_by_user(UserId) ->
Match = #booking{user_id = UserId, _ = '_'}, %% Optimized: use user_id index instead of full table scan.
Bookings = mnesia:dirty_match_object(Match), Bookings = mnesia:dirty_index_read(booking, UserId, #booking.user_id),
{ok, Bookings}. {ok, Bookings}.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -74,6 +74,7 @@ list_by_user(UserId) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec list_all() -> [#booking{}]. -spec list_all() -> [#booking{}].
list_all() -> list_all() ->
%% Genuinely lists all bookings (admin view); no index can help here.
mnesia:dirty_match_object(#booking{_ = '_'}). mnesia:dirty_match_object(#booking{_ = '_'}).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -123,8 +124,10 @@ count_bookings() ->
-spec get_by_event_and_user(EventId :: binary(), UserId :: binary()) -> -spec get_by_event_and_user(EventId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found}. {ok, #booking{}} | {error, not_found}.
get_by_event_and_user(EventId, UserId) -> get_by_event_and_user(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'}, %% Optimized: use event_id index, then filter by user_id
case mnesia:dirty_match_object(Match) of %% instead of a full table scan.
Candidates = mnesia:dirty_index_read(booking, EventId, #booking.event_id),
case [B || B <- Candidates, B#booking.user_id =:= UserId] of
[] -> {error, not_found}; [] -> {error, not_found};
[Booking] -> {ok, Booking} [Booking] -> {ok, Booking}
end. end.
+32 -10
View File
@@ -118,10 +118,12 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
[] -> [] ->
{error, master_not_found}; {error, master_not_found};
[Master] when Master#event.event_type =:= recurring -> [Master] when Master#event.event_type =:= recurring ->
Existing = mnesia:dirty_match_object( %% Optimized: use master_id index instead of full table scan,
#event{master_id = MasterId, start_time = OccurrenceStart, %% then filter by start_time and is_instance.
is_instance = true, _ = '_'} Candidates = mnesia:dirty_index_read(event, MasterId, #event.master_id),
), Existing = [E || E <- Candidates,
E#event.start_time =:= OccurrenceStart andalso
E#event.is_instance =:= true],
case Existing of case Existing of
[] -> [] ->
InstanceId = infra_utils:generate_id(16), InstanceId = infra_utils:generate_id(16),
@@ -180,8 +182,11 @@ get_by_id(Id) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}. -spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
list_by_calendar(CalendarId) -> list_by_calendar(CalendarId) ->
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'}, %% Optimized: use calendar_id index instead of full table scan,
Events = mnesia:dirty_match_object(Match), %% then filter by status and is_instance.
Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id),
Events = [E || E <- Candidates,
E#event.status =:= active andalso E#event.is_instance =:= false],
{ok, Events}. {ok, Events}.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
@@ -229,8 +234,10 @@ count_events() ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec list_all() -> [#event{}]. -spec list_all() -> [#event{}].
list_all() -> list_all() ->
Match = #event{status = active, is_instance = false, _ = '_'}, %% Optimized: use status index to read only active events,
mnesia:dirty_match_object(Match). %% then filter by is_instance instead of a full table scan.
Active = mnesia:dirty_index_read(event, active, #event.status),
[E || E <- Active, E#event.is_instance =:= false].
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Подсчёт событий, созданных в заданном временном диапазоне. %%% @doc Подсчёт событий, созданных в заданном временном диапазоне.
@@ -241,6 +248,9 @@ list_all() ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}]. [{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_events_by_date(From, To) -> count_events_by_date(From, To) ->
core_stats:with_daily(events_created, From, To, fun() -> core_stats:with_daily(events_created, From, To, fun() ->
%% TODO: created_at has a hash index (no range-scan support in Mnesia);
%% a full scan is still required here. Pre-aggregate in stats_collector
%% to avoid this fallback path.
All = mnesia:dirty_match_object(#event{_ = '_'}), All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
E#event.created_at =< To end, All), E#event.created_at =< To end, All),
@@ -261,7 +271,12 @@ count_events_by_date(From, To) ->
-spec count_events_by_type() -> [{atom(), non_neg_integer()}]. -spec count_events_by_type() -> [{atom(), non_neg_integer()}].
count_events_by_type() -> count_events_by_type() ->
core_stats:with_dim(event, type, fun() -> core_stats:with_dim(event, type, fun() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}), %% Optimized: use event_type index reads for known values instead of
%% a full table scan. event_type :: single | recurring.
KnownTypes = [single, recurring],
Events = lists:flatmap(
fun(Type) -> mnesia:dirty_index_read(event, Type, #event.event_type) end,
KnownTypes),
lists:foldl(fun(#event{event_type = Type}, Acc) -> lists:foldl(fun(#event{event_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc]; false -> [{Type, 1} | Acc];
@@ -277,7 +292,12 @@ count_events_by_type() ->
-spec count_events_by_status() -> [{atom(), non_neg_integer()}]. -spec count_events_by_status() -> [{atom(), non_neg_integer()}].
count_events_by_status() -> count_events_by_status() ->
core_stats:with_dim(event, status, fun() -> core_stats:with_dim(event, status, fun() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}), %% Optimized: use status index reads for known values instead of
%% a full table scan. status :: active | cancelled | completed | frozen | deleted.
KnownStatuses = [active, cancelled, completed, frozen, deleted],
Events = lists:flatmap(
fun(Status) -> mnesia:dirty_index_read(event, Status, #event.status) end,
KnownStatuses),
lists:foldl(fun(#event{status = Status}, Acc) -> lists:foldl(fun(#event{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc]; false -> [{Status, 1} | Acc];
@@ -295,6 +315,8 @@ get_top_events_by_rating(N) ->
case stats_tops:get_top_events_by_rating(N) of case stats_tops:get_top_events_by_rating(N) of
{ok, Events} -> Events; {ok, Events} -> Events;
{error, _} -> {error, _} ->
%% TODO: no index on rating_avg; full scan is required for this fallback.
%% The primary path uses stats_tops; this is only a fallback.
Events = mnesia:dirty_match_object(#event{_ = '_'}), Events = mnesia:dirty_match_object(#event{_ = '_'}),
Sorted = lists:reverse(lists:sort( Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end, fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
+2 -1
View File
@@ -114,7 +114,8 @@ delete(Id) ->
[] -> [] ->
{error, not_found}; {error, not_found};
[Review] -> [Review] ->
Votes = mnesia:match_object(#review_vote{review_id = Id, _ = '_'}), %% Use review_vote.review_id index instead of full-table match.
Votes = mnesia:index_read(review_vote, Id, #review_vote.review_id),
lists:foreach(fun(#review_vote{id = VoteId}) -> lists:foreach(fun(#review_vote{id = VoteId}) ->
mnesia:delete({review_vote, VoteId}) mnesia:delete({review_vote, VoteId})
end, Votes), end, Votes),
+41 -11
View File
@@ -122,10 +122,14 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
Offset :: non_neg_integer()) -> Offset :: non_neg_integer()) ->
{ok, non_neg_integer(), [map()]}. {ok, non_neg_integer(), [map()]}.
search_events(Query, UserId, Params, Limit, Offset) -> search_events(Query, UserId, Params, Limit, Offset) ->
%% Step 1: index read on #event.status (avoids full table scan).
AllEvents = get_all_events(), AllEvents = get_all_events(),
%% Step 2: batch-fetch calendars to fix N+1 (see filter_accessible_events).
AccessibleEvents = filter_accessible_events(AllEvents, UserId), AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, Params), Filtered = apply_event_filters(AccessibleEvents, Query, Params),
Sorted = sort_events(Filtered, Params), Sorted = sort_events(Filtered, Params),
%% Pagination is applied BEFORE formatting so only the page slice is
%% enriched with calendar data (see format_events).
Paginated = paginate(Sorted, Limit, Offset), Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}. {ok, length(Filtered), format_events(Paginated)}.
@@ -148,24 +152,34 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
-spec get_all_events() -> [#event{}]. -spec get_all_events() -> [#event{}].
get_all_events() -> get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'}, %% Optimization: use the secondary index on #event.status instead of a
mnesia:dirty_match_object(Match). %% full table scan (dirty_match_object). This reads only events whose
%% status is `active`, then filters out recurring instances in memory.
Events = mnesia:dirty_index_read(event, active, #event.status),
[E || E <- Events, E#event.is_instance =:= false].
-spec get_all_calendars() -> [#calendar{}]. -spec get_all_calendars() -> [#calendar{}].
get_all_calendars() -> get_all_calendars() ->
Match = #calendar{status = active, _ = '_'}, %% Optimization: use the secondary index on #calendar.status instead of
mnesia:dirty_match_object(Match). %% a full table scan.
mnesia:dirty_index_read(calendar, active, #calendar.status).
%% ============ Фильтрация по доступности ============ %% ============ Фильтрация по доступности ============
-spec filter_accessible_events([#event{}], binary()) -> [#event{}]. -spec filter_accessible_events([#event{}], binary()) -> [#event{}].
filter_accessible_events(Events, UserId) -> filter_accessible_events(Events, UserId) ->
%% Optimization (N+1 fix): batch-fetch all needed calendars in one pass
%% instead of calling core_calendar:get_by_id for every event. We
%% collect the unique calendar_ids, read them with dirty_read (key-based,
%% O(1) each) and build a lookup map.
CalendarIds = lists:usort([E#event.calendar_id || E <- Events]),
CalendarMap = build_calendar_map(CalendarIds),
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, Calendar} -> {ok, Calendar} ->
logic_calendar:can_access(UserId, Calendar) logic_calendar:can_access(UserId, Calendar)
andalso calendar_discoverable(Calendar); andalso calendar_discoverable(Calendar);
_ -> false error -> false
end end
end, Events). end, Events).
@@ -182,6 +196,18 @@ calendar_discoverable(#calendar{type = commercial} = C) ->
calendar_discoverable(_) -> calendar_discoverable(_) ->
true. true.
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1
%% queries in filter_accessible_events and format_events.
-spec build_calendar_map([binary()]) -> #{binary() => #calendar{}}.
build_calendar_map(CalendarIds) ->
lists:foldl(fun(Id, Acc) ->
case mnesia:dirty_read(calendar, Id) of
[Calendar] -> Acc#{Id => Calendar};
[] -> Acc
end
end, #{}, CalendarIds).
%% ============ Применение фильтров ============ %% ============ Применение фильтров ============
-spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}]. -spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}].
@@ -316,20 +342,24 @@ paginate(List, Limit, Offset) ->
-spec format_events([#event{}]) -> [map()]. -spec format_events([#event{}]) -> [map()].
format_events(Events) -> format_events(Events) ->
lists:map(fun format_event/1, Events). %% Optimization: batch-fetch calendars for the (already paginated)
%% subset only, avoiding an N+1 query per event during formatting.
CalendarMap = build_calendar_map(
lists:usort([E#event.calendar_id || E <- Events])),
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events).
-spec format_event(#event{}) -> map(). -spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
format_event(Event) -> format_event(Event, CalendarMap) ->
Location = case Event#event.location of Location = case Event#event.location of
undefined -> null; undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} -> #location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon} #{address => Addr, lat => Lat, lon => Lon}
end, end,
{CalendarTitle, ImageUrl} = case core_calendar:get_by_id(Event#event.calendar_id) of {CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null}; {ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
{ok, #calendar{title = T, image_url = undefined}} -> {T, null}; {ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url}; {ok, #calendar{title = T, image_url = Url}} -> {T, Url};
_ -> {null, null} error -> {null, null}
end, end,
#{ #{
id => Event#event.id, id => Event#event.id,