jpie 3.7.0 → 3.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,102 +1,461 @@
1
- # jpie Performance Baseline
2
-
3
- This document outlines the performance metrics for key operations within the `jpie` gem, comparing the initial baseline with the results after implementing all optimizations.
4
-
5
- ## Performance Comparison
6
-
7
- | Metric | Original Baseline | After Optimizations | Improvement |
8
- |--------|-------------------|---------------------|-------------|
9
- | `resource_loader_find_avg_ms` | 0.0182 | 0.0003 | **60x faster** |
10
- | `resource_loader_find_for_model_avg_ms` | 0.036 | 0.0003 | **120x faster** |
11
- | `jsonapi_object_avg_ms` | 0.0003 | 0.0001 | **3x faster** |
12
- | `serialize_single_user_avg_ms` | 0.2168 | 0.0982 | **2.2x faster** |
13
- | `serialize_10_users_avg_ms` | 1.4318 | 0.975 | **1.5x faster** |
14
- | `serialize_single_user_query_count` | 4 | 4 | N/A |
15
- | `serialize_10_users_query_count` | 40 | 40 | N/A |
16
- | `serialize_single_user_allocations` | 997 | 977 | **2% reduction** |
17
- | `serialize_10_users_allocations` | 9934 | 9716 | **2% reduction** |
18
- | `relationship_definitions_avg_ms` | 0.0036 | 0.0002 | **18x faster** |
19
- | `resource_instantiation_avg_ms` | 0.0003 | 0.0004 | Similar |
20
-
21
- ## Implemented Optimizations
22
-
23
- ### 1. ResourceLoader Caching (Phase 4)
24
- - Thread-safe caching for `find` and `find_for_model` methods
25
- - Eliminates repeated `constantize` calls
26
- - **60-120x improvement** in resource class lookups
27
-
28
- ### 2. jsonapi_object Memoization (Phase 5)
29
- - Memoized the static JSON:API object
30
- - Returns frozen object to prevent mutations
31
- - **3x improvement** in object generation
32
-
33
- ### 3. Relationship Definitions Caching (Phase 6)
34
- - Memoized `relationship_definitions` computation
35
- - Returns frozen array to prevent mutations
36
- - **18x improvement** in relationship metadata access
37
-
38
- ### 4. Optional Count Query (Phase 7)
39
- - `total_count` only computed when pagination is applied
40
- - Avoids unnecessary COUNT queries for non-paginated requests
41
- - Reduces database load
42
-
43
- ### 5. Eager Loading DSL (Phase 8)
44
- - New `eager_load` class method for resources
45
- - Automatically included in preloading without explicit `include` param
46
- - Helps eliminate N+1 queries at the resource level
47
-
48
- ### 6. Preload for Serialization Hook (Phase 9)
49
- - New `preload_for_serialization` class method
50
- - Called before serialization with all records
51
- - Enables batch-loading of data needed by `meta` methods
52
- - Thread-local storage for preloaded data
53
-
54
- ## Usage Examples
55
-
56
- ### Eager Loading DSL
57
-
58
- ```ruby
59
- class WorkstreamResource < ApplicationResource
60
- eager_load :conversation, :owners
61
-
62
- # These associations will be automatically eager-loaded
63
- # even without an explicit ?include= param
64
- end
65
- ```
1
+ # jpie performance baseline: N+1 queries and memory bloat
2
+
3
+ This document records the measured baseline for the request-level performance
4
+ problem: controller actions with large `include=` trees fire hundreds to
5
+ thousands of SQL queries and allocate hundreds of MB per request. Production
6
+ examples: `/workstreams` with 25 include paths (~300 queries at page 10, up to
7
+ `page[size]=100`), `/policies` with 17 include paths at `page[size]=1` (kills
8
+ the app on AI-conversation data; one index request allocated ~600MB),
9
+ `/evidence_checks` with a 45-id batch filter and files at depth 3 (~1,250
10
+ queries), `/incidents` with 18 include paths at page 20 (577 ActiveRecord
11
+ calls, 2,794ms), and `/suggested_risk_scenarios` with only two include paths
12
+ (541 ActiveRecord calls, 2,059ms). The last two fail for opposite reasons; see
13
+ "Two shapes, two causes" below.
14
+
15
+ The numbers come from the benchmark suite in `spec/benchmarks/`. The evaluation
16
+ behind them ran 32 independent code-path analyses; a second wave verified the
17
+ top findings empirically with probe specs. Every mechanism below carries a
18
+ measured probe result, not an estimate.
66
19
 
