Skip to content

Fix N+1 query on event image attachments in FormResponsePresenter - #11889

Merged
nbudin merged 1 commit into
mainfrom
fix-form-response-presenter-image-n-plus-one
Aug 7, 2026
Merged

Fix N+1 query on event image attachments in FormResponsePresenter#11889
nbudin merged 1 commit into
mainfrom
fix-form-response-presenter-image-n-plus-one

Conversation

@nbudin

@nbudin nbudin commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

After #11883 deployed (fixing the active_storage_attachments N+1 in EventDrop's markdown fields), Sentry surfaced a new occurrence of the same query shape (INTERCODE-18B/18D) under Event.team_members/Event.registration_policy. Same underlying association (Event#images), different call site.

Root cause

FormResponsePresenter#local_images (used to resolve inline image references when rendering markdown-formatted form fields like description/short_blurb via the formResponseAttrsJsonWithRenderedMarkdown GraphQL field) already memoizes correctly within one presenter instance -- but a fresh presenter gets constructed per Event when this field is requested for multiple events in one GraphQL query (e.g. a page listing several events each requesting this field), and each one called response.images.includes(:blob) independently with no cross-event batching.

Fix

FormResponsePresenter already receives an optional dataloader: (only ever passed by the GraphQL-facing FormResponseAttrsFields concern -- Liquid/service callers don't have one). When present, route local_images through Sources::ActiveStorageAttachment -- the same dataloader source EventType#images already uses -- so all events resolving the field in one request get batched into a single preload. Falls back to the direct query when there's no dataloader (Liquid rendering, mutations).

Also investigated, not fixed

INTERCODE-18A (registration_policy_buckets by id) is a straggler from before the UserConProfileDrop fix deployed (release tag confirms it predates #11882) -- resolving it in Sentry as already fixed, not a new bug.

INTERCODE-18C (cms_partials N+1, same shape as the earlier INTERCODE-182) traces to Cadmus::PartialFileSystem#read_template_file (in the third-party cadmus gem) doing an uncached find_by! on every {% render "name" %} Liquid tag. The app already builds a cached_partials preload hash for exactly this (CmsRenderingContext#preload_page_content), but Cadmus's file system never consults it -- and the cache stores parsed Liquid::Template objects while the file-system contract needs raw content strings, so it's not a drop-in fix. Given the low/ambiguous signal (single occurrences, no "repeated N times" breakdown from Sentry -- could just be two different partials on one page) and the blast radius of changing the global Liquid rendering pipeline used by every convention site, deliberately leaving this one alone rather than rushing a fix to a third-party integration point.

Test plan

  • New test (test/graphql/types/event_type_test.rb) executing a real GraphQL query for 5 events, verified to fail without the fix (5 queries) and pass with it (1 query)
  • Full test/liquid_drops/, test/services/event_signup_service_test.rb, test/services/execute_ranked_choice_signup_service_test.rb, test/models/run_test.rb, test/presenters/signup_count_presenter_test.rb (98 tests)
  • rubocop / stree clean (pre-existing Metrics/ParameterLists offense on this file's 7-arg constructor added to .rubocop_todo.yml, matching existing entries for other presenters/services with the same debt -- not something this fix should refactor)

🤖 Generated with Claude Code

Sentry showed an active_storage_attachments N+1 (INTERCODE-18B/18D)
appearing after the EventDrop fix in #11883 -- turned out to be a
sibling gap in the same "images" association, in a different presenter.

FormResponsePresenter#local_images already memoized per instance
correctly, but a fresh presenter is constructed per Event when the
form_response_attrs_json_with_rendered_markdown GraphQL field (used to
render markdown-formatted form fields like description/short_blurb) is
requested for multiple events in one query, and each one queried
images.includes(:blob) independently. Route it through the same
Sources::ActiveStorageAttachment dataloader source EventType#images
already uses, when a dataloader is available (GraphQL context) --
falls back to the direct query for the Liquid/service call sites that
don't have one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/graphql/sources/active_storage_attachment.rb 🔴 0% 🟢 100% 🟢 100%
app/graphql/types/convention_type.rb 🟢 79.44% 🟢 81.18% 🟢 1.74%
app/presenters/form_response_presenter.rb 🟢 89.8% 🟢 88.46% 🔴 -1.34%
app/presenters/tables/events_table_results_presenter.rb 🔴 0% 🟠 57.63% 🟢 57.63%
test/graphql/types/event_type_test.rb 🔴 0% 🟢 100% 🟢 100%
Overall Coverage 🟢 55.94% 🟢 55.99% 🟢 0.05%

Minimum allowed coverage is 0%, this run produced 55.99%

@nbudin
nbudin merged commit 92168a1 into main Aug 7, 2026
25 checks passed
@nbudin
nbudin deleted the fix-form-response-presenter-image-n-plus-one branch August 7, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant