paper_trail_diff 0.14.0 → 0.15.0
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/CHANGELOG.md +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
data/docs/performance.md
CHANGED
|
@@ -1,402 +1,127 @@
|
|
|
1
1
|
# Reporting performance
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
What the batch methods cost, what still grows with a report, and what to do
|
|
4
|
+
about it. The numbers come from a developer workstation on SQLite (see
|
|
5
|
+
[measuring](#measuring)). Use them to compare shapes and options, not as
|
|
6
|
+
latency promises.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
records the pre-fix N+1 paths and direct-snapshot experiments. Production now uses
|
|
10
|
-
prepared event successors even without changesets, and directly normalizes plain
|
|
11
|
-
root/direct-child states. Scopes and default scopes made of plain conditions,
|
|
12
|
-
and a `limit` or `offset` with a total integer order, are read from prepared
|
|
13
|
-
history; other scoped batch paths, such as owner-dependent scopes, require
|
|
14
|
-
`allow_expensive: true`, and that opt-in can still cause N+1 work. Model callbacks,
|
|
15
|
-
custom types and callable predicates also remain outside a flat-query guarantee.
|
|
8
|
+
## Use the batch methods
|
|
16
9
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
`report`, `analyze_scope`, `analyze_many`, and `compare_many` load history for
|
|
11
|
+
all their records together, so their query count depends on the associations
|
|
12
|
+
you ask for, not on how many records you report on. Calling `compare`,
|
|
13
|
+
`analyze`, or `reify` in a loop does the same work once per record.
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
Most association shapes are read from stored history without building a
|
|
16
|
+
model instance for each past state. A `report` over the last hour that asks
|
|
17
|
+
for one association, at 1, 5, and 20 records:
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
ordered `has_one`, are now read from prepared history: each record's stored
|
|
25
|
-
values decide whether it meets the scope's conditions, and the matching records
|
|
26
|
-
are ranked as the association's query ranks them. Batch methods no longer need
|
|
27
|
-
`allow_expensive: true` for them. The probe's `limited` scenario reports
|
|
28
|
-
articles with `has_many :limited_comments, -> { order(:id).limit(1) }`:
|
|
29
|
-
|
|
30
|
-
| `limited` | SQL at 1 / 5 / 20 roots | `reify` calls | Model initializations |
|
|
31
|
-
| --- | --- | --- | --- |
|
|
32
|
-
| before, with `allow_expensive: true` | 24 / 72 / 252 | 8 / 40 / 160 | 30 / 150 / 600 |
|
|
33
|
-
| after, without an opt-in | 13 / 17 / 32 | 0 / 0 / 0 | 10 / 50 / 200 |
|
|
34
|
-
|
|
35
|
-
One query per root remains: the current end loads each article's limited
|
|
36
|
-
comments on its own, because Active Record's preloader applies a limit or
|
|
37
|
-
offset to all owners' records at once rather than to each owner's.
|
|
38
|
-
|
|
39
|
-
## Scoped associations: 2026-10-29
|
|
40
|
-
|
|
41
|
-
An association scope, or a target's default scope, made of plain conditions
|
|
42
|
-
(`where(reviewer_id: nil)`, `where.not(body: "hidden")`) used to fall back to
|
|
43
|
-
PT-AT for every record, so batch methods required `allow_expensive: true` for
|
|
44
|
-
it. It is now read from prepared history, checking each record's stored values
|
|
45
|
-
against the scope. The probe below reports articles with their comments,
|
|
46
|
-
through `has_many :scoped_comments, -> { where(reviewer_id: nil) }`
|
|
47
|
-
(`scoped`) and through a `TrackedComment` default scope (`default_scope`):
|
|
48
|
-
|
|
49
|
-
| Scenario and code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
|
|
50
|
-
| --- | --- | --- |
|
|
51
|
-
| `scoped`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
|
|
52
|
-
| `scoped`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
|
|
53
|
-
| `default_scope`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
|
|
54
|
-
| `default_scope`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
|
|
55
|
-
|
|
56
|
-
Model initializations fell from 17 / 85 / 340 (`scoped`) and 19 / 95 / 380
|
|
57
|
-
(`default_scope`) to 10 / 50 / 200, the same as unscoped `comments`. The
|
|
58
|
-
default-scoped comments are read without `reify` because their scope assigns
|
|
59
|
-
no attributes to a new record; one such as `where(hidden: false)` does, so
|
|
60
|
-
those records are still rebuilt with `reify`, though the query count stays
|
|
61
|
-
flat. `limited` (`order(:id).limit(1)`) and `owner` (a scope that takes the
|
|
62
|
-
article) still raise without the opt-in, and cost what they did.
|
|
63
|
-
|
|
64
|
-
## Fewer `reify` calls: 2026-10-29
|
|
65
|
-
|
|
66
|
-
The `reify` calls that remained came from one place: building a root's
|
|
67
|
-
snapshot from its own version (`HistoricalSnapshotStore#snapshot_from_version`).
|
|
68
|
-
That snapshot is built from stored attributes only when every requested
|
|
69
|
-
association can be read the same way, and `has_many :through` and polymorphic
|
|
70
|
-
`belongs_to` could not. So the whole root was rebuilt with `reify`, and its
|
|
71
|
-
associations as model instances. Both are now read from stored attributes,
|
|
72
|
-
resolving each part of a through as the record path does, and keeping only
|
|
73
|
-
join records of the `source_type:` when there is one. A type that names no
|
|
74
|
-
class, a default-scoped or unversioned target, and every scoped association
|
|
75
|
-
still take the record path as before.
|
|
76
|
-
|
|
77
|
-
Counts from `benchmark/investigation/reconstruction.rb` at 1 / 5 / 20 roots.
|
|
78
|
-
`source_type_through` and `polymorphic_belongs_to` are new probe scenarios: a
|
|
79
|
-
holder's links to an author and to a tag, one moving to another author.
|
|
80
|
-
|
|
81
|
-
| Scenario | `reify` before | `reify` after | Model instances before | Model instances after |
|
|
82
|
-
| --- | --- | --- | --- | --- |
|
|
83
|
-
| `through_has_many` | 7 / 35 / 140 | 0 / 0 / 0 | 36 / 180 / 720 | 14 / 70 / 280 |
|
|
84
|
-
| `source_type_through` | 7 / 35 / 140 | 0 / 0 / 0 | 45 / 225 / 900 | 22 / 110 / 440 |
|
|
85
|
-
| `polymorphic_belongs_to` | 2 / 10 / 40 | 0 / 0 / 0 | 14 / 70 / 280 | 10 / 50 / 200 |
|
|
86
|
-
|
|
87
|
-
Query counts are unchanged: 20, 20 and 14 at every size. `belongs_to`,
|
|
88
|
-
`comments` and `checkpoints` made no `reify` calls before and are unchanged.
|
|
89
|
-
Model instances still grow with the roots: they are the snapshots' own records
|
|
90
|
-
and, for the two polymorphic scenarios, a few states the activity timeline
|
|
91
|
-
builds as records.
|
|
92
|
-
|
|
93
|
-
## Through associations: 2026-10-29
|
|
94
|
-
|
|
95
|
-
A `has_many :through` whose source is a `has_many` used to fall back to PT-AT
|
|
96
|
-
for every record, so batch methods required `allow_expensive: true` for it. It
|
|
97
|
-
now reads prepared history like any other through. The five-root
|
|
98
|
-
`through_has_many` probe below, an author's comments through articles:
|
|
99
|
-
|
|
100
|
-
| Code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
|
|
19
|
+
| Association | Queries | `reify` calls |
|
|
101
20
|
| --- | --- | --- |
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
21
|
+
| None | 9 / 9 / 9 | 0 |
|
|
22
|
+
| `has_many` | 17 / 17 / 17 | 0 |
|
|
23
|
+
| `belongs_to` | 15 / 15 / 15 | 0 |
|
|
24
|
+
| A `has_many` scope of plain conditions, or a default scope | 17 / 17 / 17 | 0 |
|
|
25
|
+
| `has_many :through` | 25 / 25 / 25 | 0 |
|
|
26
|
+
| Polymorphic `belongs_to` | 19 / 19 / 19 | 0 |
|
|
27
|
+
| `has_many :through` with `source_type:` | 25 / 25 / 25 | 0 |
|
|
28
|
+
| A `has_many` with `order(:id).limit(1)` | 18 / 22 / 37 | 0 |
|
|
29
|
+
| A scope that takes the owner, read with PT-AT | 29 / 77 / 257 | 9 / 45 / 180 |
|
|
30
|
+
| None, with a querying `after_initialize` | 13 / 29 / 89 | 2 / 10 / 40 |
|
|
31
|
+
| None, with a callable `historical_filter:` that queries | 15 / 19 / 34 | 2 / 10 / 40 |
|
|
32
|
+
|
|
33
|
+
The last four rows grow with the number of records, for the reasons in
|
|
34
|
+
[what costs a query per record](#what-costs-a-query-per-record).
|
|
35
|
+
|
|
36
|
+
## What still grows
|
|
37
|
+
|
|
38
|
+
Flat query counts do not make a report's cost flat:
|
|
39
|
+
|
|
40
|
+
- **Ruby work grows with the history you report on.** Every version in the
|
|
41
|
+
window is decoded and compared: an `analyze_scope` of articles with five
|
|
42
|
+
updates each took about 1.5 ms per article, and one article with 5,000
|
|
43
|
+
updates took 1.2 s.
|
|
44
|
+
- **Memory grows with the number of records.** A `report` of 1,000 articles
|
|
45
|
+
with their comments, replies, and author peaked at about 100 KB per
|
|
46
|
+
article. `limit:` is a safety ceiling, not a page size, so to bound memory,
|
|
47
|
+
page with the relation (`Article.where(...).order(:id).limit(200)`), or
|
|
48
|
+
report on shorter windows.
|
|
49
|
+
- **A wide collection in an activity timeline grows with its size times its
|
|
50
|
+
changes.** Each step copies the collection's list of records, so an
|
|
51
|
+
activity timeline over `n` comments that changed `m` times does about
|
|
52
|
+
`n × m` work: 800 comments edited 1,000 times took about 3 s. `timeline`, which has a step per
|
|
53
|
+
version of the root rather than per change, does less.
|
|
54
|
+
- **`snapshots: true` and `recorded_changes: true` cost memory and time.** Ask
|
|
55
|
+
for them only when you use them.
|
|
56
|
+
|
|
57
|
+
## Database indexes
|
|
58
|
+
|
|
59
|
+
On a large version table, add both indexes from the
|
|
60
|
+
[README](../README.md#performance-and-defaults). Without the second, every
|
|
61
|
+
`since:` or `within:` report reads all of the model's versions to find the
|
|
62
|
+
records that changed: with 5 million versions on PostgreSQL, about 230 ms per
|
|
63
|
+
report instead of 2 ms. [Database indexes](architecture.md#database-indexes)
|
|
64
|
+
explains why.
|
|
65
|
+
|
|
66
|
+
## What costs a query per record
|
|
67
|
+
|
|
68
|
+
- **A path PT-AT reads.** History cannot read a few scopes, such as one that
|
|
69
|
+
takes the owner, SQL text, or a `limit` ordered by a string. PT-AT reads
|
|
70
|
+
those, one record at a time and only approximately, so every method refuses
|
|
71
|
+
them unless you pass `allow_approximate: true`. See
|
|
72
|
+
[costly associations](reference.md#costly-associations).
|
|
73
|
+
- **A limited association on the current record.** Active Record's `preload`
|
|
74
|
+
applies a `limit` or `offset` across all the records it loads, so each
|
|
75
|
+
record's limited association is loaded on its own: one query per record.
|
|
76
|
+
The deprecated `reload_live_endpoints: false` does the same for one you
|
|
77
|
+
preloaded.
|
|
78
|
+
- **Your own code.** Model callbacks that query, such as an `after_initialize`,
|
|
79
|
+
custom attribute types, and a callable `historical_filter:` run for each
|
|
80
|
+
state they are given.
|
|
81
|
+
|
|
82
|
+
## Selecting by past values
|
|
83
|
+
|
|
84
|
+
`historical_filter:` reads the window's history of the records the relation
|
|
85
|
+
selects, and of deleted records, which a relation cannot test. A hash of
|
|
86
|
+
attributes is read straight from stored history. A callable is given a model
|
|
87
|
+
instance for each state, so it costs a `reify` per version, plus its own
|
|
88
|
+
queries. `max_filter_versions:` (10,000 by default) caps the versions it loads.
|
|
89
|
+
Either way, every version of every candidate in the window is decoded: with
|
|
90
|
+
1,000 candidates and no match, a hash and a callable each took about 0.8 s.
|
|
91
|
+
Finding the relation's records among the candidates takes up to two queries
|
|
92
|
+
per 250 of them.
|
|
93
|
+
|
|
94
|
+
## Large reports
|
|
95
|
+
|
|
96
|
+
- Report on a window with `since:` or `within:`, not on all history.
|
|
97
|
+
- Page with the relation, and set `limit:` a little above the page size, so a
|
|
98
|
+
page that grew raises instead of running long.
|
|
99
|
+
- Ask only for the associations the report shows.
|
|
100
|
+
- For a dashboard, run the report in a background job and keep the result.
|
|
101
|
+
|
|
102
|
+
## Measuring
|
|
103
|
+
|
|
104
|
+
From a repository checkout (the scripts are not in the installed gem):
|
|
156
105
|
|
|
157
106
|
```console
|
|
158
|
-
mise exec -- bundle
|
|
159
|
-
mise exec -- bundle exec ruby benchmark/reporting.rb --
|
|
160
|
-
mise exec -- bundle exec ruby benchmark/reporting.rb --case no_matches --size 1000 --samples 3
|
|
107
|
+
mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb comments 20
|
|
108
|
+
mise exec -- bundle exec ruby benchmark/reporting.rb --case many_roots --samples 3
|
|
161
109
|
```
|
|
162
110
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
| `many_roots` | 100 / 500 roots | Default analysis, including checkpoint timelines |
|
|
181
|
-
| `retained_activity` | 100 / 500 roots | Adds `activity: true, snapshots: true` |
|
|
182
|
-
| `long_history` | One root, 1,000 / 5,000 updates | Default analysis |
|
|
183
|
-
| `sparse_matches` | 100 / 1,000 roots | Historical status matches one root in every 100 |
|
|
184
|
-
| `no_matches` | 100 / 1,000 roots | Historical status matches no recorded state |
|
|
185
|
-
| `limit_refusal` | 100 / 1,000 roots | Every non-nil historical state matches; `limit: 5` refuses the sixth root |
|
|
186
|
-
| `attribute_no_matches` | 100 / 1,000 roots | Attribute hash matches no recorded state, without candidate reification |
|
|
187
|
-
| `attribute_sparse_matches` | 100 / 1,000 roots | Attribute hash matches one root in every 100 |
|
|
188
|
-
| `deleted_roots` | 100 / 500 roots | Batched missing roots with retained activity snapshots |
|
|
189
|
-
|
|
190
|
-
## Baseline: 2026-09-06
|
|
191
|
-
|
|
192
|
-
The library was unchanged at revision
|
|
193
|
-
`389ad896fbc2ef5942c6ba34b605a1eb0b3f397c`. Environment: Ruby 4.0.1,
|
|
194
|
-
ActiveRecord 8.1.3.1, PaperTrail 17.0.0, SQLite 3.53.2, Linux x86-64 on WSL2
|
|
195
|
-
(`6.18.33.2-microsoft-standard-WSL2`), AMD Ryzen 7 7800X3D, about 15.5 GiB
|
|
196
|
-
WSL system memory. No test suite or app server ran alongside the final
|
|
197
|
-
samples. This is a developer workstation, not an isolated benchmark host.
|
|
198
|
-
|
|
199
|
-
The [raw samples](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/reporting-2026-09-06.json) include runtime
|
|
200
|
-
versions, allocations, SQL duration, query-cache hits, record instantiation,
|
|
201
|
-
reification counts, and memory readings. Times below are medians of three fresh
|
|
202
|
-
processes; the range is the minimum–maximum. Query and instantiation counts were identical across each set of three samples.
|
|
203
|
-
Reify counts are medians: one 100-root retained-activity sample made 599 calls
|
|
204
|
-
instead of 600; the cause of that small variation was not established. These
|
|
205
|
-
internal counts are observations, not API guarantees. SQL time is included in
|
|
206
|
-
total time.
|
|
207
|
-
|
|
208
|
-
| Scenario | Size | Total ms (range) | SQL ms | Queries | Reify calls | AR records instantiated |
|
|
209
|
-
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
210
|
-
| `many_roots` | 100 | 304.5 (301.1–328.6) | 3.9 | 6 | 1,200 | 800 |
|
|
211
|
-
| `many_roots` | 500 | 1,463.0 (1,460.8–1,824.8) | 16.8 | 6 | 6,000 | 4,000 |
|
|
212
|
-
| `retained_activity` | 100 | 302.3 (272.2–354.1) | 4.3 | 6 | 600 | 800 |
|
|
213
|
-
| `retained_activity` | 500 | 1,452.3 (1,371.7–1,520.6) | 16.3 | 6 | 3,000 | 4,000 |
|
|
214
|
-
| `long_history` | 1,000 | 505.5 (463.3–550.7) | 4.7 | 6 | 2,002 | 1,003 |
|
|
215
|
-
| `long_history` | 5,000 | 2,414.5 (2,334.5–3,077.6) | 19.2 | 6 | 10,002 | 5,003 |
|
|
216
|
-
| `sparse_matches` | 100 | 113.1 (95.2–114.1) | 3.6 | 7 | 608 | 608 |
|
|
217
|
-
| `sparse_matches` | 1,000 | 1,043.1 (830.1–1,213.0) | 36.1 | 10 | 6,080 | 6,080 |
|
|
218
|
-
| `no_matches` | 100 | 103.3 (99.9–109.5) | 3.6 | 2 | 600 | 600 |
|
|
219
|
-
| `no_matches` | 1,000 | 878.4 (822.3–922.5) | 32.0 | 5 | 6,000 | 6,000 |
|
|
220
|
-
| `limit_refusal` | 100 | 7.1 (7.0–7.7) | 2.7 | 2 | 12 | 600 |
|
|
221
|
-
| `limit_refusal` | 1,000 | 17.7 (17.5–17.9) | 8.6 | 2 | 12 | 1,500 |
|
|
222
|
-
|
|
223
|
-
Memory medians (MiB; 1 MiB = 1,048,576 bytes):
|
|
224
|
-
|
|
225
|
-
| Scenario | Size | Retained Ruby | RSS before | RSS after |
|
|
226
|
-
| --- | ---: | ---: | ---: | ---: |
|
|
227
|
-
| `many_roots` | 100 | 3.04 | 70.8 | 108.1 |
|
|
228
|
-
| `many_roots` | 500 | 15.09 | 74.6 | 136.2 |
|
|
229
|
-
| `retained_activity` | 100 | 4.18 | 70.5 | 99.4 |
|
|
230
|
-
| `retained_activity` | 500 | 20.75 | 75.0 | 160.8 |
|
|
231
|
-
| `long_history` | 1,000 | 4.06 | 71.8 | 116.9 |
|
|
232
|
-
| `long_history` | 5,000 | 20.11 | 78.3 | 166.2 |
|
|
233
|
-
| `sparse_matches` | 100 | 0.07 | 70.9 | 91.9 |
|
|
234
|
-
| `sparse_matches` | 1,000 | 0.34 | 80.7 | 165.1 |
|
|
235
|
-
| `no_matches` | 100 | 0.01 | 70.4 | 91.6 |
|
|
236
|
-
| `no_matches` | 1,000 | 0.01 | 81.0 | 164.8 |
|
|
237
|
-
| `limit_refusal` | 100 | 0.01 | 70.9 | 71.4 |
|
|
238
|
-
| `limit_refusal` | 1,000 | 0.01 | 81.0 | 81.2 |
|
|
239
|
-
|
|
240
|
-
### What memory readings mean
|
|
241
|
-
|
|
242
|
-
Retained Ruby memory is the change in `ObjectSpace.memsize_of_all` after a full
|
|
243
|
-
GC while the result (or expected error) remains reachable. It includes lazily
|
|
244
|
-
initialized library state, not just result objects, and excludes native
|
|
245
|
-
allocations. RSS is whole-process resident memory before and immediately after
|
|
246
|
-
the report, before the final GC. It includes Ruby, libraries, and the in-memory
|
|
247
|
-
SQLite database. The raw process high-water RSS also includes startup and seeding;
|
|
248
|
-
it is not an isolated peak for the report. RSS fields are unavailable off Linux.
|
|
249
|
-
The measurements should not be interpreted as exact per-request memory budgets.
|
|
250
|
-
|
|
251
|
-
## Findings from the original baseline
|
|
252
|
-
|
|
253
|
-
The findings below describe the original revision. The follow-up measurements
|
|
254
|
-
and remaining limitations are recorded after this section.
|
|
255
|
-
|
|
256
|
-
1. **Reconstruction deserves attention before query-count tuning.** Default
|
|
257
|
-
analysis keeps the same query count as population/history grows, but reifies
|
|
258
|
-
each version twice in these fixtures. The retained-activity path makes
|
|
259
|
-
approximately half as many calls. Investigate sharing reconstruction between endpoint and checkpoint
|
|
260
|
-
analysis, preserving immutable results, ordering, and association semantics.
|
|
261
|
-
Fewer reifications can offset the extra work of retained activity; do not
|
|
262
|
-
assume that enabling activity always makes a report slower.
|
|
263
|
-
2. **A small result does not mean a cheap historical predicate.** Nonmatching
|
|
264
|
-
candidates require all their recorded states to be tried. A 1,000-root empty
|
|
265
|
-
result still reifies 6,000 versions. A matched-root `limit:` cannot cap that
|
|
266
|
-
work. Do not present arbitrary `historical_filter:` predicates as an efficient
|
|
267
|
-
large-report path in their current form. Evaluate whether a constrained
|
|
268
|
-
attribute predicate can use bulk-loaded immutable state instead of creating
|
|
269
|
-
an ActiveRecord object per version. Any retained arbitrary callback needs an
|
|
270
|
-
explicit work budget or clearly restricted use. A background job can move the
|
|
271
|
-
delay out of a request, but does not fix the reconstruction cost.
|
|
272
|
-
Do not replace historical selection with current-state
|
|
273
|
-
filtering merely to make it faster: that changes which roots qualify.
|
|
274
|
-
3. **The safety ceiling stops reification before it stops batch loading.** With
|
|
275
|
-
`limit: 5`, refusal takes 12 reify calls: the nil create and first update state
|
|
276
|
-
for each of six roots. At 1,000 candidates, 1,500 version records have already
|
|
277
|
-
been instantiated for the first 250-root batch. Investigate smaller or streamed
|
|
278
|
-
candidate loading if this is a memory problem in an application's workload.
|
|
279
|
-
Changes must preserve match-before-limit semantics and deterministic selection.
|
|
280
|
-
4. **Retained snapshots have a measurable memory cost.** Request them when the
|
|
281
|
-
consumer needs historical context. Many roots and long histories increase
|
|
282
|
-
retained data even when database round trips stay flat. Measure explicitly
|
|
283
|
-
requested associations next; this root-only baseline cannot characterize
|
|
284
|
-
their additional queries or reconstruction cost.
|
|
285
|
-
5. **The test app's deleted-history fallback is an N+1 pattern.** Its
|
|
286
|
-
`DeletedArticleHistory#call` loops over missing identities, selects each
|
|
287
|
-
destroy version, calls `reify`, and then calls `activity_timeline` separately.
|
|
288
|
-
Its 25-root cap limits exposure but does not make the approach batched.
|
|
289
|
-
This path is outside the measurements above and should not become the
|
|
290
|
-
recommended reporting recipe. A gem-level batch operation should load missing
|
|
291
|
-
roots and their histories together and return structured deletion/unavailable
|
|
292
|
-
results, so callers do not rebuild this loop themselves.
|
|
293
|
-
|
|
294
|
-
Before adding a higher-level reporting facade, address duplicate reconstruction
|
|
295
|
-
and deleted-root batching, then decide whether to redesign or restrict historical
|
|
296
|
-
predicates. Verify both query growth and reification growth over representative
|
|
297
|
-
populations. Linear work to produce requested output can be necessary; repeated
|
|
298
|
-
reconstruction of the same state and scanning thousands of states for an empty
|
|
299
|
-
report should not be accepted merely because SQL round trips are bounded.
|
|
300
|
-
|
|
301
|
-
The Rails test app now makes its missing-closing-boundary demonstration opt-in.
|
|
302
|
-
Previously that example reran selection on every report request, including
|
|
303
|
-
historical predicates. Its regression test checks that a normal historical report
|
|
304
|
-
selects candidates once. No gem runtime optimization is included with this
|
|
305
|
-
baseline; it provides a repeatable comparison for a subsequent focused change.
|
|
306
|
-
|
|
307
|
-
Do not enforce these workstation timings as CI thresholds. Re-run matching
|
|
308
|
-
workloads before and after an optimization, compare structural counts as well
|
|
309
|
-
as timing and memory, and validate the existing correctness suites separately.
|
|
310
|
-
|
|
311
|
-
## Follow-up: batched reporting, 2026-09-07 UTC
|
|
312
|
-
|
|
313
|
-
Runtime revision `fb076a4ebde62f45d639088d0bd6f843ce0879f8` addresses duplicate
|
|
314
|
-
checkpoint reconstruction, repeated association event discovery, and the test
|
|
315
|
-
app's per-deleted-root history loop. `report(scope, since:, limit:)` supplies
|
|
316
|
-
recent activity through the current saved ending. `include_deleted: true`
|
|
317
|
-
returns missing-root analyses in a separate batch with the requested graph.
|
|
318
|
-
The same combined root ceiling is checked before either population is analyzed.
|
|
319
|
-
|
|
320
|
-
Historical attribute hashes read recorded scalar state without reifying every
|
|
321
|
-
candidate. Arbitrary callables retain their earlier semantics and reconstruction
|
|
322
|
-
cost, with a default `max_filter_versions: 10_000` ceiling on loaded candidate
|
|
323
|
-
versions across batches. One extra row detects overflow before evaluating that
|
|
324
|
-
batch. This is separate from the matched-root ceiling.
|
|
325
|
-
|
|
326
|
-
Association regression tests compare one and five roots for live activity and
|
|
327
|
-
deleted activity, and exercise through-association events. The test app also
|
|
328
|
-
checks that adding deleted articles with children does not add per-article
|
|
329
|
-
queries. These tests establish query growth for their requested graphs; the
|
|
330
|
-
timings below remain root-only measurements.
|
|
331
|
-
|
|
332
|
-
The [54 follow-up samples](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/reporting-after-2026-09-07.json)
|
|
333
|
-
use the same runtime versions, workstation, schema, and measurement procedure
|
|
334
|
-
as the baseline. No test suite or app server ran alongside them. Each row gives
|
|
335
|
-
the median of three fresh processes, with the elapsed-time range in parentheses.
|
|
336
|
-
Query, reify, and AR instantiation counts were identical within each set of three.
|
|
337
|
-
Memory uses the same definitions and limitations as the baseline.
|
|
338
|
-
|
|
339
|
-
| Scenario | Size | Total ms (range) | Queries | Reify calls | Retained Ruby MiB | RSS after MiB |
|
|
340
|
-
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
341
|
-
| `many_roots` | 100 | 157.1 (154.3–179.1) | 6 | 600 | 3.02 | 84.5 |
|
|
342
|
-
| `many_roots` | 500 | 839.7 (804.8–843.8) | 6 | 3,000 | 14.98 | 142.6 |
|
|
343
|
-
| `retained_activity` | 100 | 254.4 (250.4–275.8) | 6 | 600 | 4.18 | 98.7 |
|
|
344
|
-
| `retained_activity` | 500 | 1,344.7 (1,320.1–1,599.8) | 6 | 3,000 | 20.75 | 160.0 |
|
|
345
|
-
| `long_history` | 1,000 | 280.9 (256.1–307.8) | 6 | 1,001 | 4.02 | 111.1 |
|
|
346
|
-
| `long_history` | 5,000 | 1,392.0 (1,364.1–1,409.8) | 6 | 5,001 | 19.92 | 170.9 |
|
|
347
|
-
| `sparse_matches` | 100 | 94.6 (91.3–100.4) | 7 | 602 | 0.07 | 91.9 |
|
|
348
|
-
| `sparse_matches` | 1,000 | 844.2 (835.6–962.9) | 10 | 6,020 | 0.33 | 164.8 |
|
|
349
|
-
| `no_matches` | 100 | 85.3 (81.7–92.4) | 2 | 600 | 0.01 | 94.6 |
|
|
350
|
-
| `no_matches` | 1,000 | 869.3 (798.8–909.7) | 5 | 6,000 | 0.01 | 165.4 |
|
|
351
|
-
| `limit_refusal` | 100 | 10.9 (10.2–11.0) | 2 | 12 | 0.01 | 70.3 |
|
|
352
|
-
| `limit_refusal` | 1,000 | 32.2 (30.5–33.4) | 2 | 12 | 0.01 | 81.1 |
|
|
353
|
-
| `attribute_no_matches` | 100 | 89.9 (83.0–120.9) | 2 | 0 | 0.01 | 91.7 |
|
|
354
|
-
| `attribute_no_matches` | 1,000 | 680.3 (680.2–721.8) | 5 | 0 | 0.01 | 164.7 |
|
|
355
|
-
| `attribute_sparse_matches` | 100 | 82.6 (74.6–86.8) | 7 | 6 | 0.07 | 91.7 |
|
|
356
|
-
| `attribute_sparse_matches` | 1,000 | 759.7 (742.8–762.1) | 10 | 60 | 0.33 | 166.3 |
|
|
357
|
-
| `deleted_roots` | 100 | 340.4 (304.0–364.2) | 5 | 700 | 4.44 | 103.1 |
|
|
358
|
-
| `deleted_roots` | 500 | 1,506.4 (1,487.2–1,585.5) | 5 | 3,500 | 22.08 | 164.9 |
|
|
359
|
-
|
|
360
|
-
At 500 roots, default analysis is about 43% faster than the baseline and reifies
|
|
361
|
-
each checkpoint once instead of twice. Its retained Ruby memory barely changes;
|
|
362
|
-
this is primarily a reconstruction improvement, not a result-memory reduction.
|
|
363
|
-
The 1,000-root attribute no-match case avoids all 6,000 candidate reifications but
|
|
364
|
-
still spends 680 ms reading and deserializing states. Its RSS remains comparable
|
|
365
|
-
to the callable case. Avoiding reification alone does not eliminate scan cost.
|
|
366
|
-
|
|
367
|
-
The refusal case regressed: at 1,000 candidates it takes 32.2 ms versus 17.7 ms,
|
|
368
|
-
with median SQL time rising from 8.6 to 24.0 ms. Both runs instantiate 1,500 version
|
|
369
|
-
records and make 12 reify calls. The new loading ceiling remains useful, but this
|
|
370
|
-
query deserves further tuning; the change is not a speedup for every workload.
|
|
371
|
-
Deleted history has no original timed counterpart here. Its five queries at both
|
|
372
|
-
100 and 500 roots demonstrate batching; output reconstruction still scales.
|
|
373
|
-
|
|
374
|
-
### Remaining performance limits
|
|
375
|
-
|
|
376
|
-
- **Reconstruction still scales with requested history.** Plain roots and direct
|
|
377
|
-
children avoid historical AR objects, but decoding and comparing their recorded
|
|
378
|
-
states remains linear work. Compatibility fallbacks still construct records.
|
|
379
|
-
Arbitrarily large histories are not cheap.
|
|
380
|
-
- **Callable predicates can still be expensive or issue N+1 queries.** Prefer
|
|
381
|
-
attribute hashes for batch reports. The version-loading budget cannot control
|
|
382
|
-
queries, network calls, or CPU work inside application callbacks. Hash predicates
|
|
383
|
-
still deserialize candidate states and are not SQL-indexed historical searches.
|
|
384
|
-
- **Advanced association fallbacks can still query per record.** Scoped or
|
|
385
|
-
default-scoped paths and other graphs that need PT-AT fallback reconstruction
|
|
386
|
-
require explicit `allow_expensive: true` for batch APIs and must be measured
|
|
387
|
-
before being offered in a large report. The flat-query tests
|
|
388
|
-
do not establish a guarantee for every association configuration.
|
|
389
|
-
- **Limits are not streaming.** Candidate identities are still selected up front;
|
|
390
|
-
a small root ceiling can load a full 250-candidate version batch, subject to the
|
|
391
|
-
separate version budget. Requested analysis history and retained snapshots
|
|
392
|
-
still consume memory proportional to the data involved.
|
|
393
|
-
- **Selection reads the window's versions.** Historical filters test the states a
|
|
394
|
-
record held during the window, not continuous-time membership. Activity
|
|
395
|
-
selection also reaches roots through their requested associations, with one
|
|
396
|
-
query per association level, not per root. Those queries scan the window's
|
|
397
|
-
versions of each associated model and their `version_associations` rows; ids
|
|
398
|
-
stay in subqueries unless `versions.item_id` and the keys differ in type, in
|
|
399
|
-
which case they are read in slices of 250.
|
|
400
|
-
|
|
401
|
-
Batching scoped associations needs tests of membership over time like those
|
|
402
|
-
for `has_many :through`, especially around ownership changes.
|
|
111
|
+
`reconstruction.rb` prints the queries, `reify` calls, and model instances
|
|
112
|
+
one `report` makes for a scenario and a number of records; the table above
|
|
113
|
+
comes from its scenarios. `PTD_PROBE_ALLOW_APPROXIMATE=1` passes
|
|
114
|
+
`allow_approximate: true`.
|
|
115
|
+
`reporting.rb` times `analyze_scope` over articles with no associations, each
|
|
116
|
+
in a fresh process, and records memory; `--output` keeps the raw samples.
|
|
117
|
+
Both use an in-memory SQLite database, never your application's, and read
|
|
118
|
+
time from the [test clock](testing.md#test-clock), so a host clock that steps
|
|
119
|
+
backwards cannot reorder the versions they seed. Compare counts as well as
|
|
120
|
+
times, and run the correctness suites separately; no CI job checks timings.
|
|
121
|
+
|
|
122
|
+
The numbers here were measured on 2026-10-30 at revision `6c3d30b` with Ruby
|
|
123
|
+
4.0.1, Active Record 8.1.3.1, PaperTrail 17.0.0, and SQLite 3.53.2, on WSL2
|
|
124
|
+
on an AMD Ryzen 7 7800X3D. The memory, wide-collection, and index figures come
|
|
125
|
+
from one-off scripts rather than these two. The benchmark's raw samples are in
|
|
126
|
+
[`benchmark/results/reporting-2026-10-30.json`](../benchmark/results/reporting-2026-10-30.json),
|
|
127
|
+
next to earlier runs.
|