-
-
Notifications
You must be signed in to change notification settings - Fork 518
WIP N+1 detector gem Prosopite (replacing gem Bullet) #6744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1146c66
6bc37a9
7a8d67d
d0f5517
7e7b284
85fd417
f9cd33a
5769eb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| # Prosopite N+1 Query Issues | ||
|
|
||
| Issues detected by Prosopite during test suite run. Fix by adding eager loading (`includes`, `preload`) or restructuring queries. | ||
|
|
||
| ## High Priority (20+ occurrences) | ||
|
|
||
| ### app/decorators/casa_case_decorator.rb:34 | ||
| - **Method:** `map` in decorator | ||
| - **Likely fix:** Add `includes` for associated records being accessed in iteration | ||
|
|
||
| ### app/models/user.rb:84 | ||
| - **Method:** `create_preference_set` | ||
| - **Query:** `SELECT "preference_sets".* FROM "preference_sets" WHERE "preference_sets"."user_id" = $1` | ||
| - **Likely fix:** Check if preference_set already loaded before querying | ||
|
|
||
| ### app/models/concerns/api.rb:11 | ||
| - **Method:** `initialize_api_credentials` | ||
| - **Query:** `SELECT "api_credentials".* FROM "api_credentials" WHERE "api_credentials"."user_id" = $1` | ||
| - **Likely fix:** Check if api_credentials already loaded before querying | ||
|
|
||
| ### app/lib/importers/file_importer.rb:50 | ||
| - **Method:** `create_user_record` | ||
| - **Queries:** Multiple user lookups during import | ||
| - **Likely fix:** Batch lookups or use `Prosopite.pause` for intentional import operations | ||
|
|
||
| ## Medium Priority (10-19 occurrences) | ||
|
|
||
| ### app/validators/user_validator.rb:56 | ||
| - **Method:** `validate_uniqueness` | ||
| - **Query:** `SELECT "users".* FROM "users" WHERE "users"."type" = $1 AND "users"."email" = $2` | ||
| - **Likely fix:** Consider if validation is necessary during bulk operations | ||
|
|
||
| ### app/lib/importers/supervisor_importer.rb:47 | ||
| - **Method:** `block in assign_volunteers` | ||
| - **Query:** `SELECT "users".* FROM "users" INNER JOIN "supervisor_volunteers"...` | ||
| - **Likely fix:** Preload volunteers before assignment loop | ||
|
|
||
| ### app/lib/importers/supervisor_importer.rb:51 | ||
| - **Method:** `block in assign_volunteers` | ||
| - **Query:** `SELECT "users".* FROM "users" WHERE "users"."id" = $1` | ||
| - **Likely fix:** Batch load users by ID before iteration | ||
|
|
||
| ### app/controllers/case_contacts/form_controller.rb:156 | ||
| - **Method:** `block in create_additional_case_contacts` | ||
| - **Likely fix:** Eager load case contact associations | ||
|
|
||
| ## Lower Priority (5-9 occurrences) | ||
|
|
||
| ### app/models/court_date.rb:32 | ||
| - **Method:** `associated_reports` | ||
| - **Likely fix:** Add `includes` for court report associations | ||
|
|
||
| ### app/lib/importers/supervisor_importer.rb:23 | ||
| - **Method:** `block in import_supervisors` | ||
| - **Query:** `SELECT "users".* FROM "users" WHERE "users"."type" = $1 AND "users"."email" = $2` | ||
| - **Likely fix:** Batch check existing supervisors before import loop | ||
|
|
||
| ## Lower Priority (2-4 occurrences) | ||
|
|
||
| ### app/lib/importers/file_importer.rb:57 | ||
| - **Method:** `email_addresses_to_users` | ||
| - **Likely fix:** Batch load users by email | ||
|
|
||
| ### app/lib/importers/case_importer.rb:41 | ||
| - **Method:** `create_casa_case` | ||
| - **Likely fix:** Preload or batch casa_case lookups | ||
|
|
||
| ### app/decorators/contact_type_decorator.rb:14 | ||
| - **Method:** `last_time_used_with_cases` | ||
| - **Likely fix:** Eager load case associations | ||
|
|
||
| ### app/datatables/reimbursement_datatable.rb:25 | ||
| - **Method:** `block in data` | ||
| - **Query:** `SELECT "addresses".* FROM "addresses" WHERE "addresses"."user_id" = $1` | ||
| - **Likely fix:** Add `includes(:address)` to reimbursement query | ||
|
|
||
| ### app/services/volunteer_birthday_reminder_service.rb:7 | ||
| - **Method:** `block in send_reminders` | ||
| - **Likely fix:** Eager load volunteer associations | ||
|
|
||
| ### app/models/contact_topic.rb:27 | ||
| - **Method:** `block in generate_for_org!` | ||
| - **Likely fix:** Batch operations or use `Prosopite.pause` for setup | ||
|
|
||
| ### app/models/casa_org.rb:82 | ||
| - **Method:** `user_count` | ||
| - **Likely fix:** Use counter cache or single count query | ||
|
|
||
| ### app/models/casa_org.rb:62 | ||
| - **Method:** `case_contacts_count` | ||
| - **Likely fix:** Use counter cache or single count query | ||
|
|
||
| ### app/lib/importers/volunteer_importer.rb:23 | ||
| - **Method:** `block in import_volunteers` | ||
| - **Likely fix:** Batch check existing volunteers before import loop | ||
|
|
||
| ### app/lib/importers/case_importer.rb:20 | ||
| - **Method:** `block in import_cases` | ||
| - **Likely fix:** Batch check existing cases before import loop | ||
|
|
||
| ### app/controllers/case_contacts/form_controller.rb:26 | ||
| - **Method:** `block (2 levels) in update` | ||
| - **Likely fix:** Eager load contact associations | ||
|
|
||
| ## Single Occurrence | ||
|
|
||
| ### app/services/missing_data_export_csv_service.rb:40 | ||
| - **Method:** `full_data` | ||
|
|
||
| ### app/policies/contact_topic_answer_policy.rb:18 | ||
| - **Method:** `create?` | ||
|
|
||
| ### app/models/casa_case.rb:152 | ||
| - **Method:** `next_court_date` | ||
|
|
||
| ### app/models/all_casa_admins/casa_org_metrics.rb:16 | ||
| - **Method:** `map` | ||
|
|
||
| ### config/initializers/sent_email_event.rb:7 | ||
| - **Method:** `block in <top (required)>` | ||
| - **Query:** `SELECT "casa_orgs".* FROM "casa_orgs" WHERE "casa_orgs"."id" = $1` | ||
| - **Note:** Initializer callback - consider caching org lookup | ||
|
|
||
| ## Notes | ||
|
|
||
| - **Importers:** Many N+1s occur in import code. Consider wrapping entire import operations in `Prosopite.pause { }` if the N+1 pattern is intentional for per-record processing, or batch-load records before iteration. | ||
|
|
||
| - **Decorators:** Add `includes` at the controller/query level before passing to decorators. | ||
|
|
||
| - **Callbacks:** User model callbacks (`create_preference_set`, `initialize_api_credentials`) fire on each create. Consider if these can be optimized or if the N+1 is acceptable for single-record operations. | ||
|
|
||
| ## How to Fix | ||
|
|
||
| ```ruby | ||
| # Before (N+1) | ||
| users.each { |u| u.orders.count } | ||
|
|
||
| # After (eager loading) | ||
| users.includes(:orders).each { |u| u.orders.count } | ||
|
|
||
| # For intentional batch operations | ||
| Prosopite.pause do | ||
| records.each { |r| process_individually(r) } | ||
| end | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Rack middleware for development only — in test, scanning is handled by RSpec hooks | ||
| if Rails.env.development? && | ||
| Rails.configuration.respond_to?(:prosopite_enabled) && | ||
| Rails.configuration.prosopite_enabled | ||
| require "prosopite/middleware/rack" | ||
| Rails.configuration.middleware.use(Prosopite::Middleware::Rack) | ||
| end | ||
|
|
||
| # Development configuration — test config lives in spec/support/prosopite.rb | ||
| Rails.application.config.after_initialize do | ||
| next unless Rails.env.development? | ||
|
|
||
| Prosopite.enabled = Rails.configuration.respond_to?(:prosopite_enabled) && | ||
| Rails.configuration.prosopite_enabled | ||
|
|
||
| Prosopite.min_n_queries = Rails.configuration.respond_to?(:prosopite_min_n_queries) ? | ||
| Rails.configuration.prosopite_min_n_queries : 2 | ||
|
|
||
| Prosopite.rails_logger = true | ||
| Prosopite.prosopite_logger = true | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Directories excluded from Prosopite N+1 raise (will still log). | ||
| # Remove paths as you fix the underlying N+1s. | ||
| # | ||
| # See PROSOPITE_TODO.md for the full list of known issues. | ||
| spec/models | ||
| spec/services | ||
| spec/lib | ||
| spec/system | ||
| spec/requests | ||
| spec/controllers | ||
| spec/views | ||
| spec/decorators | ||
| spec/policies | ||
| spec/datatables | ||
| spec/helpers | ||
| spec/seeds | ||
| spec/mailers | ||
| spec/presenters | ||
| spec/config | ||
| spec/notifications |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| return unless defined?(Prosopite) | ||
|
|
||
| # Test configuration — this file owns all Prosopite settings for the test env | ||
| Prosopite.enabled = true | ||
| Prosopite.raise = true | ||
| Prosopite.rails_logger = true | ||
| Prosopite.prosopite_logger = true | ||
|
|
||
| # Allowlist for known acceptable N+1 patterns (e.g., test matchers) | ||
| Prosopite.allow_stack_paths = [ | ||
| "shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb", | ||
| "shoulda/matchers/active_model/validate_presence_of_matcher.rb", | ||
| "shoulda/matchers/active_model/validate_inclusion_of_matcher.rb", | ||
| "shoulda/matchers/active_model/allow_value_matcher.rb" | ||
| ] | ||
|
|
||
| # Load ignore list from file for gradual rollout — directories listed in | ||
| # .prosopite_ignore are scanned but won't raise, only log. | ||
| PROSOPITE_IGNORE = if File.exist?("spec/.prosopite_ignore") | ||
| File.read("spec/.prosopite_ignore") | ||
| .lines | ||
| .map(&:chomp) | ||
| .reject { |line| line.empty? || line.start_with?("#") } | ||
| else | ||
| [] | ||
| end | ||
|
|
||
| RSpec.configure do |config| | ||
| # Pause Prosopite during factory creation to prevent false positives | ||
| # from factory callbacks and associations | ||
| config.before(:suite) do | ||
| if defined?(FactoryBot) | ||
| FactoryBot::SyntaxRunner.class_eval do | ||
| alias_method :original_create, :create | ||
|
|
||
| def create(*args, **kwargs, &block) | ||
| if defined?(Prosopite) && Prosopite.enabled? | ||
| Prosopite.pause { original_create(*args, **kwargs, &block) } | ||
| else | ||
| original_create(*args, **kwargs, &block) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| config.around do |example| | ||
| if use_prosopite?(example) | ||
| Prosopite.scan { example.run } | ||
| else | ||
| original_enabled = Prosopite.enabled? | ||
| Prosopite.enabled = false | ||
| begin | ||
| example.run | ||
| ensure | ||
| Prosopite.enabled = original_enabled | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def use_prosopite?(example) | ||
| # Explicit metadata takes precedence | ||
| return false if example.metadata[:disable_prosopite] | ||
| return true if example.metadata[:enable_prosopite] | ||
|
|
||
| # Check against ignore list | ||
| PROSOPITE_IGNORE.none? do |path| | ||
| File.fnmatch?("./#{path}/*", example.metadata[:rerun_file_path].to_s) | ||
| end | ||
|
Comment on lines
+64
to
+72
|
||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changing this group to include
:productionwill also bundle/require a large set of dev/test-only gems (e.g.,rspec-rails,factory_bot_rails, linters) in production, increasing boot time/memory and potentially introducing production-only dependency conflicts. Keep the group as:development, :testand, if Prosopite truly needs to be available in production, place onlyprosopitein its own appropriate group (or mark itrequire: false) instead of pulling the entire dev/test toolchain into production.