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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user