Skip to content

Commit 5ec0994

Browse files
aaronlippoldwdower
andauthored
fix: exclude xml/binary blobs from with_severity_counts (prod crash) (#713)
* fix: exclude xml/binary blobs from with_severity_counts The with_severity_counts scope used select("table.*") which loaded ALL columns including multi-MB xml blobs on Stig and SRG models. On index pages this blew Heroku dyno memory (R14/R15) and caused 30s timeouts (H12), crashing the app. Fix: auto-detect columns of type xml/binary and exclude them from the SELECT in with_severity_counts. This is a DRY fix at the concern level — all models (Stig, SRG, Component) benefit without per-controller workarounds. Models without heavy columns are unaffected (all columns loaded as before). The xml column is still loaded via Stig.find(id) for export/download endpoints that need it. Authored by: Aaron Lippold<lippold@gmail.com> * fix: address Copilot review — use abstract column type, update docs, dynamic test assertions - Use c.type (ActiveRecord abstract) instead of c.sql_type (adapter- specific) for heavy column detection. Catches Postgres bytea, MySQL blob, etc. - Update concern header docs to reflect auto-generated scope (was stale "define your own" instruction) - Spec column assertions now dynamically check all non-blob columns instead of hardcoded subset Signed-off-by: Will Dower <will@dower.dev> --------- Signed-off-by: Will Dower <will@dower.dev> Co-authored-by: Will Dower <will@dower.dev>
1 parent 730853c commit 5ec0994

File tree

2 files changed

+83
-7
lines changed

2 files changed

+83
-7
lines changed

app/models/concerns/severity_counts.rb

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@
44
# SeverityCounts - Shared concern for models with severity-categorized rules
55
#
66
# Provides severity aggregation methods for Component, STIG, and SRG models.
7-
# Each including model must define its own `with_severity_counts` scope
8-
# due to model-specific SQL conditions.
7+
# Auto-generates a `with_severity_counts` scope based on model type.
8+
# Heavy columns (xml, binary) are excluded from the SELECT to prevent
9+
# memory blowout on index pages.
910
#
1011
# Usage:
1112
# class Component < ApplicationRecord
1213
# include SeverityCounts
13-
#
14-
# scope :with_severity_counts, -> { ... } # Model-specific SQL
15-
# def rules_association; rules; end # Define association to query
14+
# def rules_association; rules; end # Define association to query
1615
# end
1716
#
1817
module SeverityCounts
@@ -29,11 +28,22 @@ def as_json(options = {})
2928
super.merge({ severity_counts: severity_counts_hash })
3029
end
3130

31+
# Column types that are never needed on index/list pages and can be
32+
# multi-MB per row. Excluding them from `with_severity_counts` prevents
33+
# Heroku dyno memory blowout (R14/R15) on endpoints like GET /stigs.
34+
# Models without these column types are unaffected (all columns loaded).
35+
# Uses ActiveRecord's abstract `type` (not adapter-specific `sql_type`)
36+
# so it works across databases (Postgres xml/bytea, MySQL blob, etc.).
37+
HEAVY_COLUMN_TYPES = %i[xml binary].freeze
38+
3239
included do
3340
##
3441
# Auto-generate with_severity_counts scope based on model type
3542
#
36-
# Detects model type and creates appropriate SQL subqueries
43+
# Detects model type and creates appropriate SQL subqueries.
44+
# Automatically excludes heavy columns (xml, binary) from the SELECT
45+
# to prevent memory blowout on index pages. Use `Stig.find(id)` (no
46+
# scope) when you need the full record including xml for export/download.
3747
scope :with_severity_counts, lambda {
3848
table = table_name
3949
rule_type = "#{name.gsub('SecurityRequirementsGuide', 'Srg')}Rule" # Component->ComponentRule, Stig->StigRule, SRG->SrgRule
@@ -54,8 +64,14 @@ def as_json(options = {})
5464
# Add type filter for STI (not needed for Component since it uses component_id)
5565
type_condition = name == 'Component' ? '' : "AND base_rules.type = '#{rule_type}' "
5666

67+
# Select only lightweight columns — exclude xml/binary blobs that can be
68+
# multi-MB per row. This is the DRY fix: all models using this concern
69+
# automatically benefit without per-controller workarounds.
70+
lightweight_cols = columns.reject { |c| HEAVY_COLUMN_TYPES.include?(c.type) }
71+
.map { |c| "#{table}.#{c.name}" }
72+
5773
select(
58-
"#{table}.*",
74+
*lightweight_cols,
5975
"(SELECT COUNT(*) FROM base_rules WHERE #{fk_condition} #{type_condition}" \
6076
"AND base_rules.rule_severity = 'high') AS severity_high_count",
6177
"(SELECT COUNT(*) FROM base_rules WHERE #{fk_condition} #{type_condition}" \

spec/models/concerns/severity_counts_spec.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,64 @@ def actual_counts(record)
153153
expect(json[:severity_counts]).to eq(expected)
154154
end
155155
end
156+
157+
describe 'heavy column exclusion' do
158+
# REQUIREMENT: with_severity_counts must NOT load xml/binary columns.
159+
# These columns store multi-MB STIG/SRG XML documents. Loading them on
160+
# index pages blows Heroku dyno memory (R14/R15) and causes 30s timeouts (H12).
161+
# The xml column is only needed for export/download, not for listing.
162+
163+
it 'excludes xml columns from STIG queries' do
164+
stig = create(:stig)
165+
loaded = Stig.with_severity_counts.find(stig.id)
166+
167+
# The record should NOT have the xml attribute loaded
168+
expect(loaded.has_attribute?(:xml)).to be false
169+
end
170+
171+
it 'excludes xml columns from SRG queries' do
172+
loaded = SecurityRequirementsGuide.with_severity_counts.find(srg.id)
173+
174+
expect(loaded.has_attribute?(:xml)).to be false
175+
end
176+
177+
it 'still includes all non-blob columns for STIGs' do
178+
stig = create(:stig)
179+
loaded = Stig.with_severity_counts.find(stig.id)
180+
181+
expected_cols = Stig.columns.reject { |c| SeverityCounts::HEAVY_COLUMN_TYPES.include?(c.type) }
182+
.map(&:name)
183+
expected_cols.each do |col|
184+
expect(loaded.has_attribute?(col)).to be(true), "Expected #{col} to be loaded"
185+
end
186+
end
187+
188+
it 'still includes all non-blob columns for SRGs' do
189+
loaded = SecurityRequirementsGuide.with_severity_counts.find(srg.id)
190+
191+
expected_cols = SecurityRequirementsGuide.columns
192+
.reject { |c| SeverityCounts::HEAVY_COLUMN_TYPES.include?(c.type) }
193+
.map(&:name)
194+
expected_cols.each do |col|
195+
expect(loaded.has_attribute?(col)).to be(true), "Expected #{col} to be loaded"
196+
end
197+
end
198+
199+
it 'does not affect models without heavy columns (Component)' do
200+
loaded = Component.with_severity_counts.find(component.id)
201+
202+
# Component has no xml column — all columns should be present
203+
expect(loaded.has_attribute?(:id)).to be true
204+
expect(loaded.has_attribute?(:name)).to be true
205+
expect(loaded.has_attribute?(:description)).to be true
206+
end
207+
208+
it 'xml is still loadable via explicit query (for export/download)' do
209+
stig = create(:stig)
210+
211+
# Direct find without scope — loads everything including xml
212+
full_stig = Stig.find(stig.id)
213+
expect(full_stig.has_attribute?(:xml)).to be true
214+
end
215+
end
156216
end

0 commit comments

Comments
 (0)