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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. 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
- The gem's original purpose is to make batch reports practical without naive
4
- per-record queries and reification. Both database round trips and repeated Ruby
5
- reconstruction are therefore design concerns. A small SQL query count alone
6
- does not establish that a batch API meets that purpose.
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
- The [follow-up reconstruction investigation](../benchmark/investigation/README.md)
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
- The benchmark exercises `analyze_scope` with genuine PaperTrail history. It is
18
- a local SQLite baseline for finding work inside the gem, not a production
19
- latency guarantee or a comparison of SQL dialects.
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
- ## Limited associations: 2026-10-29
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
- A `limit` or `offset` with a total order on integer or boolean columns, and an
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
- | Before, with `allow_expensive: true` | 56 / 200 / 740 | 15 / 75 / 300 |
103
- | After, without an opt-in | 20 / 20 / 20 | 7 / 35 / 140 |
104
-
105
- The `reify` calls still grew with the roots: each root's own versions were
106
- still rebuilt that way, until the change above.
107
-
108
- ## Direct reconstruction follow-up: 2026-09-07
109
-
110
- Implementation: `1b6b1b9`, followed by belongs-to discovery fix `f6a4ae2`.
111
- [Raw measurements](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/direct-reconstruction-2026-09-07.json)
112
- include all samples, typed parity checks and query origins. Timing runs overlapped
113
- development checks, so they do not establish a controlled speedup.
114
-
115
- | Probe | SQL at 1 / 5 / 20 roots | Historical reify / prepared-instance calls |
116
- | --- | --- | --- |
117
- | Scalar activity | 6 / 6 / 6 | 0 / 0 |
118
- | Direct comments activity | 12 / 12 / 12 | 0 / 0 |
119
- | Comments without changesets | 12 / 12 / 12 | 0 / 0 |
120
- | Direct comments checkpoints | 10 / 10 / 10 | 0 / 0 |
121
- | Belongs-to activity | 9 / 9 / 9 | 0 / 0 |
122
-
123
- Live records and version rows still instantiate when loaded in batches. Zero
124
- historical instances does not mean zero allocation or constant total CPU cost.
125
- The missing-changeset case previously used 15 / 27 / 72 queries. Limited,
126
- owner-scoped, default-scoped and collection-source through paths now refuse
127
- before report SQL by default. With explicit opt-in, the five-root through probe
128
- used 199 queries and 75 reify calls, but no unrelated authorship loads.
129
-
130
- Three diagnostic samples at 500 roots had median 742.3 ms for checkpoints and
131
- 1,365.5 ms for retained activity; each used six SQL queries and zero reifications.
132
- One root with 5,000 updates had median 1,154.2 ms and zero reifications. Retained
133
- Ruby memory was about 15.0, 20.8 and 19.9 MiB respectively. These timings do not
134
- establish a retained-activity improvement.
135
-
136
- A querying initialization callback still produced 10 / 26 / 86 queries, and a
137
- querying historical predicate produced 8 / 12 / 27. Custom behavior, advanced
138
- graph reconstruction, and non-leaf activity fallbacks may still instantiate
139
- historical records, as does a polymorphic `belongs_to` to a default-scoped
140
- class. There is no blanket no-N+1 guarantee.
141
-
142
- Production parity matches PaperTrail for STI, enum, decimal, date, boolean, JSON
143
- and serialized values, including missing fields supplied by callbacks/default
144
- scopes through the compatibility fallback. From a repository checkout (benchmark
145
- scripts are not included in the installed gem), reproduce these checks with:
146
-
147
- ```console
148
- PTD_PRODUCTION=1 mise exec -- bundle exec ruby benchmark/investigation/scalar_parity.rb
149
- mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb comments_without_changes 20
150
- mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb through_has_many 5
151
- ```
152
-
153
- ## Reproduce
154
-
155
- From a repository checkout:
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 install
159
- mise exec -- bundle exec ruby benchmark/reporting.rb --output /tmp/reporting.json
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
- The default run takes three samples of each population size below. Each sample
164
- starts a fresh Ruby process and creates its own in-memory SQLite database. It
165
- does not use `PAPER_TRAIL_DIFF_DATABASE_URL` or an application's database. Setup,
166
- seeding, and the initial garbage collection are excluded from report timing.
167
- The measured call runs without the ActiveRecord query cache. Successful calls
168
- validate the selected root count; the refusal scenario must raise
169
- `BatchLimitExceededError`.
170
-
171
- Each article has a title, status, and 1 KiB body, using PaperTrail's default YAML
172
- serializer. Except for the long-history case, each has one create and five update
173
- versions; `deleted_roots` adds a destroy event. The window includes all seeded
174
- versions and uses `close_on: :current`.
175
- There are no associations. The fixture has an `(item_type, item_id)` version
176
- index; a different schema, serializer, database, or graph needs its own run.
177
-
178
- | Scenario | Population | Selection and output |
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.