67
- ### Preload for Serialization Hook
68
-
69
- ```ruby
70
- class WorkstreamResource < ApplicationResource
71
- def self.preload_for_serialization(records, context = {})
72
- # Batch-load stats for all records
73
- stats = Preloaders::StatsPreloader.call(records: records)
74
- stats # Store in preloaded_data for access in meta
75
- end
76
-
77
- def meta(...)
78
- stats = self.class.preloaded_data[record.id] || record.loading_stats
79
- super.merge(loading: stats)
80
- end
81
- end
20
+ ## How to run
21
+
22
+ ```bash
23
+ rake benchmark
82
24
  ```
83
25
 
84
- ## Metric Interpretation
85
-
86
- - **`resource_loader_find_avg_ms`**: Average time to find a resource class by type
87
- - **`resource_loader_find_for_model_avg_ms`**: Average time to find a resource class by model
88
- - **`jsonapi_object_avg_ms`**: Average time to generate the static `jsonapi` object
89
- - **`serialize_single_user_avg_ms`**: Average time to serialize a single `User` record
90
- - **`serialize_10_users_avg_ms`**: Average time to serialize a collection of 10 `User` records
91
- - **`serialize_single_user_query_count`**: Number of SQL queries for single `User` serialization
92
- - **`serialize_10_users_query_count`**: Number of SQL queries for 10 `User` records serialization
93
- - **`serialize_single_user_allocations`**: Object allocations for single `User` serialization
94
- - **`serialize_10_users_allocations`**: Object allocations for 10 `User` records serialization
95
- - **`relationship_definitions_avg_ms`**: Average time to compute relationship definitions
96
- - **`resource_instantiation_avg_ms`**: Average time to instantiate a resource
97
-
98
- ## Notes
99
-
100
- - Query counts remain the same in benchmarks as they test the serializer directly
101
- - Real-world N+1 reduction comes from the `eager_load` DSL and `preload_for_serialization` hook
102
- - Allocation reduction is modest but consistent across serialization
26
+ This runs `spec/benchmarks/` with `PERFORMANCE=1`, prints a results table, and
27
+ writes `tmp/request_benchmark_results.json` (per-scenario query counts, query
28
+ shapes, wall time, allocated/retained bytes, allocation sites, RSS). Diff that
29
+ file against this baseline after any optimization. `DEBUG_QUERIES=1` also dumps
30
+ raw SQL logs to `tmp/benchmarks/query_logs/`.
31
+
32
+ The suite seeds a stand-in domain (`Assignment`, `Gadget`, `Fault`, `Edition`,
33
+ `Remark`, and others) that copies the production include-tree topology: wide +
34
+ deep trees, polymorphic hops, shared prefixes, STI-free but otherwise faithful.
35
+ The `assignments_full` tree mirrors the production `/workstreams` request
36
+ path-for-path; `assignments_single_conversation` mirrors the `/policies` killer
37
+ (fat document bodies, a 600-remark conversation, ActiveStorage files, per-remark
38
+ children); `gadgets_evidence_shape` mirrors `/evidence_checks` (a 45-id `id_in`
39
+ batch, two branches converging on ActiveStorage files, polymorphic leaves at
40
+ depth 2-3); `faults_index` mirrors `/incidents` (link joins to depth-3 gadget
41
+ and appliance targets whose resources carry resource-default preloads and
42
+ per-record meta, plus a full discussion thread); `suggestions_polymorphic`
43
+ mirrors `/suggested_risk_scenarios` (two depth-1 paths, one of them
44
+ polymorphic, onto target resources that under-declare their preloads).
45
+
46
+ ## Baseline (large dataset, sqlite, 2026-08-14)
47
+
48
+ The seeds sit above the worst production reports on purpose: a 600-remark AI
49
+ conversation, 40 editions with ~64KB document bodies, ~4KB remark bodies,
50
+ fanout 10 on subject inspections plus link-owned inspections. The heaviest
51
+ scenarios overshoot the observed ceilings (600MB allocated, 1,250 queries) so
52
+ the bottleneck curves stay visible past them.
53
+
54
+ | Scenario | Primary | Included | Queries | ms (median) | Alloc MB | Retained MB |
55
+ |---|---|---|---|---|---|---|
56
+ | assignments_full (page 10, 25 paths) | 10 | 2,701 | 782 | 1,499 | 133.9 | 14.1 |
57
+ | assignments_full + authz hook | 10 | 2,701 | 3,403 | 2,200 | 168.2 | 14.6 |
58
+ | assignments_single_conversation (page 1, 17 paths) | 1 | 2,191 | 1,728 | 1,376 | 147.4 | 24.6 |
59
+ | assignments_single_conversation + authz hook | 1 | 2,191 | 4,029 | 2,079 | 176.8 | 25.0 |
60
+ | gadgets_evidence_shape (45-id batch, 9 paths) | 45 | 1,656 | 2,550 | 1,386 | 132.3 | 8.1 |
61
+ | assignments page 1 / 25 / 50 / 100 (fixed tree) | 1-100 | 1,317-16,382 | 345/1,502/2,702/5,102 | 634-11,148 | 60.0/257.2/466.9/884.5 | 6.9-102.1 |
62
+ | assignments tree depth 1 / 2 / full (fixed page 10) | 10 | 67/1,470/2,701 | 129/1,294/782 | 66/639/1,499 | 8.4/64.5/133.9 | 1.7/13.7/14.1 |
63
+ | users_deep_include (control: clean resources) | 20 | 400 | 3 | 97 | 12.0 | 1.6 |
64
+
65
+ The reproduction exceeds every production report. Queries: 782 at the
66
+ `/workstreams` page-10 shape, 2,550 at the `/evidence_checks` shape (vs 1,250
67
+ observed), 1,728 for ONE primary record at the `/policies` shape, 5,102 at
68
+ page 100. Memory: 884.5MB allocated at page 100 (vs ~600MB observed), 133.9MB
69
+ at page 10. The authorization-hook variants add a narrowing Pundit-style scope
70
+ on every model; production sits between the plain and authz numbers because
71
+ only some models carry narrowing scopes. The control scenario shows the same
72
+ serializer doing 400 included records in 3 queries when no mechanism below is
73
+ active — the domain shape alone is not the problem.
74
+
75
+ ## Verified mechanisms
76
+
77
+ ### N+1 query drivers
78
+
79
+ 1. **Per-visit authorization re-check — FIXED** (with mechanism 2, see
80
+ "Optimizations landed"). The include walk runs the
81
+ `authorization_scope` hook once per parent record, per hop, per include path.
82
+ When the hook changes the scope's SQL, each visit fires one filter query.
83
+ Probe: 10 posts, `include=comments,comments.author`: 3 queries without a
84
+ hook, 73 with a narrowing hook. No memoization: 10 distinct users were
85
+ re-authorized 50 times. Shared prefixes multiply visits (each path re-visits
86
+ its whole prefix). Scaling: queries ≈ parents × path-visits.
87
+ Anchors: `include_filtering.rb` (`filter_loaded_records`, `narrowed?`),
88
+ `includes_serialization.rb`.
89
+ Baseline signature: 1,170× single-row authorization plucks in
90
+ `assignments_full_authz`.
91
+
92
+ 2. **Narrowing resource-default scopes re-check per visit — FIXED** (same
93
+ change as mechanism 1). A resource whose
94
+ `records` narrows the model scope (production: `MessageResource.records =
95
+ super.client_visible`) triggers the same per-visit re-check without any
96
+ authorization hook.
97
+ Baseline signature: 570× `SELECT remarks.id WHERE body IS NOT NULL AND id = ?`
98
+ in `assignments_full`; 3,270× at page 100.
99
+
100
+ 3. **ActiveStorage includes never preload — FIXED.** `filter_includable` kept
101
+ only include keys with an AR reflection; `has_many_attached :files` defines
102
+ `files_attachments`, not `files`, so the key dropped silently. Serialization
103
+ then probed attachments per record (2N+1 queries; 2,340 of the 2,550
104
+ queries in `gadgets_evidence_shape`). The fix maps the attachment key to
105
+ `{files_attachments: {blob: {}}}` in the preload hash and reads blobs
106
+ through the loaded associations. See "Optimizations landed" below.
107
+
108
+ 4. **Resource-default preloads reached depth 1 only — FIXED** (see
109
+ "Optimizations landed" #3). Two separate passes load data, and they did not
110
+ cover the same ground:
111
+ - `scope_with_includes` preloads the include tree at **every** depth. It
112
+ builds one nested hash from all paths, so a 4-level path costs 4 queries,
113
+ not 4 per record. Control: `users_deep_include` serializes 400 records in
114
+ 3 queries; `assignments_single_conversation` serializes 2,191 records at
115
+ depth 4 in 22 queries.
116
+ - `preload_included_resource_associations` applies each resource's own
117
+ `records` preloads to include targets. It used to read
118
+ `include_path.split(".").first`, so only **depth-1** targets got them.
119
+
120
+ A resource at depth 2 or deeper therefore serialized with its own
121
+ associations unloaded. Every `meta` method that branches on `loaded?` took
122
+ the fallback branch and queried per record. Production:
123
+ `SelectedControlResource.records` preloads `evidence_checks`,
124
+ `selected_control_checks`, and `framework_criteria`, but `/incidents`
125
+ reaches `SelectedControl` at depth 2 through
126
+ `related_controls.selected_control`, so none of the three apply.
127
+ Anchor: `include_preloading.rb` (`preload_included_resource_associations`).
128
+ Baseline signature: 390 of 415 queries in `faults_index` are per-record;
129
+ 40× draft-edition and 40× published-edition lookups in `assignments_full`.
130
+
131
+ The depth-1 pass itself is sound, including through a polymorphic hop. Probe:
132
+ `GET /assignments?include=subject` (polymorphic to `Gadget`) batches all five
133
+ of `GadgetResource`'s declared preloads into one query each, and the count
134
+ stays flat as records grow. Regression spec:
135
+ `polymorphic_resource_preload_spec.rb`. Read the two facts together before
136
+ blaming jpie for an N+1: at depth 1 a declared preload is applied, so a
137
+ per-record query there means the resource never declared it.
138
+
139
+ 5. **Resources that never declare the preloads their own reads need.** A
140
+ `meta` method or attribute that walks an association costs one query per
141
+ serialized record when the resource's `records` scope does not preload it.
142
+ jpie cannot infer the need; nothing in the include tree names the
143
+ association. This is an application fault, not a library limit, and it is
144
+ the whole cost of a shallow request. Production `/suggested_risk_scenarios`
145
+ sends two depth-1 paths and still runs 541 ActiveRecord calls:
146
+ `RiskScenarioTemplateResource#meta` walks `inherent_risk_template.category`
147
+ with no `records` override (2 queries per template);
148
+ `SelectedControlResource#baseline` reads `control_template`, which its
149
+ preload list omits; `ComplianceScopeableResource` exposes `in_scope` and
150
+ `in_scope_justification`, which probe and then load
151
+ `resource_framework_scopes`, and no resource preloads them.
152
+ Baseline signature: `suggestions_polymorphic` runs one `baseline_estimates`
153
+ and one `estimate_categories` query per serialized repair plan, while every
154
+ association a resource does declare loads in a single batched query.
155
+
156
+ 6. **Virtual sort disables everything.** A virtual (resource-method) sort field
157
+ materializes the whole filtered table as an Array before pagination, and
158
+ `scope_with_includes` bails on Arrays, so nothing preloads. Probe: 10 users,
159
+ `include=posts.comments`: 3 queries with a column sort, 41 with a virtual
160
+ sort; the primary SELECT has no LIMIT. Not in the sample production requests,
161
+ but a cliff any client can trigger.
162
+
163
+ ### Memory drivers
164
+
165
+ Measured attribution for `assignments_full` (133.9MB allocated): the top
166
+ allocation sites are ActiveSupport inflector string churn (24.9MB, driven by
167
+ uncached type-name inflection and `ResourceLoader` resolution per record and per
168
+ identifier), the include walk itself (`includes_serialization.rb`, 10.9MB), and
169
+ row materialization. At page 100 (884.5MB) the inflector alone allocates
170
+ 163MB and the include walk 68MB.
171
+
172
+ 1. **Shared included records serialize once per primary.** The dedupe by
173
+ `type-id` runs after full serialization, so a page of 50 primaries sharing
174
+ one included record serializes it 50 times and discards 49. Probe: the
175
+ shared-record request allocates 90% of what 50 distinct records allocate,
176
+ with 1 record in `included`. Multiplies every other per-record cost by page
177
+ size on shared subtrees.
178
+
179
+ 2. **`record.attributes` churn.** Exactly 3 fresh full-column hash
180
+ materializations per model-backed attribute per serialized record
181
+ (`model_has_attribute?` twice, value read once). Probe: 420 serialized
182
+ records produced 1,680 `#attributes` calls; the AR attribute machinery
183
+ accounts for ~21% of request allocations.
184
+
185
+ 3. **Uncached name resolution.** `ResourceLoader.find_for_model` and type-name
186
+ inflection run per record, per identifier, and per include-hop visit —
187
+ thousands of calls per request; the inflector is the single largest
188
+ allocation site in every heavy scenario.
189
+
190
+ 4. **Full-column AR materialization.** Fat text columns (document bodies,
191
+ remark bodies) load for every included record and dominate retained-during-
192
+ request memory; the response then holds the document again, and meta can hold
193
+ it a third time (production `PolicyVersionResource` serializes `html` in
194
+ attributes and meta; `PolicyResource#meta` adds a `preview` copy).
195
+
196
+ Retained-after-request memory stays under ~102MB in every scenario (25MB
197
+ outside the page-100 stress point): the bloat is
198
+ transient garbage plus heap high-water marks, not a leak.
199
+
200
+ ### Checked and cleared
201
+
202
+ The evaluation also refuted several suspicions, with measurements: pagination
203
+ LIMIT does not break include batching (the count query costs 1); the
204
+ `preload`-vs-`includes` decision is query-neutral; polymorphic and STI hops
205
+ batch correctly at load time; the meta resource instance is cheap (~2MB); the
206
+ attribute-transform dup chain never runs on GET; query tracking retains only
207
+ per-request SQL references and is off in production.
208
+
209
+ ## Optimizations applied
210
+
211
+ - **Name-resolution caching** (`ResourceLoader` + `TypeConversion`): memory
212
+ driver 3 above. Allocations drop 15-22% and median wall time drops 40-55%
213
+ across every scenario, with identical query counts. Page 100: 884.5MB ->
214
+ 694.1MB and 11.1s -> 5.2s. The inflector leaves the top allocation sites;
215
+ the include walk is now the largest jpie-owned allocator.
216
+ - **Shared include context** (dedupe before serialization): memory driver 1
217
+ above. A record reached from several primaries serializes once, not once per
218
+ primary. Allocations drop 4-10% on multi-primary pages (page 100: 884.5MB ->
219
+ 797.4MB) and stay flat on single-primary scenarios, where nothing is shared.
220
+ Query counts are identical. Pages with heavy cross-primary overlap (many
221
+ primaries sharing the same reference records) gain far more: the probe case
222
+ of 50 primaries sharing one record cut its included-serialization work 50x.
223
+ These figures come from the 884.5MB baseline, before name-resolution caching
224
+ landed. Re-run the benchmark on top of that change for the combined number.
225
+
226
+ ## Reading the numbers
227
+
228
+ - Query count is the primary regression metric. It transfers from sqlite to
229
+ postgres; wall time does not (sqlite has near-zero per-query latency, so the
230
+ same request is far slower against production postgres).
231
+ - `allocated_mb` (memory_profiler) is the primary memory metric. RSS deltas are
232
+ indicative only.
233
+ - The scaling scenarios pin the shape: queries and bytes grow linearly with
234
+ page size at a fixed tree, and with tree size at a fixed page. Cost ≈
235
+ parents × path-visits × per-visit mechanisms.
236
+
237
+ ## Optimizations landed
238
+
239
+ ### 1. Preload attachment includes (mechanism 3)
240
+
241
+ `filter_includable` now maps an attachment include key to the association pair
242
+ ActiveStorage defines for it, and `blobs_for` reads blobs through the loaded
243
+ associations. Regression spec: `spec/jsonapi_spec/query_parameters/`
244
+ `attachment_include_preload_spec.rb` pins `GET /users?include=avatar` at 3
245
+ queries for any record count (was 2N+1).
246
+
247
+ Before/after (large dataset; scenarios without attachment includes are
248
+ query-identical, confirming no behavior change outside the mechanism):
249
+
250
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
251
+ |---|---|---|---|---|
252
+ | gadgets_evidence_shape | 2,550 | 146 (−94%) | 132.3 | 89.5 |
253
+ | assignments_single_conversation | 1,728 | 330 (−81%) | 147.4 | 119.5 |
254
+ | assignments_single_conversation + authz | 4,029 | 2,631 | 176.8 | 148.9 |
255
+ | assignments_full (no attachment path) | 782 | 782 | 133.8 | 133.8 |
256
+
257
+ The 330 remaining queries in the `/policies` shape are mostly the narrowing
258
+ resource-scope re-check (mechanism 2) — the next target.
259
+
260
+ ### 2. Vet include-filter verdicts once per request (mechanisms 1 and 2)
261
+
262
+ A request-scoped `IncludeFilterCache` computes each related class's filter
263
+ scopes once, gives each record id one verdict per (class, scope kind), and is
264
+ warmed from the already-loaded include tree: one vetting query per class
265
+ instead of one per parent record per path visit. Pure memoization — the
266
+ allowed set is identical, id for id; the IDOR regression specs hold
267
+ unchanged. Regression spec: `include_filter_batching_spec.rb`.
268
+
269
+ #### First recording (2026-08-14, base: the attachment-preload branch)
270
+
271
+ Measured before the dedupe-included change (#66) landed on main. Kept for the
272
+ record — the query counts still hold, the allocation figures do not.
273
+
274
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
275
+ |---|---|---|---|---|
276
+ | assignments_full | 782 | 153 (−80%) | 133.8 | 111.8 |
277
+ | assignments_full + authz | 3,403 | 176 (−95%) | 168.2 | 114.0 |
278
+ | assignments_single_conversation | 330 | 22 (−93%) | 119.5 | 102.8 |
279
+ | assignments_single_conversation + authz | 2,631 | 34 (−99%) | 148.9 | 104.7 |
280
+ | assignments_page_100 | 5,102 | 1,233 (−76%) | 884.5 | 741.0 |
281
+ | gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 89.5 | 79.2 |
282
+
283
+ #### Second recording (2026-08-17, base: main at 8bff12d, after #64 and #66)
284
+
285
+ Re-measured after the rebase onto main. Both columns come from one sequential
286
+ run of `rake benchmark` on the same machine, large dataset.
287
+
288
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after | ms(med) before | ms(med) after |
289
+ |---|---|---|---|---|---|---|
290
+ | assignments_full | 782 | 153 (−80%) | 98.8 | 80.4 | 708.6 | 522.3 |
291
+ | assignments_full + authz | 3,403 | 176 (−95%) | 133.6 | 82.8 | 1,208.1 | 473.9 |
292
+ | assignments_single_conversation | 330 | 22 (−93%) | 92.6 | 79.6 | 534.1 | 376.4 |
293
+ | assignments_single_conversation + authz | 2,631 | 34 (−99%) | 122.4 | 81.6 | 997.8 | 402.4 |
294
+ | assignments_page_100 | 5,102 | 1,233 (−76%) | 632.2 | 512.3 | 4,800.1 | 3,128.2 |
295
+ | gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 64.9 | 57.9 | 443.6 | 340.2 |
296
+
297
+ Every query count reproduces the first recording exactly, which confirms #66
298
+ changed no query counts in these scenarios. The allocation figures dropped on
299
+ both sides, because #66 cut allocations independently — so the earlier "before"
300
+ column overstated the saving this change alone delivers.
301
+
302
+ Cumulative against the original baseline: the /policies shape is down from
303
+ 1,728 queries to 22; the /evidence_checks shape from 2,550 to 146; page 100
304
+ from 5,102 to 1,233. The remaining residue is mostly mechanism 4 (per-record
305
+ meta lookups: 120 of the 153 queries in assignments_full).
306
+
307
+ ### 3. Apply resource-default preloads at every depth (mechanism 4)
308
+
309
+ `preload_included_resource_associations` read only the first segment of each
310
+ include path, so a resource reached at depth 2 or deeper never received the
311
+ preloads its own `records` scope declares. It now nests the paths into one tree
312
+ and walks it, applying each target class's preloads at every hop. Shared
313
+ prefixes collapse, so a prefix is visited once however many paths cross it, and
314
+ a polymorphic hop continues once per class present in the loaded targets.
315
+
316
+ The walk costs no association queries: every hop reads targets that
317
+ `scope_with_includes` already loaded. Pure batching — included counts are
318
+ identical, scenario for scenario, and the whole suite passes unchanged.
319
+ Regression spec: `deep_resource_preload_spec.rb` pins a depth-2 target at a
320
+ flat query count (36 now, 141 before).
321
+
322
+ Large dataset, before and after:
323
+
324
+ | Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
325
+ |---|---|---|---|---|
326
+ | faults_index | 415 | 81 (−80%) | 117.0 | 114.4 |
327
+ | assignments_full | 292 | 183 (−37%) | 83.7 | 82.4 |
328
+ | assignments_full + authz | 315 | 206 (−35%) | 86.0 | 84.7 |
329
+ | gadgets_single | 87 | 34 (−61%) | 3.09 | 2.57 |
330
+ | assignments_page_1 | 108 | 55 (−49%) | 40.2 | 39.5 |
331
+ | assignments_page_25 | 582 | 385 (−34%) | 156.3 | 154.1 |
332
+ | assignments_page_50 | 1,027 | 693 (−33%) | 280.8 | 277.2 |
333
+ | assignments_page_100 | 1,830 | 1,293 (−29%) | 526.2 | 520.5 |
334
+ | gadgets_evidence_shape (control) | 150 | 150 | 59.2 | 59.3 |
335
+ | assignments_single_conversation (control) | 22 | 22 | 79.7 | 80.0 |
336
+ | suggestions_polymorphic (control) | 84 | 84 | 9.37 | 9.37 |
337
+ | users_deep_include (control) | 3 | 3 | 9.83 | 9.84 |
338
+
339
+ The controls carry the argument. `suggestions_polymorphic` does not move,
340
+ because its targets already sat at depth 1 — that request's cost is
341
+ mechanism 5, in the application, and no jpie change touches it.
342
+ `gadgets_evidence_shape` and `users_deep_include` do not move either: neither
343
+ reaches a preload-declaring resource below depth 1.
344
+
345
+ Allocations barely move because the saving is query count, not object count.
346
+ Wall time on sqlite is too noisy between runs to attribute — the same scenario
347
+ varied by 2x across runs with identical query counts. Against production
348
+ postgres, where per-query latency is real, a 334-query drop on `faults_index`
349
+ is the whole point.
350
+
351
+ ## Two shapes, two causes (2026-08-17)
352
+
353
+ Two staging requests look alike from the outside: both are index pages, both
354
+ spend about 2 seconds, both fire hundreds of ActiveRecord calls. They fail for
355
+ opposite reasons, and the fix for one does nothing for the other.
356
+
357
+ | | `/incidents` | `/suggested_risk_scenarios` |
358
+ |---|---|---|
359
+ | Include paths | 18, up to depth 3 | 2, both depth 1 |
360
+ | Where targets sit | depth 2-3 | depth 1 |
361
+ | Did jpie apply the target's preloads? | No — depth-1 pass missed them | Yes |
362
+ | Cause | jpie: mechanism 4 | application: mechanism 5 |
363
+ | Fix belongs in | jpie | kiln resources |
364
+ | Benchmark scenario | `faults_index` | `suggestions_polymorphic` |
365
+ | Status | fixed: 415 → 81 queries | open, needs kiln preloads |
366
+
367
+ ### The /incidents shape: mechanism 4
368
+
369
+ `faults_index` reproduces the production `/incidents` request: 18 include
370
+ paths, `page[size]=20`, `sort=-created_at`, `filter[archived]=false`. Three
371
+ link branches reach `Gadget` and `Appliance` at depth 2-3. Both resources now
372
+ carry what production carries: a `records` scope that preloads what `meta`
373
+ reads, and `meta` methods that fall back to a query when nothing preloaded.
374
+
375
+ **This change moved the older scenarios too.** `Gadget` and `Appliance` sit in
376
+ the `assignments_full` tree as well, so that scenario went from 153 queries to
377
+ 292 on the same code. No optimization regressed; the benchmark domain became
378
+ more faithful. Compare future runs against the table below, not against the
379
+ 2026-08-14 recordings.
380
+
381
+ | Scenario | Primary | Included | Queries | ms (median) | Alloc MB |
382
+ |---|---|---|---|---|---|
383
+ | faults_index (page 20, 18 paths) | 20 | 4,290 | 415 | 2,430 | 117.0 |
384
+ | assignments_full (re-recorded) | 10 | 2,701 | 292 | 1,548 | 83.7 |
385
+ | assignments_full + authz (re-recorded) | 10 | 2,701 | 315 | 1,088 | 86.0 |
386
+ | gadgets_evidence_shape (re-recorded) | 45 | 1,656 | 150 | 767 | 59.2 |
387
+ | assignments_single_conversation (unchanged) | 1 | 2,191 | 22 | 432 | 79.7 |
388
+ | users_deep_include (control) | 20 | 400 | 3 | 95 | 9.8 |
389
+
390
+ Adding the suggestion domain changed none of these counts. Every scenario above
391
+ reproduced its query count exactly on the run that first measured
392
+ `suggestions_polymorphic`, which confirms the new tables and resources sit
393
+ outside the other include trees.
394
+
395
+ 390 of the 415 `faults_index` queries are per-record. The include tree itself
396
+ costs about 25. The breakdown, against 45 gadgets and 25 appliances reached at
397
+ depth 2-3:
398
+
399
+ | Count | Query | Source | After the depth fix |
400
+ |---|---|---|---|
401
+ | 70 | `archivals` by owner | `records` preload, unreachable at depth 2 | 2 |
402
+ | 45 | `criteria.id` pluck | `criterium_ids` attribute | 1 |
403
+ | 45 + 45 | `inspections` order-limit, then exists | inspection history in `meta` | 1 |
404
+ | 45 + 45 | `standard_scopes` rows, then exists | scope verdict in `meta` | 1 |
405
+ | 45 | `gadget_checks` exists | checklist verdict in `meta` | 1 |
406
+ | 25 + 25 | `stream_entries` by stream | append-only read, no association | 50 |
407
+
408
+ Only the last row was beyond jpie's reach: a raw stream read has no association
409
+ for any preloader to batch. Everything above it collapsed to a handful of
410
+ batched queries once resource-default preloads applied at every depth, taking
411
+ the scenario from 415 queries to 81. The 50 stream reads are now the largest
412
+ single item, and they need a change in the application.
413
+
414
+ ### The /suggested_risk_scenarios shape: mechanism 5
415
+
416
+ `suggestions_polymorphic` reproduces the production request:
417
+ `filter[account_level]=true`, `sort=-priority`, and two include paths —
418
+ `repair_plan` (plain) and `suggestable` (polymorphic, onto `Gadget` and
419
+ `Appliance`). `SuggestionResource.records` copies the production scope, with
420
+ the plain hop on `includes` and the polymorphic hop on `preload`.
421
+
422
+ Both targets sit at depth 1, so jpie applies their `records` preloads. The
423
+ split in the results is the whole point:
424
+
425
+ - `Gadget` declares its preloads. All five load in one batched query each,
426
+ however many gadgets the page holds.
427
+ - `RepairPlan` declares none, and its `meta` walks
428
+ `baseline_estimate.estimate_category`. That costs two queries per serialized
429
+ plan — the exact `Risk::InherentRiskTemplate` and `Risk::Category` bands in
430
+ the staging trace.
431
+ - `Appliance` reads an append-only stream, which has no association to preload
432
+ at any depth.
433
+
434
+ | Dataset | Primary | Included | Queries | ms (median) | Alloc MB |
435
+ |---|---|---|---|---|---|
436
+ | small | 5 | 10 | 22 | 14 | - |
437
+ | medium | 25 | 37 | 46 | 31 | 2.9 |
438
+ | large | 100 | 112 | 84 | 203 | 9.4 |
439
+
440
+ Queries grow far slower than the page, because the repair-plan pool caps at 12
441
+ and only 25 of the 100 suggestables are appliances. Production behaves the same
442
+ way: 18 suggestions reached 13 distinct templates. The large run splits like
443
+ this:
444
+
445
+ | Count | Query | Verdict |
446
+ |---|---|---|
447
+ | 25 + 25 | `stream_entries` by stream | unfixable by preloading |
448
+ | 12 + 12 | `baseline_estimates`, then `estimate_categories` | resource never declared it |
449
+ | 2 | `archivals` by owner type | correct: one per polymorphic class |
450
+ | 8 | primary, both target classes, plan, declared preloads | correct: batched |
451
+
452
+ Only 8 of the 84 queries are structural. Every association a resource declares
453
+ is batched, including through the polymorphic hop, and the 24 `RepairPlan`
454
+ queries are the shape a one-line preload removes.
455
+
456
+ No change to jpie removes the `RepairPlan` queries. The resource has to ask for
457
+ the preload. In production the three to fix are
458
+ `RiskScenarioTemplateResource` (add `inherent_risk_template: :category`),
459
+ `SelectedControlResource` (add `control_template` to its existing preload
460
+ list), and `ComplianceScopeableResource` (add `resource_framework_scopes`,
461
+ which alone accounts for 82 of the 541 calls).
data/README.md CHANGED
@@ -532,7 +532,7 @@ end
532
532
 
533
533
  ### Resource-Level Meta
534
534
 
535
- Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps in ISO8601 format if the model responds to these methods.
535
+ Resource-level meta appears within each resource object. By default, the gem automatically includes `created_at` and `updated_at` timestamps if the model responds to these methods. The gem passes the raw `Time` to the JSON encoder, so Rails renders ISO8601 at the precision `ActiveSupport::JSON::Encoding.time_precision` sets — three sub-second digits by default. A timestamp you also expose as an attribute therefore reads the same in both places.
536
536
 
537
537
  You can also define custom meta in two ways:
538
538
 
@@ -578,8 +578,8 @@ The instance method has access to the model instance via `resource`. Custom meta
578
578
  "email": "john@example.com"
579
579
  },
