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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +144 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1367
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +181 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +59 -24
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +25 -2
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/value_objects.rb +66 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +81 -43
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +37 -27
- 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*.
|
data/docs/performance.md
ADDED
|
@@ -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.
|