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
+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,