From d0528626119a81f3b4a472a831ba80c61c7e5ec0 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 17 Sep 2026 12:00:16 +0200 Subject: [PATCH] perf: index coach attendance and count coaches in the database The /coaches page counted all-time attended coaches by loading the grouped result into Ruby (Relation#length) and aggregating over all 1.98M workshop_invitations rows. A partial index on member_id for attended coach rows makes the aggregation an index-only scan, and the count now runs as COUNT(DISTINCT member_id) instead of materialising 3,572 rows. The index is created and dropped concurrently. Measured against the production dump: page db_runtime 112ms to 46ms. Closes #2886 --- app/controllers/dashboard_controller.rb | 2 +- ...ach_attended_index_to_workshop_invitations.rb | 16 ++++++++++++++++ db/schema.rb | 3 ++- spec/features/listing_coaches_spec.rb | 13 +++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260917090000_add_coach_attended_index_to_workshop_invitations.rb diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 341b7c8fb..06a5d2a72 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -31,7 +31,7 @@ def faq; end def about; end def wall_of_fame - @coaches_count = top_coach_query.length + @coaches_count = WorkshopInvitation.to_coaches.attended.distinct.count(:member_id) coaches = Member.where(id: top_coach_query .year(year_param)) .includes(:skills) diff --git a/db/migrate/20260917090000_add_coach_attended_index_to_workshop_invitations.rb b/db/migrate/20260917090000_add_coach_attended_index_to_workshop_invitations.rb new file mode 100644 index 000000000..8fda81504 --- /dev/null +++ b/db/migrate/20260917090000_add_coach_attended_index_to_workshop_invitations.rb @@ -0,0 +1,16 @@ +class AddCoachAttendedIndexToWorkshopInvitations < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def up + add_index :workshop_invitations, :member_id, + where: "attended AND role = 'Coach'", + name: :index_workshop_invitations_coach_attended_on_member_id, + algorithm: :concurrently + end + + def down + remove_index :workshop_invitations, + name: :index_workshop_invitations_coach_attended_on_member_id, + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 2c185a2fa..2fb99ecd1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_09_17_023327) do +ActiveRecord::Schema[8.1].define(version: 2026_09_17_090000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -615,6 +615,7 @@ t.integer "workshop_id" t.index ["member_id", "attending"], name: "index_workshop_invitations_member_attending" t.index ["member_id", "workshop_id", "role"], name: "idx_on_member_id_workshop_id_role_e3cea6bbfd", unique: true + t.index ["member_id"], name: "index_workshop_invitations_coach_attended_on_member_id", where: "(attended AND ((role)::text = 'Coach'::text))" t.index ["member_id"], name: "index_workshop_invitations_on_member_id" t.index ["token"], name: "index_workshop_invitations_on_token", unique: true t.index ["workshop_id", "attending"], name: "index_workshop_invitations_workshop_attending" diff --git a/spec/features/listing_coaches_spec.rb b/spec/features/listing_coaches_spec.rb index b07c59dd3..ba7bde268 100644 --- a/spec/features/listing_coaches_spec.rb +++ b/spec/features/listing_coaches_spec.rb @@ -9,6 +9,19 @@ expect(page).to have_text(coach.name, wait: 5) end + scenario 'I can see the total number of volunteers across all years' do + workshop_last_year = Fabricate(:workshop, date_and_time: 1.year.ago) + workshop_three_years_ago = Fabricate(:workshop, date_and_time: 3.years.ago) + coach = Fabricate(:attended_coach, workshop: workshop_last_year).member + Fabricate(:attended_coach, member: coach, workshop: workshop_three_years_ago) + Fabricate(:attended_coach, workshop: workshop_three_years_ago) + + visit coaches_path + + # The same coach at two workshops counts once + expect(page).to have_text('the 2 volunteers', wait: 5) + end + scenario 'I can see the top coaches by year' do travel_to(Time.current) do latest_workshop = Fabricate(:workshop, date_and_time: 1.year.ago)