paper_trail_diff 0.11.0 → 0.13.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1367
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +59 -24
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/value_objects.rb +66 -0
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  70. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  71. data/lib/paper_trail_diff/version_query.rb +33 -0
  72. data/lib/paper_trail_diff/version_range.rb +10 -38
  73. data/lib/paper_trail_diff.rb +81 -43
  74. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  76. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  77. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  78. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  79. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  80. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  81. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  82. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  83. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  84. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  85. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  86. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  87. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  88. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  89. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  90. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  93. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  94. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  95. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  96. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  97. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  98. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  99. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  100. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  101. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  102. data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
  103. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  104. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  105. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  106. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  107. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  108. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  109. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  110. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  111. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  112. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  113. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  114. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  115. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  116. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  117. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  118. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  119. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  120. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  121. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  122. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  123. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  124. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  125. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  126. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  127. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  128. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  129. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  130. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  131. data/sig/generated/paper_trail_diff.rbs +37 -27
  132. metadata +31 -5
@@ -0,0 +1,88 @@
1
+ # How results are built
2
+
3
+ [Back to the API reference](reference.md). This page explains how the gem
4
+ rebuilds history and what that costs. You do not need it to use the gem.
5
+
6
+ ## Two ways to rebuild a timeline
7
+
8
+ `timeline` and `analyze(activity: true)` return the same root-record steps for
9
+ the same range. They build them differently:
10
+
11
+ - `timeline` rebuilds the root record and every chosen association separately
12
+ at each root version. Its cost grows with *root versions × size of the chosen
13
+ associations*. It does not depend on how often the associations changed.
14
+ - `analyze(activity: true)` rebuilds the state once, then applies each recorded
15
+ event in order. Its cost grows with *one rebuild + number of events*. It does
16
+ not depend on how large the chosen associations are.
17
+
18
+ Neither is always faster. Choose by the output you need. If performance matters,
19
+ measure on real history with the [instrumentation events](reference.md#instrumentation).
20
+ As a rough guide, `analyze(activity: true)` suits wide associations with modest
21
+ activity, and `timeline` suits a few root versions with many child events
22
+ between them.
23
+
24
+ ## Loading history
25
+
26
+ Single-record and batch methods load the history they need up front, walking
27
+ only the association paths you request:
28
+
29
+ 1. The root record's versions in the range, plus one version after it, which
30
+ holds the state the range's last change produced. When there is no later
31
+ version, the current record is used instead.
32
+ 2. For each requested association, the IDs of records that belonged to it at
33
+ the start of the range or joined it later, found through PT-AT's
34
+ `version_associations` table and the current rows.
35
+ 3. Those records' versions in the range, plus one version after it.
36
+
37
+ From that, the gem builds each state without querying again. Plain recorded
38
+ attributes are read straight from the stored version, without creating a model
39
+ instance. Models with initialization callbacks, custom attribute types or
40
+ defaults, default scopes, or encrypted attributes are rebuilt with PaperTrail's
41
+ `reify` instead, so their behavior is kept.
42
+
43
+ Each rebuilt state is an immutable snapshot. Activity timelines carry the
44
+ previous snapshot forward and replace only the parts an event changed, so an
45
+ edit to one comment does not copy the whole article again. Events in one
46
+ PT-AT transaction are applied together.
47
+
48
+ ## When the gem falls back to PT-AT
49
+
50
+ These association shapes cannot be read from the loaded history. The gem asks
51
+ PT-AT to rebuild them at each point instead, which costs queries per record and
52
+ resolves association membership by timestamp only:
53
+
54
+ - associations with a scope, including `limit` and owner-dependent scopes;
55
+ - targets with a default scope;
56
+ - targets that are not versioned;
57
+ - composite foreign keys;
58
+ - `has_many :through` associations whose source is not a `belongs_to`.
59
+
60
+ Batch methods reject the scoped and through cases unless you pass
61
+ `allow_expensive: true`; see [costly associations](reference.md#costly-associations).
62
+
63
+ HABTM membership comes from the snapshots PT-AT records with each transaction,
64
+ so it is only known at root versions saved in a transaction.
65
+
66
+ ## Database indexes
67
+
68
+ For large histories, add a composite index that matches how the gem reads
69
+ versions:
70
+
71
+ ```ruby
72
+ add_index :versions, %i[item_type item_id created_at id],
73
+ name: "index_versions_on_item_and_timeline"
74
+ ```
75
+
76
+ It helps most with repeated `within:` queries, which filter by `item_type`,
77
+ `item_id`, and `created_at`, then order by time and ID. Keep PT-AT's own index
78
+ on `version_associations` (`foreign_key_name`, `foreign_key_id`,
79
+ `foreign_type`).
80
+
81
+ ## Remaining costs
82
+
83
+ - An activity timeline produces one step per event, so a very busy range is
84
+ large no matter how it is built. Bound or paginate wide ranges in requests
85
+ that must respond quickly.
86
+ - Repeated events in one very wide collection still copy that collection's
87
+ list of records for each step. The comparison work stays small, but memory
88
+ traffic grows with *events × collection size*.
@@ -0,0 +1,309 @@
1
+ # Reporting performance
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.
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. Scoped and collection-source through batch paths require
12
+ `allow_expensive: true`; that opt-in can still cause N+1 work. Model callbacks,
13
+ custom types and callable predicates also remain outside a flat-query guarantee.
14
+
15
+ The benchmark exercises `analyze_scope` with genuine PaperTrail history. It is
16
+ a local SQLite baseline for finding work inside the gem, not a production
17
+ latency guarantee or a comparison of SQL dialects.
18
+
19
+ ## Direct reconstruction follow-up: 2026-09-07
20
+
21
+ Implementation: `1b6b1b9`, followed by belongs-to discovery fix `f6a4ae2`.
22
+ [Raw measurements](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/direct-reconstruction-2026-09-07.json)
23
+ include all samples, typed parity checks and query origins. Timing runs overlapped
24
+ development checks, so they do not establish a controlled speedup.
25
+
26
+ | Probe | SQL at 1 / 5 / 20 roots | Historical reify / prepared-instance calls |
27
+ | --- | --- | --- |
28
+ | Scalar activity | 6 / 6 / 6 | 0 / 0 |
29
+ | Direct comments activity | 12 / 12 / 12 | 0 / 0 |
30
+ | Comments without changesets | 12 / 12 / 12 | 0 / 0 |
31
+ | Direct comments checkpoints | 10 / 10 / 10 | 0 / 0 |
32
+ | Belongs-to activity | 9 / 9 / 9 | 0 / 0 |
33
+
34
+ Live records and version rows still instantiate when loaded in batches. Zero
35
+ historical instances does not mean zero allocation or constant total CPU cost.
36
+ The missing-changeset case previously used 15 / 27 / 72 queries. Limited,
37
+ owner-scoped, default-scoped and collection-source through paths now refuse
38
+ before report SQL by default. With explicit opt-in, the five-root through probe
39
+ used 199 queries and 75 reify calls, but no unrelated authorship loads.
40
+
41
+ Three diagnostic samples at 500 roots had median 742.3 ms for checkpoints and
42
+ 1,365.5 ms for retained activity; each used six SQL queries and zero reifications.
43
+ One root with 5,000 updates had median 1,154.2 ms and zero reifications. Retained
44
+ Ruby memory was about 15.0, 20.8 and 19.9 MiB respectively. These timings do not
45
+ establish a retained-activity improvement.
46
+
47
+ A querying initialization callback still produced 10 / 26 / 86 queries, and a
48
+ querying historical predicate produced 8 / 12 / 27. Custom behavior, polymorphic
49
+ or advanced graph reconstruction, and non-leaf activity fallbacks may still
50
+ instantiate historical records. There is no blanket no-N+1 guarantee.
51
+
52
+ Production parity matches PaperTrail for STI, enum, decimal, date, boolean, JSON
53
+ and serialized values, including missing fields supplied by callbacks/default
54
+ scopes through the compatibility fallback. From a repository checkout (benchmark
55
+ scripts are not included in the installed gem), reproduce these checks with:
56
+
57
+ ```console
58
+ PTD_PRODUCTION=1 mise exec -- bundle exec ruby benchmark/investigation/scalar_parity.rb
59
+ mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb comments_without_changes 20
60
+ PTD_PROBE_ALLOW_EXPENSIVE=1 mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb through_has_many 5
61
+ ```
62
+
63
+ ## Reproduce
64
+
65
+ From a repository checkout:
66
+
67
+ ```console
68
+ mise exec -- bundle install
69
+ mise exec -- bundle exec ruby benchmark/reporting.rb --output /tmp/reporting.json
70
+ mise exec -- bundle exec ruby benchmark/reporting.rb --case no_matches --size 1000 --samples 3
71
+ ```
72
+
73
+ The default run takes three samples of each population size below. Each sample
74
+ starts a fresh Ruby process and creates its own in-memory SQLite database. It
75
+ does not use `PAPER_TRAIL_DIFF_DATABASE_URL` or an application's database. Setup,
76
+ seeding, and the initial garbage collection are excluded from report timing.
77
+ The measured call runs without the ActiveRecord query cache. Successful calls
78
+ validate the selected root count; the refusal scenario must raise
79
+ `BatchLimitExceededError`.
80
+
81
+ Each article has a title, status, and 1 KiB body, using PaperTrail's default YAML
82
+ serializer. Except for the long-history case, each has one create and five update
83
+ versions; `deleted_roots` adds a destroy event. The window includes all seeded
84
+ versions and uses `close_on: :current`.
85
+ There are no associations. The fixture has an `(item_type, item_id)` version
86
+ index; a different schema, serializer, database, or graph needs its own run.
87
+
88
+ | Scenario | Population | Selection and output |
89
+ | --- | --- | --- |
90
+ | `many_roots` | 100 / 500 roots | Default analysis, including checkpoint timelines |
91
+ | `retained_activity` | 100 / 500 roots | Adds `activity: true, snapshots: true` |
92
+ | `long_history` | One root, 1,000 / 5,000 updates | Default analysis |
93
+ | `sparse_matches` | 100 / 1,000 roots | Historical status matches one root in every 100 |
94
+ | `no_matches` | 100 / 1,000 roots | Historical status matches no recorded state |
95
+ | `limit_refusal` | 100 / 1,000 roots | Every non-nil historical state matches; `limit: 5` refuses the sixth root |
96
+ | `attribute_no_matches` | 100 / 1,000 roots | Attribute hash matches no recorded state, without candidate reification |
97
+ | `attribute_sparse_matches` | 100 / 1,000 roots | Attribute hash matches one root in every 100 |
98
+ | `deleted_roots` | 100 / 500 roots | Batched missing roots with retained activity snapshots |
99
+
100
+ ## Baseline: 2026-09-06
101
+
102
+ The library was unchanged at revision
103
+ `389ad896fbc2ef5942c6ba34b605a1eb0b3f397c`. Environment: Ruby 4.0.1,
104
+ ActiveRecord 8.1.3.1, PaperTrail 17.0.0, SQLite 3.53.2, Linux x86-64 on WSL2
105
+ (`6.18.33.2-microsoft-standard-WSL2`), AMD Ryzen 7 7800X3D, about 15.5 GiB
106
+ WSL system memory. No test suite or app server ran alongside the final
107
+ samples. This is a developer workstation, not an isolated benchmark host.
108
+
109
+ The [raw samples](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/reporting-2026-09-06.json) include runtime
110
+ versions, allocations, SQL duration, query-cache hits, record instantiation,
111
+ reification counts, and memory readings. Times below are medians of three fresh
112
+ processes; the range is the minimum–maximum. Query and instantiation counts were identical across each set of three samples.
113
+ Reify counts are medians: one 100-root retained-activity sample made 599 calls
114
+ instead of 600; the cause of that small variation was not established. These
115
+ internal counts are observations, not API guarantees. SQL time is included in
116
+ total time.
117
+
118
+ | Scenario | Size | Total ms (range) | SQL ms | Queries | Reify calls | AR records instantiated |
119
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
120
+ | `many_roots` | 100 | 304.5 (301.1–328.6) | 3.9 | 6 | 1,200 | 800 |
121
+ | `many_roots` | 500 | 1,463.0 (1,460.8–1,824.8) | 16.8 | 6 | 6,000 | 4,000 |
122
+ | `retained_activity` | 100 | 302.3 (272.2–354.1) | 4.3 | 6 | 600 | 800 |
123
+ | `retained_activity` | 500 | 1,452.3 (1,371.7–1,520.6) | 16.3 | 6 | 3,000 | 4,000 |
124
+ | `long_history` | 1,000 | 505.5 (463.3–550.7) | 4.7 | 6 | 2,002 | 1,003 |
125
+ | `long_history` | 5,000 | 2,414.5 (2,334.5–3,077.6) | 19.2 | 6 | 10,002 | 5,003 |
126
+ | `sparse_matches` | 100 | 113.1 (95.2–114.1) | 3.6 | 7 | 608 | 608 |
127
+ | `sparse_matches` | 1,000 | 1,043.1 (830.1–1,213.0) | 36.1 | 10 | 6,080 | 6,080 |
128
+ | `no_matches` | 100 | 103.3 (99.9–109.5) | 3.6 | 2 | 600 | 600 |
129
+ | `no_matches` | 1,000 | 878.4 (822.3–922.5) | 32.0 | 5 | 6,000 | 6,000 |
130
+ | `limit_refusal` | 100 | 7.1 (7.0–7.7) | 2.7 | 2 | 12 | 600 |
131
+ | `limit_refusal` | 1,000 | 17.7 (17.5–17.9) | 8.6 | 2 | 12 | 1,500 |
132
+
133
+ Memory medians (MiB; 1 MiB = 1,048,576 bytes):
134
+
135
+ | Scenario | Size | Retained Ruby | RSS before | RSS after |
136
+ | --- | ---: | ---: | ---: | ---: |
137
+ | `many_roots` | 100 | 3.04 | 70.8 | 108.1 |
138
+ | `many_roots` | 500 | 15.09 | 74.6 | 136.2 |
139
+ | `retained_activity` | 100 | 4.18 | 70.5 | 99.4 |
140
+ | `retained_activity` | 500 | 20.75 | 75.0 | 160.8 |
141
+ | `long_history` | 1,000 | 4.06 | 71.8 | 116.9 |
142
+ | `long_history` | 5,000 | 20.11 | 78.3 | 166.2 |
143
+ | `sparse_matches` | 100 | 0.07 | 70.9 | 91.9 |
144
+ | `sparse_matches` | 1,000 | 0.34 | 80.7 | 165.1 |
145
+ | `no_matches` | 100 | 0.01 | 70.4 | 91.6 |
146
+ | `no_matches` | 1,000 | 0.01 | 81.0 | 164.8 |
147
+ | `limit_refusal` | 100 | 0.01 | 70.9 | 71.4 |
148
+ | `limit_refusal` | 1,000 | 0.01 | 81.0 | 81.2 |
149
+
150
+ ### What memory readings mean
151
+
152
+ Retained Ruby memory is the change in `ObjectSpace.memsize_of_all` after a full
153
+ GC while the result (or expected error) remains reachable. It includes lazily
154
+ initialized library state, not just result objects, and excludes native
155
+ allocations. RSS is whole-process resident memory before and immediately after
156
+ the report, before the final GC. It includes Ruby, libraries, and the in-memory
157
+ SQLite database. The raw process high-water RSS also includes startup and seeding;
158
+ it is not an isolated peak for the report. RSS fields are unavailable off Linux.
159
+ The measurements should not be interpreted as exact per-request memory budgets.
160
+
161
+ ## Findings from the original baseline
162
+
163
+ The findings below describe the original revision. The follow-up measurements
164
+ and remaining limitations are recorded after this section.
165
+
166
+ 1. **Reconstruction deserves attention before query-count tuning.** Default
167
+ analysis keeps the same query count as population/history grows, but reifies
168
+ each version twice in these fixtures. The retained-activity path makes
169
+ approximately half as many calls. Investigate sharing reconstruction between endpoint and checkpoint
170
+ analysis, preserving immutable results, ordering, and association semantics.
171
+ Fewer reifications can offset the extra work of retained activity; do not
172
+ assume that enabling activity always makes a report slower.
173
+ 2. **A small result does not mean a cheap historical predicate.** Nonmatching
174
+ candidates require all their recorded states to be tried. A 1,000-root empty
175
+ result still reifies 6,000 versions. A matched-root `limit:` cannot cap that
176
+ work. Do not present arbitrary `historical_filter:` predicates as an efficient
177
+ large-report path in their current form. Evaluate whether a constrained
178
+ attribute predicate can use bulk-loaded immutable state instead of creating
179
+ an ActiveRecord object per version. Any retained arbitrary callback needs an
180
+ explicit work budget or clearly restricted use. A background job can move the
181
+ delay out of a request, but does not fix the reconstruction cost.
182
+ Do not replace historical selection with current-state
183
+ filtering merely to make it faster: that changes which roots qualify.
184
+ 3. **The safety ceiling stops reification before it stops batch loading.** With
185
+ `limit: 5`, refusal takes 12 reify calls: the nil create and first update state
186
+ for each of six roots. At 1,000 candidates, 1,500 version records have already
187
+ been instantiated for the first 250-root batch. Investigate smaller or streamed
188
+ candidate loading if this is a memory problem in an application's workload.
189
+ Changes must preserve match-before-limit semantics and deterministic selection.
190
+ 4. **Retained snapshots have a measurable memory cost.** Request them when the
191
+ consumer needs historical context. Many roots and long histories increase
192
+ retained data even when database round trips stay flat. Measure explicitly
193
+ requested associations next; this root-only baseline cannot characterize
194
+ their additional queries or reconstruction cost.
195
+ 5. **The test app's deleted-history fallback is an N+1 pattern.** Its
196
+ `DeletedArticleHistory#call` loops over missing identities, selects each
197
+ destroy version, calls `reify`, and then calls `activity_timeline` separately.
198
+ Its 25-root cap limits exposure but does not make the approach batched.
199
+ This path is outside the measurements above and should not become the
200
+ recommended reporting recipe. A gem-level batch operation should load missing
201
+ roots and their histories together and return structured deletion/unavailable
202
+ results, so callers do not rebuild this loop themselves.
203
+
204
+ Before adding a higher-level reporting facade, address duplicate reconstruction
205
+ and deleted-root batching, then decide whether to redesign or restrict historical
206
+ predicates. Verify both query growth and reification growth over representative
207
+ populations. Linear work to produce requested output can be necessary; repeated
208
+ reconstruction of the same state and scanning thousands of states for an empty
209
+ report should not be accepted merely because SQL round trips are bounded.
210
+
211
+ The Rails test app now makes its missing-closing-boundary demonstration opt-in.
212
+ Previously that example reran selection on every report request, including
213
+ historical predicates. Its regression test checks that a normal historical report
214
+ selects candidates once. No gem runtime optimization is included with this
215
+ baseline; it provides a repeatable comparison for a subsequent focused change.
216
+
217
+ Do not enforce these workstation timings as CI thresholds. Re-run matching
218
+ workloads before and after an optimization, compare structural counts as well
219
+ as timing and memory, and validate the existing correctness suites separately.
220
+
221
+ ## Follow-up: batched reporting, 2026-09-07 UTC
222
+
223
+ Runtime revision `fb076a4ebde62f45d639088d0bd6f843ce0879f8` addresses duplicate
224
+ checkpoint reconstruction, repeated association event discovery, and the test
225
+ app's per-deleted-root history loop. `report(scope, since:, limit:)` supplies
226
+ recent activity through the current saved ending. `include_deleted: true`
227
+ returns missing-root analyses in a separate batch with the requested graph.
228
+ The same combined root ceiling is checked before either population is analyzed.
229
+
230
+ Historical attribute hashes read recorded scalar state without reifying every
231
+ candidate. Arbitrary callables retain their earlier semantics and reconstruction
232
+ cost, with a default `max_filter_versions: 10_000` ceiling on loaded candidate
233
+ versions across batches. One extra row detects overflow before evaluating that
234
+ batch. This is separate from the matched-root ceiling.
235
+
236
+ Association regression tests compare one and five roots for live activity and
237
+ deleted activity, and exercise through-association events. The test app also
238
+ checks that adding deleted articles with children does not add per-article
239
+ queries. These tests establish query growth for their requested graphs; the
240
+ timings below remain root-only measurements.
241
+
242
+ The [54 follow-up samples](https://github.com/aheathwilliams/paper_trail_diff/blob/c799d0e/benchmark/results/reporting-after-2026-09-07.json)
243
+ use the same runtime versions, workstation, schema, and measurement procedure
244
+ as the baseline. No test suite or app server ran alongside them. Each row gives
245
+ the median of three fresh processes, with the elapsed-time range in parentheses.
246
+ Query, reify, and AR instantiation counts were identical within each set of three.
247
+ Memory uses the same definitions and limitations as the baseline.
248
+
249
+ | Scenario | Size | Total ms (range) | Queries | Reify calls | Retained Ruby MiB | RSS after MiB |
250
+ | --- | ---: | ---: | ---: | ---: | ---: | ---: |
251
+ | `many_roots` | 100 | 157.1 (154.3–179.1) | 6 | 600 | 3.02 | 84.5 |
252
+ | `many_roots` | 500 | 839.7 (804.8–843.8) | 6 | 3,000 | 14.98 | 142.6 |
253
+ | `retained_activity` | 100 | 254.4 (250.4–275.8) | 6 | 600 | 4.18 | 98.7 |
254
+ | `retained_activity` | 500 | 1,344.7 (1,320.1–1,599.8) | 6 | 3,000 | 20.75 | 160.0 |
255
+ | `long_history` | 1,000 | 280.9 (256.1–307.8) | 6 | 1,001 | 4.02 | 111.1 |
256
+ | `long_history` | 5,000 | 1,392.0 (1,364.1–1,409.8) | 6 | 5,001 | 19.92 | 170.9 |
257
+ | `sparse_matches` | 100 | 94.6 (91.3–100.4) | 7 | 602 | 0.07 | 91.9 |
258
+ | `sparse_matches` | 1,000 | 844.2 (835.6–962.9) | 10 | 6,020 | 0.33 | 164.8 |
259
+ | `no_matches` | 100 | 85.3 (81.7–92.4) | 2 | 600 | 0.01 | 94.6 |
260
+ | `no_matches` | 1,000 | 869.3 (798.8–909.7) | 5 | 6,000 | 0.01 | 165.4 |
261
+ | `limit_refusal` | 100 | 10.9 (10.2–11.0) | 2 | 12 | 0.01 | 70.3 |
262
+ | `limit_refusal` | 1,000 | 32.2 (30.5–33.4) | 2 | 12 | 0.01 | 81.1 |
263
+ | `attribute_no_matches` | 100 | 89.9 (83.0–120.9) | 2 | 0 | 0.01 | 91.7 |
264
+ | `attribute_no_matches` | 1,000 | 680.3 (680.2–721.8) | 5 | 0 | 0.01 | 164.7 |
265
+ | `attribute_sparse_matches` | 100 | 82.6 (74.6–86.8) | 7 | 6 | 0.07 | 91.7 |
266
+ | `attribute_sparse_matches` | 1,000 | 759.7 (742.8–762.1) | 10 | 60 | 0.33 | 166.3 |
267
+ | `deleted_roots` | 100 | 340.4 (304.0–364.2) | 5 | 700 | 4.44 | 103.1 |
268
+ | `deleted_roots` | 500 | 1,506.4 (1,487.2–1,585.5) | 5 | 3,500 | 22.08 | 164.9 |
269
+
270
+ At 500 roots, default analysis is about 43% faster than the baseline and reifies
271
+ each checkpoint once instead of twice. Its retained Ruby memory barely changes;
272
+ this is primarily a reconstruction improvement, not a result-memory reduction.
273
+ The 1,000-root attribute no-match case avoids all 6,000 candidate reifications but
274
+ still spends 680 ms reading and deserializing states. Its RSS remains comparable
275
+ to the callable case. Avoiding reification alone does not eliminate scan cost.
276
+
277
+ The refusal case regressed: at 1,000 candidates it takes 32.2 ms versus 17.7 ms,
278
+ with median SQL time rising from 8.6 to 24.0 ms. Both runs instantiate 1,500 version
279
+ records and make 12 reify calls. The new loading ceiling remains useful, but this
280
+ query deserves further tuning; the change is not a speedup for every workload.
281
+ Deleted history has no original timed counterpart here. Its five queries at both
282
+ 100 and 500 roots demonstrate batching; output reconstruction still scales.
283
+
284
+ ### Remaining performance limits
285
+
286
+ - **Reconstruction still scales with requested history.** Plain roots and direct
287
+ children avoid historical AR objects, but decoding and comparing their recorded
288
+ states remains linear work. Compatibility fallbacks still construct records.
289
+ Arbitrarily large histories are not cheap.
290
+ - **Callable predicates can still be expensive or issue N+1 queries.** Prefer
291
+ attribute hashes for batch reports. The version-loading budget cannot control
292
+ queries, network calls, or CPU work inside application callbacks. Hash predicates
293
+ still deserialize candidate states and are not SQL-indexed historical searches.
294
+ - **Advanced association fallbacks can still query per record.** Scoped or
295
+ default-scoped paths and other graphs that need PT-AT fallback reconstruction
296
+ require explicit `allow_expensive: true` for batch APIs and must be measured
297
+ before being offered in a large report. The flat-query tests
298
+ do not establish a guarantee for every association configuration.
299
+ - **Limits are not streaming.** Candidate identities are still selected up front;
300
+ a small root ceiling can load a full 250-candidate version batch, subject to the
301
+ separate version budget. Requested analysis history and retained snapshots
302
+ still consume memory proportional to the data involved.
303
+ - **Selection semantics are unchanged.** Historical filters test the states a
304
+ record held during the window, not continuous-time membership. Scope selection needs a root
305
+ version in the window; child-only edits do not select an otherwise quiet root.
306
+
307
+ Further batching of advanced association fallbacks needs temporal parity tests,
308
+ especially around ownership changes. A graph-wide selector for child-only activity
309
+ also needs its own bounded query design.