jpie 3.5.0 → 3.8.1
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.
- checksums.yaml +4 -4
- data/.claude/skills/writing-discipline/SKILL.md +55 -0
- data/.rubocop.yml +58 -0
- data/CLAUDE.md +34 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +3 -1
- data/PERFORMANCE_BASELINE.md +258 -99
- data/README.md +3 -3
- data/Rakefile +5 -0
- data/lib/json_api/active_storage/serialization.rb +23 -6
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +18 -3
- data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +18 -3
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +55 -12
- data/lib/json_api/railtie.rb +6 -1
- data/lib/json_api/resources/resource_loader.rb +35 -2
- data/lib/json_api/routing.rb +10 -0
- data/lib/json_api/serialization/concerns/include_filtering.rb +7 -29
- data/lib/json_api/serialization/concerns/includes_serialization.rb +25 -16
- data/lib/json_api/serialization/concerns/meta_serialization.rb +8 -5
- data/lib/json_api/serialization/include_filter_cache.rb +113 -0
- data/lib/json_api/serialization/serializer.rb +18 -4
- data/lib/json_api/support/type_conversion.rb +24 -6
- data/lib/json_api/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9e7099c27c6aec0de41a095baa563dbc63eb934b44ab8195a9e3a51f5152c4e
|
|
4
|
+
data.tar.gz: 86ee7fbdc3cf8414d9829bbf4f42ac7e016603f8a884a0b674d8e9b869cf1b8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a013761543aba53b56e575f8b2f51d3004d1cae53e53570bf506bd744cb2d38891e43a5188ad11ee338923aacdd5c8bc4cf107e1772f5cb73173aafb590d1859
|
|
7
|
+
data.tar.gz: 105cbbcb454083ebbfae7db54d9fbec5e76c39d3c1d1823c16b34de071abeae47e4cdf5923aadeb3224e609ee85c07712ed37651caa1122719501ac1c479ae07
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: writing-discipline
|
|
3
|
+
description: The writing standard for all prose — chat responses, documentation, code comments, commit messages, PR descriptions, Jira and Slack posts. Use BEFORE you write any human-facing text. Enforces ASD-STE100 (Simplified Technical English) plus Orwell's six rules — active voice, short sentences, short everyday words, one instruction per sentence, no idioms, no jargon, cut every word you can.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing discipline
|
|
7
|
+
|
|
8
|
+
All prose obeys ASD-STE100 (Simplified Technical English) and Orwell's six rules of
|
|
9
|
+
writing. This covers chat responses, documentation, code comments, commit messages,
|
|
10
|
+
PR descriptions, and Jira and Slack posts.
|
|
11
|
+
|
|
12
|
+
## ASD-STE100
|
|
13
|
+
|
|
14
|
+
- Use only approved words from the STE dictionary (one word, one meaning). Technical
|
|
15
|
+
names and technical verbs of this domain are permitted.
|
|
16
|
+
- Write in the active voice. Use the present tense where possible.
|
|
17
|
+
- Keep sentences short: maximum 20 words in an instruction, maximum 25 words in
|
|
18
|
+
descriptive text.
|
|
19
|
+
- Give one instruction per sentence. Start an instruction with the command form of
|
|
20
|
+
the verb.
|
|
21
|
+
- Do not make noun clusters of more than three nouns.
|
|
22
|
+
- Do not use slang, idioms, or Latin abbreviations (no "e.g.", "i.e.", "etc.", "via").
|
|
23
|
+
- Use a vertical list when you give more than three facts or steps in sequence.
|
|
24
|
+
- Start a warning or caution with the command, not the explanation.
|
|
25
|
+
|
|
26
|
+
## Orwell's six rules
|
|
27
|
+
|
|
28
|
+
1. Never use a metaphor, simile, or other figure of speech which you are used to
|
|
29
|
+
seeing in print.
|
|
30
|
+
2. Never use a long word where a short one will do.
|
|
31
|
+
3. If it is possible to cut a word out, always cut it out.
|
|
32
|
+
4. Never use the passive where you can use the active.
|
|
33
|
+
5. Never use a foreign phrase, a scientific word, or a jargon word if you can think
|
|
34
|
+
of an everyday English equivalent.
|
|
35
|
+
6. Break any of these rules sooner than say anything outright barbarous.
|
|
36
|
+
|
|
37
|
+
## How to apply
|
|
38
|
+
|
|
39
|
+
- Draft, then cut. Remove every word that does not change the meaning.
|
|
40
|
+
- Break a long sentence at "which", "and", or "but".
|
|
41
|
+
- Prefer a verb to a noun cluster: write "when the job runs", not "at job execution
|
|
42
|
+
time".
|
|
43
|
+
- Replace idioms with plain statements: write "this shows the cause", not "this is
|
|
44
|
+
the smoking gun".
|
|
45
|
+
- Replace Latin abbreviations: write "for example", not "e.g."; name the items, or
|
|
46
|
+
write "and more", instead of "etc.".
|
|
47
|
+
- Keep technical names, commands, and code identifiers as they are. The standard
|
|
48
|
+
limits prose, not identifiers.
|
|
49
|
+
|
|
50
|
+
## Self-check before you send
|
|
51
|
+
|
|
52
|
+
- Is every sentence inside the length limit?
|
|
53
|
+
- Is every verb active?
|
|
54
|
+
- Can any word go?
|
|
55
|
+
- Does a reader who is not an expert know every word that is not a technical name?
|
data/.rubocop.yml
CHANGED
|
@@ -47,6 +47,10 @@ Metrics/BlockLength:
|
|
|
47
47
|
|
|
48
48
|
Metrics/ParameterLists:
|
|
49
49
|
CountKeywordArgs: false
|
|
50
|
+
Exclude:
|
|
51
|
+
# Seed helpers thread id pools positionally; a params object would add
|
|
52
|
+
# indirection to throwaway scaffolding.
|
|
53
|
+
- "spec/benchmarks/**/*"
|
|
50
54
|
|
|
51
55
|
# Naming - allow DSL methods
|
|
52
56
|
Naming/PredicatePrefix:
|
|
@@ -95,6 +99,9 @@ Lint/UnusedBlockArgument:
|
|
|
95
99
|
# Security PoC specs configure authorization handlers via keyword lambdas
|
|
96
100
|
# that intentionally ignore some of the required keyword arguments.
|
|
97
101
|
- "spec/requests/regression/*_spec.rb"
|
|
102
|
+
# The benchmark suite and filter-batching specs configure the same hooks.
|
|
103
|
+
- "spec/benchmarks/**/*"
|
|
104
|
+
- "spec/jsonapi_spec/query_parameters/include_filter_batching_spec.rb"
|
|
98
105
|
|
|
99
106
|
# Security PoC regression specs read as a single sequential narrative
|
|
100
107
|
# (sanity check -> attack -> assert), so a handful of expectations per example
|
|
@@ -102,10 +109,61 @@ Lint/UnusedBlockArgument:
|
|
|
102
109
|
RSpec/MultipleExpectations:
|
|
103
110
|
Exclude:
|
|
104
111
|
- "spec/requests/regression/*_spec.rb"
|
|
112
|
+
- "spec/benchmarks/**/*"
|
|
105
113
|
|
|
106
114
|
RSpec/ExampleLength:
|
|
107
115
|
Exclude:
|
|
108
116
|
- "spec/requests/regression/*_spec.rb"
|
|
117
|
+
- "spec/benchmarks/**/*"
|
|
118
|
+
|
|
119
|
+
# The benchmark suite reads as data tables (seed rows, scenario matrices,
|
|
120
|
+
# result formatting). Splitting those methods obscures the data shapes, and
|
|
121
|
+
# the suite is measurement scaffolding, not library code.
|
|
122
|
+
Metrics/MethodLength:
|
|
123
|
+
Exclude:
|
|
124
|
+
- "spec/benchmarks/**/*"
|
|
125
|
+
|
|
126
|
+
Metrics/AbcSize:
|
|
127
|
+
Exclude:
|
|
128
|
+
- "spec/benchmarks/**/*"
|
|
129
|
+
# Mirrors a production resource: meta computes several version lookups.
|
|
130
|
+
- "spec/dummy/app/resources/assignment_resource.rb"
|
|
131
|
+
|
|
132
|
+
Metrics/ModuleLength:
|
|
133
|
+
Exclude:
|
|
134
|
+
- "spec/benchmarks/**/*"
|
|
135
|
+
|
|
136
|
+
Metrics/CyclomaticComplexity:
|
|
137
|
+
Exclude:
|
|
138
|
+
- "spec/benchmarks/**/*"
|
|
139
|
+
|
|
140
|
+
Metrics/PerceivedComplexity:
|
|
141
|
+
Exclude:
|
|
142
|
+
- "spec/benchmarks/**/*"
|
|
143
|
+
|
|
144
|
+
Style/FormatStringToken:
|
|
145
|
+
Exclude:
|
|
146
|
+
- "spec/benchmarks/**/*"
|
|
147
|
+
|
|
148
|
+
# Benchmark seeds load once per size context; per-example transactions are
|
|
149
|
+
# deliberately off there.
|
|
150
|
+
RSpec/BeforeAfterAll:
|
|
151
|
+
Exclude:
|
|
152
|
+
- "spec/benchmarks/**/*"
|
|
153
|
+
|
|
154
|
+
RSpec/InstanceVariable:
|
|
155
|
+
Exclude:
|
|
156
|
+
- "spec/benchmarks/**/*"
|
|
157
|
+
|
|
158
|
+
# Include-tree constants in the benchmark spec document the production
|
|
159
|
+
# request shapes; they never leak outside the suite run.
|
|
160
|
+
Lint/ConstantDefinitionInBlock:
|
|
161
|
+
Exclude:
|
|
162
|
+
- "spec/benchmarks/**/*"
|
|
163
|
+
|
|
164
|
+
RSpec/LeakyConstantDeclaration:
|
|
165
|
+
Exclude:
|
|
166
|
+
- "spec/benchmarks/**/*"
|
|
109
167
|
|
|
110
168
|
# RSpec - file-specific exclusions
|
|
111
169
|
RSpec/DescribedClass:
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Writing standard: ASD-STE100 + Orwell's six rules
|
|
6
|
+
|
|
7
|
+
All prose you write — chat responses, documentation, code comments, commit messages,
|
|
8
|
+
PR descriptions, Jira and Slack posts — must obey ASD-STE100 (Simplified Technical
|
|
9
|
+
English) and Orwell's six rules of writing.
|
|
10
|
+
|
|
11
|
+
ASD-STE100:
|
|
12
|
+
|
|
13
|
+
- Use only approved words from the STE dictionary (one word, one meaning). Technical
|
|
14
|
+
names and technical verbs of this domain are permitted.
|
|
15
|
+
- Write in the active voice. Use the present tense where possible.
|
|
16
|
+
- Keep sentences short: maximum 20 words in an instruction, maximum 25 words in
|
|
17
|
+
descriptive text.
|
|
18
|
+
- Give one instruction per sentence. Start an instruction with the command form of
|
|
19
|
+
the verb.
|
|
20
|
+
- Do not make noun clusters of more than three nouns.
|
|
21
|
+
- Do not use slang, idioms, or Latin abbreviations (no "e.g.", "i.e.", "etc.", "via").
|
|
22
|
+
- Use a vertical list when you give more than three facts or steps in sequence.
|
|
23
|
+
- Start a warning or caution with the command, not the explanation.
|
|
24
|
+
|
|
25
|
+
Orwell's six rules:
|
|
26
|
+
|
|
27
|
+
1. Never use a metaphor, simile, or other figure of speech which you are used to
|
|
28
|
+
seeing in print.
|
|
29
|
+
2. Never use a long word where a short one will do.
|
|
30
|
+
3. If it is possible to cut a word out, always cut it out.
|
|
31
|
+
4. Never use the passive where you can use the active.
|
|
32
|
+
5. Never use a foreign phrase, a scientific word, or a jargon word if you can think
|
|
33
|
+
of an everyday English equivalent.
|
|
34
|
+
6. Break any of these rules sooner than say anything outright barbarous.
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
jpie (3.1
|
|
4
|
+
jpie (3.8.1)
|
|
5
5
|
actionpack (~> 8.1, >= 8.1.0)
|
|
6
6
|
pg_query (>= 4)
|
|
7
7
|
prosopite (>= 1)
|
|
@@ -151,6 +151,7 @@ GEM
|
|
|
151
151
|
net-pop
|
|
152
152
|
net-smtp
|
|
153
153
|
marcel (1.1.0)
|
|
154
|
+
memory_profiler (1.1.0)
|
|
154
155
|
mini_mime (1.1.5)
|
|
155
156
|
minitest (5.26.2)
|
|
156
157
|
net-imap (0.5.12)
|
|
@@ -340,6 +341,7 @@ DEPENDENCIES
|
|
|
340
341
|
bundler (~> 2.0)
|
|
341
342
|
jpie!
|
|
342
343
|
json_schemer (~> 2.4)
|
|
344
|
+
memory_profiler (~> 1.1)
|
|
343
345
|
pundit (~> 2.3)
|
|
344
346
|
rake (~> 13.0)
|
|
345
347
|
rspec (~> 3.12)
|
data/PERFORMANCE_BASELINE.md
CHANGED
|
@@ -1,102 +1,261 @@
|
|
|
1
|
-
# jpie
|
|
2
|
-
|
|
3
|
-
This document
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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), and
|
|
9
|
+
`/evidence_checks` with a 45-id batch filter and files at depth 3 (~1,250
|
|
10
|
+
queries).
|
|
11
|
+
|
|
12
|
+
The numbers come from the benchmark suite in `spec/benchmarks/`. The evaluation
|
|
13
|
+
behind them ran 32 independent code-path analyses; a second wave verified the
|
|
14
|
+
top findings empirically with probe specs. Every mechanism below carries a
|
|
15
|
+
measured probe result, not an estimate.
|
|
66
16
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
|
|
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
|
|
17
|
+
## How to run
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
rake benchmark
|
|
82
21
|
```
|
|
83
22
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
23
|
+
This runs `spec/benchmarks/` with `PERFORMANCE=1`, prints a results table, and
|
|
24
|
+
writes `tmp/request_benchmark_results.json` (per-scenario query counts, query
|
|
25
|
+
shapes, wall time, allocated/retained bytes, allocation sites, RSS). Diff that
|
|
26
|
+
file against this baseline after any optimization. `DEBUG_QUERIES=1` also dumps
|
|
27
|
+
raw SQL logs to `tmp/benchmarks/query_logs/`.
|
|
28
|
+
|
|
29
|
+
The suite seeds a stand-in domain (`Assignment`, `Gadget`, `Edition`, `Remark`,
|
|
30
|
+
and others) that copies the production include-tree topology: wide + deep trees,
|
|
31
|
+
polymorphic hops, shared prefixes, STI-free but otherwise faithful. The
|
|
32
|
+
`assignments_full` tree mirrors the production `/workstreams` request
|
|
33
|
+
path-for-path; `assignments_single_conversation` mirrors the `/policies` killer
|
|
34
|
+
(fat document bodies, a 600-remark conversation, ActiveStorage files, per-remark
|
|
35
|
+
children); `gadgets_evidence_shape` mirrors `/evidence_checks` (a 45-id `id_in`
|
|
36
|
+
batch, two branches converging on ActiveStorage files, polymorphic leaves at
|
|
37
|
+
depth 2-3).
|
|
38
|
+
|
|
39
|
+
## Baseline (large dataset, sqlite, 2026-08-14)
|
|
40
|
+
|
|
41
|
+
The seeds sit above the worst production reports on purpose: a 600-remark AI
|
|
42
|
+
conversation, 40 editions with ~64KB document bodies, ~4KB remark bodies,
|
|
43
|
+
fanout 10 on subject inspections plus link-owned inspections. The heaviest
|
|
44
|
+
scenarios overshoot the observed ceilings (600MB allocated, 1,250 queries) so
|
|
45
|
+
the bottleneck curves stay visible past them.
|
|
46
|
+
|
|
47
|
+
| Scenario | Primary | Included | Queries | ms (median) | Alloc MB | Retained MB |
|
|
48
|
+
|---|---|---|---|---|---|---|
|
|
49
|
+
| assignments_full (page 10, 25 paths) | 10 | 2,701 | 782 | 1,499 | 133.9 | 14.1 |
|
|
50
|
+
| assignments_full + authz hook | 10 | 2,701 | 3,403 | 2,200 | 168.2 | 14.6 |
|
|
51
|
+
| assignments_single_conversation (page 1, 17 paths) | 1 | 2,191 | 1,728 | 1,376 | 147.4 | 24.6 |
|
|
52
|
+
| assignments_single_conversation + authz hook | 1 | 2,191 | 4,029 | 2,079 | 176.8 | 25.0 |
|
|
53
|
+
| gadgets_evidence_shape (45-id batch, 9 paths) | 45 | 1,656 | 2,550 | 1,386 | 132.3 | 8.1 |
|
|
54
|
+
| 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 |
|
|
55
|
+
| 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 |
|
|
56
|
+
| users_deep_include (control: clean resources) | 20 | 400 | 3 | 97 | 12.0 | 1.6 |
|
|
57
|
+
|
|
58
|
+
The reproduction exceeds every production report. Queries: 782 at the
|
|
59
|
+
`/workstreams` page-10 shape, 2,550 at the `/evidence_checks` shape (vs 1,250
|
|
60
|
+
observed), 1,728 for ONE primary record at the `/policies` shape, 5,102 at
|
|
61
|
+
page 100. Memory: 884.5MB allocated at page 100 (vs ~600MB observed), 133.9MB
|
|
62
|
+
at page 10. The authorization-hook variants add a narrowing Pundit-style scope
|
|
63
|
+
on every model; production sits between the plain and authz numbers because
|
|
64
|
+
only some models carry narrowing scopes. The control scenario shows the same
|
|
65
|
+
serializer doing 400 included records in 3 queries when no mechanism below is
|
|
66
|
+
active — the domain shape alone is not the problem.
|
|
67
|
+
|
|
68
|
+
## Verified mechanisms
|
|
69
|
+
|
|
70
|
+
### N+1 query drivers
|
|
71
|
+
|
|
72
|
+
1. **Per-visit authorization re-check — FIXED** (with mechanism 2, see
|
|
73
|
+
"Optimizations landed"). The include walk runs the
|
|
74
|
+
`authorization_scope` hook once per parent record, per hop, per include path.
|
|
75
|
+
When the hook changes the scope's SQL, each visit fires one filter query.
|
|
76
|
+
Probe: 10 posts, `include=comments,comments.author`: 3 queries without a
|
|
77
|
+
hook, 73 with a narrowing hook. No memoization: 10 distinct users were
|
|
78
|
+
re-authorized 50 times. Shared prefixes multiply visits (each path re-visits
|
|
79
|
+
its whole prefix). Scaling: queries ≈ parents × path-visits.
|
|
80
|
+
Anchors: `include_filtering.rb` (`filter_loaded_records`, `narrowed?`),
|
|
81
|
+
`includes_serialization.rb`.
|
|
82
|
+
Baseline signature: 1,170× single-row authorization plucks in
|
|
83
|
+
`assignments_full_authz`.
|
|
84
|
+
|
|
85
|
+
2. **Narrowing resource-default scopes re-check per visit — FIXED** (same
|
|
86
|
+
change as mechanism 1). A resource whose
|
|
87
|
+
`records` narrows the model scope (production: `MessageResource.records =
|
|
88
|
+
super.client_visible`) triggers the same per-visit re-check without any
|
|
89
|
+
authorization hook.
|
|
90
|
+
Baseline signature: 570× `SELECT remarks.id WHERE body IS NOT NULL AND id = ?`
|
|
91
|
+
in `assignments_full`; 3,270× at page 100.
|
|
92
|
+
|
|
93
|
+
3. **ActiveStorage includes never preload — FIXED.** `filter_includable` kept
|
|
94
|
+
only include keys with an AR reflection; `has_many_attached :files` defines
|
|
95
|
+
`files_attachments`, not `files`, so the key dropped silently. Serialization
|
|
96
|
+
then probed attachments per record (2N+1 queries; 2,340 of the 2,550
|
|
97
|
+
queries in `gadgets_evidence_shape`). The fix maps the attachment key to
|
|
98
|
+
`{files_attachments: {blob: {}}}` in the preload hash and reads blobs
|
|
99
|
+
through the loaded associations. See "Optimizations landed" below.
|
|
100
|
+
|
|
101
|
+
4. **Per-record meta queries, any depth.** Instance `meta` that touches an
|
|
102
|
+
association or runs a finder fires per serialized record. The batch pass
|
|
103
|
+
(`preload_included_resource_associations`) only applies resource-default
|
|
104
|
+
preloads to depth-1 targets, and only from `records` scope values. Probe: 1
|
|
105
|
+
profile query per serialized user at depth 1 and depth 2; putting the path in
|
|
106
|
+
`include=` collapses it to one batched query. Production:
|
|
107
|
+
`PolicyResource#meta` runs 3 version lookups per policy;
|
|
108
|
+
`PolicyVersionResource#meta` touches `resource.policy` per version.
|
|
109
|
+
Baseline signature: 40× draft-edition and 40× published-edition lookups in
|
|
110
|
+
`assignments_full`.
|
|
111
|
+
|
|
112
|
+
5. **Virtual sort disables everything.** A virtual (resource-method) sort field
|
|
113
|
+
materializes the whole filtered table as an Array before pagination, and
|
|
114
|
+
`scope_with_includes` bails on Arrays, so nothing preloads. Probe: 10 users,
|
|
115
|
+
`include=posts.comments`: 3 queries with a column sort, 41 with a virtual
|
|
116
|
+
sort; the primary SELECT has no LIMIT. Not in the sample production requests,
|
|
117
|
+
but a cliff any client can trigger.
|
|
118
|
+
|
|
119
|
+
### Memory drivers
|
|
120
|
+
|
|
121
|
+
Measured attribution for `assignments_full` (133.9MB allocated): the top
|
|
122
|
+
allocation sites are ActiveSupport inflector string churn (24.9MB, driven by
|
|
123
|
+
uncached type-name inflection and `ResourceLoader` resolution per record and per
|
|
124
|
+
identifier), the include walk itself (`includes_serialization.rb`, 10.9MB), and
|
|
125
|
+
row materialization. At page 100 (884.5MB) the inflector alone allocates
|
|
126
|
+
163MB and the include walk 68MB.
|
|
127
|
+
|
|
128
|
+
1. **Shared included records serialize once per primary.** The dedupe by
|
|
129
|
+
`type-id` runs after full serialization, so a page of 50 primaries sharing
|
|
130
|
+
one included record serializes it 50 times and discards 49. Probe: the
|
|
131
|
+
shared-record request allocates 90% of what 50 distinct records allocate,
|
|
132
|
+
with 1 record in `included`. Multiplies every other per-record cost by page
|
|
133
|
+
size on shared subtrees.
|
|
134
|
+
|
|
135
|
+
2. **`record.attributes` churn.** Exactly 3 fresh full-column hash
|
|
136
|
+
materializations per model-backed attribute per serialized record
|
|
137
|
+
(`model_has_attribute?` twice, value read once). Probe: 420 serialized
|
|
138
|
+
records produced 1,680 `#attributes` calls; the AR attribute machinery
|
|
139
|
+
accounts for ~21% of request allocations.
|
|
140
|
+
|
|
141
|
+
3. **Uncached name resolution.** `ResourceLoader.find_for_model` and type-name
|
|
142
|
+
inflection run per record, per identifier, and per include-hop visit —
|
|
143
|
+
thousands of calls per request; the inflector is the single largest
|
|
144
|
+
allocation site in every heavy scenario.
|
|
145
|
+
|
|
146
|
+
4. **Full-column AR materialization.** Fat text columns (document bodies,
|
|
147
|
+
remark bodies) load for every included record and dominate retained-during-
|
|
148
|
+
request memory; the response then holds the document again, and meta can hold
|
|
149
|
+
it a third time (production `PolicyVersionResource` serializes `html` in
|
|
150
|
+
attributes and meta; `PolicyResource#meta` adds a `preview` copy).
|
|
151
|
+
|
|
152
|
+
Retained-after-request memory stays under ~102MB in every scenario (25MB
|
|
153
|
+
outside the page-100 stress point): the bloat is
|
|
154
|
+
transient garbage plus heap high-water marks, not a leak.
|
|
155
|
+
|
|
156
|
+
### Checked and cleared
|
|
157
|
+
|
|
158
|
+
The evaluation also refuted several suspicions, with measurements: pagination
|
|
159
|
+
LIMIT does not break include batching (the count query costs 1); the
|
|
160
|
+
`preload`-vs-`includes` decision is query-neutral; polymorphic and STI hops
|
|
161
|
+
batch correctly at load time; the meta resource instance is cheap (~2MB); the
|
|
162
|
+
attribute-transform dup chain never runs on GET; query tracking retains only
|
|
163
|
+
per-request SQL references and is off in production.
|
|
164
|
+
|
|
165
|
+
## Optimizations applied
|
|
166
|
+
|
|
167
|
+
- **Name-resolution caching** (`ResourceLoader` + `TypeConversion`): memory
|
|
168
|
+
driver 3 above. Allocations drop 15-22% and median wall time drops 40-55%
|
|
169
|
+
across every scenario, with identical query counts. Page 100: 884.5MB ->
|
|
170
|
+
694.1MB and 11.1s -> 5.2s. The inflector leaves the top allocation sites;
|
|
171
|
+
the include walk is now the largest jpie-owned allocator.
|
|
172
|
+
- **Shared include context** (dedupe before serialization): memory driver 1
|
|
173
|
+
above. A record reached from several primaries serializes once, not once per
|
|
174
|
+
primary. Allocations drop 4-10% on multi-primary pages (page 100: 884.5MB ->
|
|
175
|
+
797.4MB) and stay flat on single-primary scenarios, where nothing is shared.
|
|
176
|
+
Query counts are identical. Pages with heavy cross-primary overlap (many
|
|
177
|
+
primaries sharing the same reference records) gain far more: the probe case
|
|
178
|
+
of 50 primaries sharing one record cut its included-serialization work 50x.
|
|
179
|
+
These figures come from the 884.5MB baseline, before name-resolution caching
|
|
180
|
+
landed. Re-run the benchmark on top of that change for the combined number.
|
|
181
|
+
|
|
182
|
+
## Reading the numbers
|
|
183
|
+
|
|
184
|
+
- Query count is the primary regression metric. It transfers from sqlite to
|
|
185
|
+
postgres; wall time does not (sqlite has near-zero per-query latency, so the
|
|
186
|
+
same request is far slower against production postgres).
|
|
187
|
+
- `allocated_mb` (memory_profiler) is the primary memory metric. RSS deltas are
|
|
188
|
+
indicative only.
|
|
189
|
+
- The scaling scenarios pin the shape: queries and bytes grow linearly with
|
|
190
|
+
page size at a fixed tree, and with tree size at a fixed page. Cost ≈
|
|
191
|
+
parents × path-visits × per-visit mechanisms.
|
|
192
|
+
|
|
193
|
+
## Optimizations landed
|
|
194
|
+
|
|
195
|
+
### 1. Preload attachment includes (mechanism 3)
|
|
196
|
+
|
|
197
|
+
`filter_includable` now maps an attachment include key to the association pair
|
|
198
|
+
ActiveStorage defines for it, and `blobs_for` reads blobs through the loaded
|
|
199
|
+
associations. Regression spec: `spec/jsonapi_spec/query_parameters/`
|
|
200
|
+
`attachment_include_preload_spec.rb` pins `GET /users?include=avatar` at 3
|
|
201
|
+
queries for any record count (was 2N+1).
|
|
202
|
+
|
|
203
|
+
Before/after (large dataset; scenarios without attachment includes are
|
|
204
|
+
query-identical, confirming no behavior change outside the mechanism):
|
|
205
|
+
|
|
206
|
+
| Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
|
|
207
|
+
|---|---|---|---|---|
|
|
208
|
+
| gadgets_evidence_shape | 2,550 | 146 (−94%) | 132.3 | 89.5 |
|
|
209
|
+
| assignments_single_conversation | 1,728 | 330 (−81%) | 147.4 | 119.5 |
|
|
210
|
+
| assignments_single_conversation + authz | 4,029 | 2,631 | 176.8 | 148.9 |
|
|
211
|
+
| assignments_full (no attachment path) | 782 | 782 | 133.8 | 133.8 |
|
|
212
|
+
|
|
213
|
+
The 330 remaining queries in the `/policies` shape are mostly the narrowing
|
|
214
|
+
resource-scope re-check (mechanism 2) — the next target.
|
|
215
|
+
|
|
216
|
+
### 2. Vet include-filter verdicts once per request (mechanisms 1 and 2)
|
|
217
|
+
|
|
218
|
+
A request-scoped `IncludeFilterCache` computes each related class's filter
|
|
219
|
+
scopes once, gives each record id one verdict per (class, scope kind), and is
|
|
220
|
+
warmed from the already-loaded include tree: one vetting query per class
|
|
221
|
+
instead of one per parent record per path visit. Pure memoization — the
|
|
222
|
+
allowed set is identical, id for id; the IDOR regression specs hold
|
|
223
|
+
unchanged. Regression spec: `include_filter_batching_spec.rb`.
|
|
224
|
+
|
|
225
|
+
#### First recording (2026-08-14, base: the attachment-preload branch)
|
|
226
|
+
|
|
227
|
+
Measured before the dedupe-included change (#66) landed on main. Kept for the
|
|
228
|
+
record — the query counts still hold, the allocation figures do not.
|
|
229
|
+
|
|
230
|
+
| Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after |
|
|
231
|
+
|---|---|---|---|---|
|
|
232
|
+
| assignments_full | 782 | 153 (−80%) | 133.8 | 111.8 |
|
|
233
|
+
| assignments_full + authz | 3,403 | 176 (−95%) | 168.2 | 114.0 |
|
|
234
|
+
| assignments_single_conversation | 330 | 22 (−93%) | 119.5 | 102.8 |
|
|
235
|
+
| assignments_single_conversation + authz | 2,631 | 34 (−99%) | 148.9 | 104.7 |
|
|
236
|
+
| assignments_page_100 | 5,102 | 1,233 (−76%) | 884.5 | 741.0 |
|
|
237
|
+
| gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 89.5 | 79.2 |
|
|
238
|
+
|
|
239
|
+
#### Second recording (2026-08-17, base: main at 8bff12d, after #64 and #66)
|
|
240
|
+
|
|
241
|
+
Re-measured after the rebase onto main. Both columns come from one sequential
|
|
242
|
+
run of `rake benchmark` on the same machine, large dataset.
|
|
243
|
+
|
|
244
|
+
| Scenario | Queries before | Queries after | Alloc MB before | Alloc MB after | ms(med) before | ms(med) after |
|
|
245
|
+
|---|---|---|---|---|---|---|
|
|
246
|
+
| assignments_full | 782 | 153 (−80%) | 98.8 | 80.4 | 708.6 | 522.3 |
|
|
247
|
+
| assignments_full + authz | 3,403 | 176 (−95%) | 133.6 | 82.8 | 1,208.1 | 473.9 |
|
|
248
|
+
| assignments_single_conversation | 330 | 22 (−93%) | 92.6 | 79.6 | 534.1 | 376.4 |
|
|
249
|
+
| assignments_single_conversation + authz | 2,631 | 34 (−99%) | 122.4 | 81.6 | 997.8 | 402.4 |
|
|
250
|
+
| assignments_page_100 | 5,102 | 1,233 (−76%) | 632.2 | 512.3 | 4,800.1 | 3,128.2 |
|
|
251
|
+
| gadgets_evidence_shape (no narrowed scope — control) | 146 | 146 | 64.9 | 57.9 | 443.6 | 340.2 |
|
|
252
|
+
|
|
253
|
+
Every query count reproduces the first recording exactly, which confirms #66
|
|
254
|
+
changed no query counts in these scenarios. The allocation figures dropped on
|
|
255
|
+
both sides, because #66 cut allocations independently — so the earlier "before"
|
|
256
|
+
column overstated the saving this change alone delivers.
|
|
257
|
+
|
|
258
|
+
Cumulative against the original baseline: the /policies shape is down from
|
|
259
|
+
1,728 queries to 22; the /evidence_checks shape from 2,550 to 146; page 100
|
|
260
|
+
from 5,102 to 1,233. The remaining residue is mostly mechanism 4 (per-record
|
|
261
|
+
meta lookups: 120 of the 153 queries in assignments_full).
|
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
|
|
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:
|
|
582
|
-
"updated_at": "2024-01-15T10:30:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
attachment.attached? ? attachment.blobs.to_a : []
|
|
23
38
|
end
|
|
24
39
|
|
|
25
|
-
def
|
|
26
|
-
|
|
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)
|