580
580
  "meta": {
581
- "created_at": "2024-01-15T10:30:00Z",
582
- "updated_at": "2024-01-15T10:30:00Z",
581
+ "created_at": "2024-01-15T10:30:00.000Z",
582
+ "updated_at": "2024-01-15T10:30:00.000Z",
583
583
  "name_length": 8,
584
584
  "custom_field": "value"
585
585
  },
data/Rakefile CHANGED
@@ -7,6 +7,11 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  task default: :spec
9
9
 
10
+ desc "Run the performance benchmark suite (writes tmp/request_benchmark_results.json)"
11
+ task :benchmark do
12
+ sh({ "PERFORMANCE" => "1" }, "bundle", "exec", "rspec", "spec/benchmarks", "--format", "progress")
13
+ end
14
+
10
15
  # Override release task to require OTP code
11
16
  # Usage: GEM_HOST_OTP_CODE=123456 rake release
12
17
  Rake::Task["release"].enhance do
@@ -11,19 +11,36 @@ module JSONAPI
11
11
  attachment = record.public_send(attachment_name)
12
12
  return nil unless attachment.respond_to?(:attached?)
13
13
 
14
- attachment.attached? ? serialize_attached(attachment) : empty_attachment_value(attachment)
14
+ blobs = blobs_for(attachment_name, record)
15
+ return blobs.map { |blob| serialize_blob_identifier(blob) } if attachment.is_a?(::ActiveStorage::Attached::Many)
16
+
17
+ blobs.first ? serialize_blob_identifier(blobs.first) : nil
15
18
  end
