7101 - Fix Prosopite N+1 harness and clear datatable + request specs - #7102
Open
suttondemlong wants to merge 3 commits into
Open
7101 - Fix Prosopite N+1 harness and clear datatable + request specs#7102suttondemlong wants to merge 3 commits into
suttondemlong wants to merge 3 commits into
Conversation
spec/.prosopite_ignore documented itself as "scanned but won't raise, only log", but ignored examples ran with Prosopite.enabled = false, so they were not scanned at all and nothing was logged either. Since every spec directory was listed, no N+1 was being detected anywhere. Raising is now opted into per example via Prosopite.start_raise, so an ignored directory genuinely logs to log/prosopite.log without failing the build. Two further gaps kept enforcement from being usable once a directory was un-ignored: - The factory pause patched FactoryBot::SyntaxRunner, but RSpec includes FactoryBot::Syntax::Methods into example groups, so `create` called from a spec or a let block never went through it and every created record's uniqueness check looked like an N+1. Patch the module that is included. - Prosopite reports every repeated query in an example, including ones the spec itself causes by looping over records to build an expectation. Those are not application N+1s, so enforcement now fails an example only when the call stack reaches app/ or lib/. Also allowlist per-record validations and has_one initialisers, plus the operations that write one record at a time by design (CSV import, org defaults, bulk supervisor assignment): each runs one INSERT and its belongs_to checks per row, so there is no collection to eager load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… forms VolunteerDatatable ran three queries per row. made_contact_with_all_cases_in_days? and hours_spent_in_days are aggregates that cannot be preloaded, so compute them once per page as grouped queries. The contacted-case count is deliberately not DISTINCT, matching Volunteer#cases_where_contact_made_in_days, which counts contact rows rather than cases -- preserved so this is a performance change only. Languages are preloaded against the loaded page rather than through raw_records: filtered_records can carry SELECT aliases, DISTINCT and an ORDER BY on an alias, and an includes there makes Rails build an id-lookup query that repeats the alias in its own SELECT list. CasaCase#next_court_date and #most_recent_past_court_date are scoped queries on the court_dates association, so they re-ran per case and silently defeated any includes(:court_dates). They now filter in Ruby when the association is loaded, which also fixes the missing-data report. The rest are missing eager loads on collections that are rendered per row: - case group form: assigned volunteers for every case in the org - court report case picker: assigned volunteers and court dates per case - Supervisor#volunteers: :supervisor as well as the join row, since Volunteer#supervisor is its own has_one :through - notifications: patch note types - all-CASA dashboard: per-org user and case contact counts, batched - contact type options: one "last logged" lookup instead of one per type - contact form: contact_topic_answers' topics, so nested saves validate belongs_to from memory - new contacts table: casa_org and creator_casa_org for the row policy - CaseCourtReportContext: interviewees' contact types and case contacts, and the last hearing date resolved once instead of per caller Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both directories are now free of application N+1s, so drop them from the ignore list. Remaining directories stay log-only and can be enabled the same way as they are cleaned up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
|
Looks very promising :) |
Author
Thanks! Sean asked me to use the resources from the DataAnnotation thing to do some work in here. Once I can verify and add screenshots I’ll mark as ready for review! |
suttondemlong
marked this pull request as ready for review
August 7, 2026 22:13
suttondemlong
requested review from
FireLemons,
compwron and
elasticspoon
as code owners
August 7, 2026 22:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What github issue is this PR for, if any?
Resolves #7101
What changed, and why?
Part 1 of the Prosopite work: make N+1 detection actually run, then clear
spec/datatablesandspec/requestsand start enforcing them. Three commits, each green on its own so the history bisects.1.
30f5d4b— fix the harness.spec/.prosopite_ignoredocumented itself as "scanned but won't raise, only log", but ignored examples ran withProsopite.enabled = false, so they were not scanned and nothing was logged either. Since every spec directory was listed, nothing was detected anywhere. Raising is now opted into per example viaProsopite.start_raise, so an ignored directory genuinely logs tolog/prosopite.logwithout failing the build.Two further gaps stopped enforcement from being usable:
FactoryBot::SyntaxRunner, butspec/support/factory_bot.rbincludesFactoryBot::Syntax::Methodsinto example groups, socreatefrom a spec orletblock never went through it. Patched the module that is actually included — this alone removed 8 false-positive failures inspec/datatables.app/orlib/. (Rails' backtrace cleaner already silencesspec/, so "no app frame" means it came from test code.) I verified each residual failure with a widened cleaner rather than assuming — 7 of 9 were spec-side, e.g.volunteer_datatable_spec.rb:270.Also allowlisted per-record validations/
has_oneinitialisers and the operations that write one record at a time by design (CSV import, org defaults, bulk supervisor assignment, copying a draft contact per case). Each runs one INSERT plus that row'sbelongs_tochecks, so there is no collection to eager load.2.
e657527— the N+1 fixes.VolunteerDatatableran three queries per row.made_contact_with_all_cases_in_days?andhours_spent_in_daysare aggregates that cannot be preloaded, so they are computed once per page as grouped queries. Before changing them I probed the existing semantics and preserved them exactly, including one surprise: the contacted-case count is deliberately notDISTINCT, becauseVolunteer#cases_where_contact_made_in_dayscounts contact rows rather than cases. Keeping that makes this a performance change only (see the out-of-scope note below).Languages are preloaded against the already-loaded page rather than through
raw_records. Addingincludes(:languages)there breaks theextra_languagesfilter withPG::UndefinedColumnondefault_sort_order: that relation carries SELECT aliases,DISTINCTand anORDER BYon an alias, and anincludesmakes Rails build an id-lookup query that repeats the alias in its own SELECT list. This is why the existingindex_relationscopes its ownincludes(:languages)the way it does.CasaCase#next_court_dateand#most_recent_past_court_datewere scoped queries on thecourt_datesassociation, so they re-ran per case and silently defeated anyincludes(:court_dates). They now filter in Ruby when the association is loaded, which also fixes the missing-data report.The remainder are missing eager loads on collections rendered per row: the case group form (assigned volunteers for every case in the org), the court report case picker,
Supervisor#volunteers(added:supervisor—Volunteer#supervisoris its ownhas_one :through, so preloading the join row does not satisfy it), notifications' patch note types, the all-CASA dashboard's per-org counts (batched, verified against the per-org methods including the cross-org edge case), the contact type "last logged" hint (one lookup instead of one per type), the contact form'scontact_topic_answerstopics so nested saves validatebelongs_tofrom memory, the new contacts table's row policy associations, andCaseCourtReportContext(interviewees' contact types and case contacts, plus resolving the last hearing date once instead of per caller).3.
404825c— dropspec/datatablesandspec/requestsfrom the ignore list.One decision worth a reviewer's opinion
The intentional per-record write loops are declared in
spec/support/prosopite.rbviaallow_stack_paths, to keep test tooling out of application code.PROSOPITE_TODO.mdsuggests wrapping them inProsopite.pauseinstead, which would put the intent at the call site and also quiet the development rack middleware. Happy to switch if you prefer that.How is this tested? (please write rspec and jest tests!) 💖💪
The enforcement itself is the test: with these two directories un-ignored, any new N+1 in a datatable or request path fails the build. Baseline before the fixes was 25 failures in
spec/datatablesand 44 inspec/requests, all Prosopite.Each commit verified green individually, not just the tip.
Semantics were checked rather than assumed: for the three aggregates I replaced, and for the batched all-CASA counts, I compared the new grouped queries against the original per-record methods on fixtures covering the awkward cases (a volunteer with more contacts than cases, an inactive assigned case, an org-less draft contact, a contact on one org's case created by another org's volunteer). No existing expectations changed, and no new ones were needed — the values are identical by construction.
Not covered:
spec/systemand the other still-ignored directories remain log-only, so N+1s there are written tolog/prosopite.logbut do not fail. Those are the later parts described in #7101.Screenshots please :)
No visual change is intended; the four touched views (
all_casa_admins/dashboard/show,case_groups/_form,case_contacts/form/_contact_types,case_contacts/form/details) only change which records are preloaded and where a value is read from, not the markup or the rendered values.Follow-up
Not in this PR:
app/views/volunteers/index.html.erb:107callshours_spent_in_days(30)inside the row loop — the same N+1 as the datatable, still live on the migrated index. The current specs do not catch it because the fixtures are too small to crossmin_n_queries. Fixing it means lifting the batching helpers out of the datatable into shared code.User#no_attempt_for_two_weeksand#volunteers_serving_transition_aged_youthhave no callers left after the Tailwind migration removedSupervisorDatatable; they look like dead code now.