diff --git a/src/core/core_booking.erl b/src/core/core_booking.erl index 0d314e0..d6d7b86 100644 --- a/src/core/core_booking.erl +++ b/src/core/core_booking.erl @@ -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. diff --git a/src/core/core_event.erl b/src/core/core_event.erl index f76643c..a31666b 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -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, diff --git a/src/core/core_review.erl b/src/core/core_review.erl index 9b1eb39..7683453 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -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), diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl index 02ce768..f46a20d 100755 --- a/src/logic/logic_search.erl +++ b/src/logic/logic_search.erl @@ -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,