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{}]}.
list_by_event(EventId) ->
Match = #booking{event_id = EventId, _ = '_'},
Bookings = mnesia:dirty_match_object(Match),
%% Optimized: use event_id index instead of full table scan.
Bookings = mnesia:dirty_index_read(booking, EventId, #booking.event_id),
{ok, Bookings}.
%%%-------------------------------------------------------------------
@@ -64,8 +64,8 @@ list_by_event(EventId) ->
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> {ok, [#booking{}]}.
list_by_user(UserId) ->
Match = #booking{user_id = UserId, _ = '_'},
Bookings = mnesia:dirty_match_object(Match),
%% Optimized: use user_id index instead of full table scan.
Bookings = mnesia:dirty_index_read(booking, UserId, #booking.user_id),
{ok, Bookings}.
%%%-------------------------------------------------------------------
@@ -74,6 +74,7 @@ list_by_user(UserId) ->
%%%-------------------------------------------------------------------
-spec list_all() -> [#booking{}].
list_all() ->
%% Genuinely lists all bookings (admin view); no index can help here.
mnesia:dirty_match_object(#booking{_ = '_'}).
%%%-------------------------------------------------------------------
@@ -123,8 +124,10 @@ count_bookings() ->
-spec get_by_event_and_user(EventId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found}.
get_by_event_and_user(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case mnesia:dirty_match_object(Match) of
%% Optimized: use event_id index, then filter by user_id
%% 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};
[Booking] -> {ok, Booking}
end.
+32 -10
View File
@@ -118,10 +118,12 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
[] ->
{error, master_not_found};
[Master] when Master#event.event_type =:= recurring ->
Existing = mnesia:dirty_match_object(
#event{master_id = MasterId, start_time = OccurrenceStart,
is_instance = true, _ = '_'}
),
%% Optimized: use master_id index instead of full table scan,
%% then filter by start_time and is_instance.
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
[] ->
InstanceId = infra_utils:generate_id(16),
@@ -180,8 +182,11 @@ get_by_id(Id) ->
%%%-------------------------------------------------------------------
-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
list_by_calendar(CalendarId) ->
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'},
Events = mnesia:dirty_match_object(Match),
%% Optimized: use calendar_id index instead of full table scan,
%% 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}.
%%%-------------------------------------------------------------------
@@ -229,8 +234,10 @@ count_events() ->
%%%-------------------------------------------------------------------
-spec list_all() -> [#event{}].
list_all() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimized: use status index to read only active events,
%% 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 Подсчёт событий, созданных в заданном временном диапазоне.
@@ -241,6 +248,9 @@ list_all() ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_events_by_date(From, To) ->
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{_ = '_'}),
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
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()}].
count_events_by_type() ->
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) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
@@ -277,7 +292,12 @@ count_events_by_type() ->
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
count_events_by_status() ->
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) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
@@ -295,6 +315,8 @@ get_top_events_by_rating(N) ->
case stats_tops:get_top_events_by_rating(N) of
{ok, Events} -> Events;
{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{_ = '_'}),
Sorted = lists:reverse(lists:sort(
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};
[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}) ->
mnesia:delete({review_vote, VoteId})
end, Votes),
+41 -11
View File
@@ -122,10 +122,14 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
Offset :: non_neg_integer()) ->
{ok, non_neg_integer(), [map()]}.
search_events(Query, UserId, Params, Limit, Offset) ->
%% Step 1: index read on #event.status (avoids full table scan).
AllEvents = get_all_events(),
%% Step 2: batch-fetch calendars to fix N+1 (see filter_accessible_events).
AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, 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),
{ok, length(Filtered), format_events(Paginated)}.
@@ -148,24 +152,34 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
-spec get_all_events() -> [#event{}].
get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimization: use the secondary index on #event.status instead of a
%% 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{}].
get_all_calendars() ->
Match = #calendar{status = active, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimization: use the secondary index on #calendar.status instead of
%% a full table scan.
mnesia:dirty_index_read(calendar, active, #calendar.status).
%% ============ Фильтрация по доступности ============
-spec filter_accessible_events([#event{}], binary()) -> [#event{}].
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) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, Calendar} ->
logic_calendar:can_access(UserId, Calendar)
andalso calendar_discoverable(Calendar);
_ -> false
error -> false
end
end, Events).
@@ -182,6 +196,18 @@ calendar_discoverable(#calendar{type = commercial} = C) ->
calendar_discoverable(_) ->
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{}].
@@ -316,20 +342,24 @@ paginate(List, Limit, Offset) ->
-spec format_events([#event{}]) -> [map()].
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().
format_event(Event) ->
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
format_event(Event, CalendarMap) ->
Location = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
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 = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
_ -> {null, null}
error -> {null, null}
end,
#{
id => Event#event.id,