Summary
TranslationSetsController#edit (and #new) renders one hidden <select> of components for every participatory space in the organization, for every constraint in the set. On an organization with ~530 participatory spaces this makes the page take 16-22s when it succeeds, and time out with a 500 the rest of the time.
Impact
Production APM data for a single translation set with 2 constraints:
| Metric |
Value |
| Successful loads |
16.0s, 17.0s, 18.1s, 20.1s, 21.6s |
| Failed loads (HTTP 500) |
27.7s, 28.1s, 28.4s, 28.6s, 34.7s |
| Exception on failure |
Rack::Timeout::RequestTimeoutException (SIGTERM at 25000ms) |
| Time spent in view rendering |
19.87s out of 20.07s total |
| Time spent in SQL |
3.2s |
Decidim::Component Load queries in one request |
889 |
| Object allocations in one request |
4,814,003 |
The _constraint_fields partial was rendered 3 times (1 hidden <template> + 2 constraints), taking 6.5s, 5.9s and 7.1s respectively. The resulting HTML contains roughly 1,600 <select> elements.
The page is therefore unusable in practice: adding a third constraint to the set puts every single load past the timeout.
Root cause
app/views/decidim/term_customizer/admin/translation_sets/_constraint_fields.html.erb#L34-L55:
<% subject_manifests.each do |manifest| %>
<% models = manifest.model_class_name.constantize.where(organization: current_organization) %>
...
<% models.each do |model| %>
...
<% if model.respond_to?(:components) %>
<%= component_fields.select :component_id, model.components.map { |c| [translated_attribute(c.name), c.id] }, selected: selected_component, include_blank: true %>
Three separate problems:
- Every space gets a pre-rendered component select. Only the selected space's select is ever visible;
constraint_fields.js shows/hides .component-container by data-components. The markup for the other ~529 spaces is dead weight in the response.
model.components is an N+1, one query per space, per constraint block. Hence the 889 Decidim::Component Load queries in a single request.
- The space lists are unscoped, so past, unpublished and archived spaces are all loaded and rendered.
The stack trace of the timed-out requests ends exactly at line 49 of that partial.
Secondarily, TranslationSetsController#subject_manifests (app/controllers/decidim/term_customizer/admin/translation_sets_controller.rb#L119-L126) loads every space of every manifest and maps all their translated titles, only to discard the array and test count.positive?:
models = manifest.model_class_name.constantize.where(organization: current_organization).map { |p| [translated_attribute(p.title), p.id] }
next unless models.count.positive?
An exists? would do the same job.
Steps to reproduce
- Have an organization with several hundred participatory spaces.
- Create a translation set with 2 or more constraints.
- Open
/admin/term_customizer/sets/:id/edit.
Response time scales as (number of constraints + 1) × (number of spaces in the organization), so it degrades every time a new space or initiative is created.
Suggested fix
Load the component options on demand instead of pre-rendering them:
- Render only the space selects, and fetch the components of the chosen space over AJAX (a small admin endpoint returning the components for a given participatory space). This makes the page O(1) in the number of spaces.
- If a full rewrite is too much for now, an interim mitigation would be to hoist
models out of the per-constraint loop and replace the per-space model.components with a single grouped query. That removes the ~889 queries, but roughly 1,600 select elements still have to be built, so it is a partial mitigation only.
- Scope the space lists (published, not archived) and replace the
subject_manifests full load with exists?.
Summary
TranslationSetsController#edit(and#new) renders one hidden<select>of components for every participatory space in the organization, for every constraint in the set. On an organization with ~530 participatory spaces this makes the page take 16-22s when it succeeds, and time out with a 500 the rest of the time.Impact
Production APM data for a single translation set with 2 constraints:
Rack::Timeout::RequestTimeoutException(SIGTERM at 25000ms)Decidim::Component Loadqueries in one requestThe
_constraint_fieldspartial was rendered 3 times (1 hidden<template>+ 2 constraints), taking 6.5s, 5.9s and 7.1s respectively. The resulting HTML contains roughly 1,600<select>elements.The page is therefore unusable in practice: adding a third constraint to the set puts every single load past the timeout.
Root cause
app/views/decidim/term_customizer/admin/translation_sets/_constraint_fields.html.erb#L34-L55:Three separate problems:
constraint_fields.jsshows/hides.component-containerbydata-components. The markup for the other ~529 spaces is dead weight in the response.model.componentsis an N+1, one query per space, per constraint block. Hence the 889Decidim::Component Loadqueries in a single request.The stack trace of the timed-out requests ends exactly at line 49 of that partial.
Secondarily,
TranslationSetsController#subject_manifests(app/controllers/decidim/term_customizer/admin/translation_sets_controller.rb#L119-L126) loads every space of every manifest and maps all their translated titles, only to discard the array and testcount.positive?:An
exists?would do the same job.Steps to reproduce
/admin/term_customizer/sets/:id/edit.Response time scales as
(number of constraints + 1) × (number of spaces in the organization), so it degrades every time a new space or initiative is created.Suggested fix
Load the component options on demand instead of pre-rendering them:
modelsout of the per-constraint loop and replace the per-spacemodel.componentswith a single grouped query. That removes the ~889 queries, but roughly 1,600 select elements still have to be built, so it is a partial mitigation only.subject_manifestsfull load withexists?.