16
19
 
17
- def serialize_attached(attachment)
20
+ # Blobs for an attachment. Reads through the ActiveStorage associations
21
+ # when the include preloader loaded them (zero queries); otherwise falls
22
+ # back to the probing reader (attached? + blobs, one query each).
23
+ def blobs_for(attachment_name, record)
24
+ attachment = record.public_send(attachment_name)
25
+ return [] unless attachment.respond_to?(:attached?)
26
+
18
27
  if attachment.is_a?(::ActiveStorage::Attached::Many)
19
- return attachment.blobs.map { |blob| serialize_blob_identifier(blob) }
28
+ many_blobs(attachment, record.association(:"#{attachment_name}_attachments"))
29
+ else
30
+ one_blob(attachment, record.association(:"#{attachment_name}_attachment"))
20
31
  end
32
+ end
33
+
34
+ def many_blobs(attachment, association)
35
+ return association.target.filter_map(&:blob) if association.loaded?
21
36
 
22
- serialize_blob_identifier(attachment.blob)
37
+ attachment.attached? ? attachment.blobs.to_a : []
23
38
  end
24
39
 
25
- def empty_attachment_value(attachment)
26
- attachment.is_a?(::ActiveStorage::Attached::Many) ? [] : nil
40
+ def one_blob(attachment, association)
41
+ return [association.target&.blob].compact if association.loaded?
42
+
43
+ attachment.attached? ? [attachment.blob].compact : []
27
44
  end
28
45
 
29
46
  def serialize_blob_identifier(blob)