upkeep-rails 0.1.6
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.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +518 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +514 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +821 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +320 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Single Subscriber And Cold Fixed-Cost Roadmap
|
|
2
|
+
|
|
3
|
+
This roadmap targets the cases where Upkeep has little or no render sharing
|
|
4
|
+
amortization: one active subscriber, cold page setup, cable activation, and
|
|
5
|
+
short-lived anonymous pages.
|
|
6
|
+
|
|
7
|
+
Last updated: 2026-05-21.
|
|
8
|
+
|
|
9
|
+
Current code state:
|
|
10
|
+
|
|
11
|
+
- Base runtime/docs commit: `474fb9c` (`Instrument capture and document Upkeep Rails`).
|
|
12
|
+
- Current pass adds gated request/action profiling, operation-scoped capture
|
|
13
|
+
metrics, mutation action/delivery timings, client/server phase correlation,
|
|
14
|
+
benchmark phase labels, lightweight subscription-shape tracing, static
|
|
15
|
+
template metadata caching, shared-stream signature memoization, and queued
|
|
16
|
+
dependency flushing.
|
|
17
|
+
- Latest verification: `bundle exec rake test` passed with `186` runs and
|
|
18
|
+
`1172` assertions.
|
|
19
|
+
- Latest single-subscriber benchmark data is the 2026-05-21 identity-free
|
|
20
|
+
report below.
|
|
21
|
+
|
|
22
|
+
## Environment
|
|
23
|
+
|
|
24
|
+
From a fresh zsh shell in any worktree:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
cd /path/to/upkeep-rails
|
|
28
|
+
eval "$(mise activate zsh)"
|
|
29
|
+
ruby -v
|
|
30
|
+
bundle exec rake test
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Do not use system Ruby. The repo expects Ruby from mise, currently `3.4.7`.
|
|
34
|
+
|
|
35
|
+
## Baseline
|
|
36
|
+
|
|
37
|
+
Latest identity-free 1-subscriber report:
|
|
38
|
+
|
|
39
|
+
- Report: `benchmark/results/identity-free-feed-compare-20260521114026.md`
|
|
40
|
+
- Upkeep setup p95: `140ms`
|
|
41
|
+
- Turbo setup p95: `104ms`
|
|
42
|
+
- Upkeep page render p95: `91ms`
|
|
43
|
+
- Turbo page render p95: `73ms`
|
|
44
|
+
- Upkeep WebSocket connect p95: `38ms`
|
|
45
|
+
- Turbo WebSocket connect p95: `17ms`
|
|
46
|
+
- Upkeep subscribe call p95: `10ms`
|
|
47
|
+
- Turbo subscribe call p95: `14ms`
|
|
48
|
+
- Upkeep subscribe ack p95: `10ms`
|
|
49
|
+
- Turbo subscribe ack p95: `13ms`
|
|
50
|
+
- Upkeep write POST p95: `38.85ms`
|
|
51
|
+
- Turbo write POST p95: `66.37ms`
|
|
52
|
+
- Upkeep update-settled p95: `90ms`
|
|
53
|
+
- Turbo update-settled p95: `96ms`
|
|
54
|
+
- Subscription shape cache: `1` miss, `0` hits, `0` bypasses
|
|
55
|
+
- Shape miss timing: total `4.221ms`, key `1.743ms`, index template `1.003ms`
|
|
56
|
+
- Setup page server timing: Upkeep p95 `83.88ms`; Turbo p95 `66.02ms`
|
|
57
|
+
- Write request server timing: Upkeep p95 `23.78ms`; Turbo p95 `32.72ms`
|
|
58
|
+
- `GET /feed` request-capture timing: total p95 `82.921ms`; action p95
|
|
59
|
+
`70.594ms`; view p95 `55.578ms`; template p95 `42.352ms`; collection render
|
|
60
|
+
p95 `13.226ms`; SQL p95 `1.577ms`; register p95 `12.129ms`
|
|
61
|
+
- `GET /feed` recorder timing: dependency p95 `5.783ms`; frame p95 `2.418ms`;
|
|
62
|
+
shape trace p95 `4.264ms`
|
|
63
|
+
- Subscribe channel server timing: total `2.085ms`, activation `1.95ms`
|
|
64
|
+
|
|
65
|
+
With one subscriber there is still no render dedup savings. The latest cold
|
|
66
|
+
report is a one-sample fixed-cost probe and is noisier than the shared report.
|
|
67
|
+
It still shows the right target: the remaining gap is not SQL, shape cache hit
|
|
68
|
+
cost, or server-side channel subscribe. This pass reduced recorder dependency
|
|
69
|
+
and shape tracing substantially, so the dominant cold fixed cost is now the
|
|
70
|
+
first page/action path, especially Action View template and collection capture,
|
|
71
|
+
plus first registration.
|
|
72
|
+
|
|
73
|
+
## Priorities
|
|
74
|
+
|
|
75
|
+
1. Decompose cold page/capture setup
|
|
76
|
+
|
|
77
|
+
Upkeep page render p95 is now `91ms` versus Turbo `73ms`, while setup p95
|
|
78
|
+
is `140ms` versus Turbo `104ms`. `GET /feed` request-capture action p95 is
|
|
79
|
+
`70.594ms`, view p95 is `55.578ms`, and template p95 is `42.352ms`.
|
|
80
|
+
|
|
81
|
+
Candidate work:
|
|
82
|
+
|
|
83
|
+
- Reduce Action View capture overhead around template and collection render
|
|
84
|
+
instrumentation. SQL is only `1.735ms` in this run.
|
|
85
|
+
- Keep ActionCable open/confirmation separate from server-side channel
|
|
86
|
+
subscribe; latest server-side subscribe total is only `2.085ms`.
|
|
87
|
+
- Continue reducing Action View template capture overhead; template p95 is
|
|
88
|
+
`42.352ms` in the latest one-subscriber probe.
|
|
89
|
+
- Investigate whether page/layout frame capture can be represented with one
|
|
90
|
+
page boundary without losing layout identity safety.
|
|
91
|
+
- Keep queued dependency flushing; the recorder shape trace has already
|
|
92
|
+
dropped from `10.04ms` to `4.264ms`.
|
|
93
|
+
|
|
94
|
+
Target outcome:
|
|
95
|
+
|
|
96
|
+
- 1-subscriber page render p95 closes the current `18ms` gap to Turbo.
|
|
97
|
+
- Setup telemetry explains the remaining `36ms` setup gap.
|
|
98
|
+
|
|
99
|
+
2. Finish shape miss/key cleanup
|
|
100
|
+
|
|
101
|
+
Shape keys no longer serialize the full recorder snapshot, and the
|
|
102
|
+
subscription-shape trace now records normal capture shape terms in
|
|
103
|
+
DAG/runtime. Shared-hit key p95 is `0.031ms`; the single-subscriber miss path
|
|
104
|
+
is still `1.743ms` and total miss is `4.221ms`, but it is a first-shape
|
|
105
|
+
cost.
|
|
106
|
+
|
|
107
|
+
Remaining work:
|
|
108
|
+
|
|
109
|
+
- Preserve the rolling trace digest for hot hits.
|
|
110
|
+
- Identify why first-shape key generation still costs `1.743ms`.
|
|
111
|
+
- Move template digest and manifest fingerprint normalization out of the
|
|
112
|
+
miss path where possible.
|
|
113
|
+
- Keep cache misses exact without replay-payload hashing.
|
|
114
|
+
|
|
115
|
+
Target outcome:
|
|
116
|
+
|
|
117
|
+
- Shape-key generation is sub-millisecond for both miss and hit paths.
|
|
118
|
+
- Cache misses stay exact, and false misses remain safe.
|
|
119
|
+
|
|
120
|
+
3. Reduce subscribe ack fixed cost
|
|
121
|
+
|
|
122
|
+
Server-side subscribe is no longer the main cost, but client-observed suback
|
|
123
|
+
can still be noisy. Keep the server/client split visible.
|
|
124
|
+
|
|
125
|
+
Candidate work:
|
|
126
|
+
|
|
127
|
+
- Keep cable subscribe phase telemetry: fetch, authorization, activation,
|
|
128
|
+
active-index register, stream attach, confirmation.
|
|
129
|
+
- Keep benchmark phase labels on WebSocket open, cable open, subscription
|
|
130
|
+
registration, and confirmation.
|
|
131
|
+
- Keep durable writer and persistent index work outside the ack critical path.
|
|
132
|
+
- Preserve strict activation semantics; no compatibility shims for old rows.
|
|
133
|
+
|
|
134
|
+
Target outcome:
|
|
135
|
+
|
|
136
|
+
- Server-side subscribe p95 stays within a few milliseconds of Turbo in both
|
|
137
|
+
1- and 200-subscriber identity-free reports.
|
|
138
|
+
|
|
139
|
+
4. Single-subscriber write fast path
|
|
140
|
+
|
|
141
|
+
The latest one-subscriber run recovered on write and update-settled, but the
|
|
142
|
+
one-sample p95 is noisy. Keep this lane explicit: if planning resolves to
|
|
143
|
+
exactly one active subscriber, skip shared-group work that only pays off for
|
|
144
|
+
cohorts.
|
|
145
|
+
|
|
146
|
+
Candidate checks:
|
|
147
|
+
|
|
148
|
+
- No render dedup accounting on the hot path for a single target unless the
|
|
149
|
+
runtime already produced the shared group.
|
|
150
|
+
- No batch fanout structure for one connection.
|
|
151
|
+
- No persistent index dependency for active, in-process delivery.
|
|
152
|
+
|
|
153
|
+
Target outcome:
|
|
154
|
+
|
|
155
|
+
- 1-subscriber update-settled returns to at or below Turbo.
|
|
156
|
+
- Planning/build telemetry shows one lookup, one plan, one render, one
|
|
157
|
+
transmit.
|
|
158
|
+
|
|
159
|
+
5. Cold benchmark separation
|
|
160
|
+
|
|
161
|
+
Keep cold burst capacity separate from warm update economics.
|
|
162
|
+
|
|
163
|
+
Benchmark shapes:
|
|
164
|
+
|
|
165
|
+
- Cold burst capacity: simultaneous new users, accepts, page render, cable
|
|
166
|
+
open, subscribe ack.
|
|
167
|
+
- Cold ramp setup: staged subscribers without accept queue loss.
|
|
168
|
+
- Warm single update: subscribers are ready, one mutation settles.
|
|
169
|
+
|
|
170
|
+
## Success Criteria
|
|
171
|
+
|
|
172
|
+
- 1-subscriber first-write miss behavior is explained and bounded.
|
|
173
|
+
- `steady_state_setup_leaks` is zero in warm single-subscriber runs.
|
|
174
|
+
- 1-subscriber setup p95 closes most of the current `140ms` versus Turbo
|
|
175
|
+
`104ms` gap.
|
|
176
|
+
- 1-subscriber page render p95 closes most of the current `91ms` versus Turbo
|
|
177
|
+
`73ms` gap.
|
|
178
|
+
- Shape miss telemetry shows where first-render cost remains.
|
|
179
|
+
- 1-subscriber update-settled p95 remains at or below Turbo.
|
|
180
|
+
|
|
181
|
+
## Validation
|
|
182
|
+
|
|
183
|
+
Use the mise environment in every shell:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
eval "$(mise activate zsh)"
|
|
187
|
+
bundle exec rake test
|
|
188
|
+
BENCH_FAMILY=render_dedup BENCH_WORKLOAD=identity_free_feed_compare BENCH_TIER=report BENCH_VUS=1 ruby benchmark/bin/run
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
When changing cold setup behavior, also run the relevant cold benchmark instead
|
|
192
|
+
of using the warm shared report as a proxy.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Stress-test findings, fixes, and remaining decisions
|
|
2
|
+
|
|
3
|
+
**Gem under test:** `upkeep-rails` v0.1.0 (installed from RubyGems)
|
|
4
|
+
**How found:** end-to-end browser stress test in the companion app at
|
|
5
|
+
`../stress-test-upkeep/stress_app/` (Rails 8 + importmap + propshaft, real Chrome via Ferrum).
|
|
6
|
+
Run instructions and the harness are in that app's `STRESS_TEST.md` and `lib/stress/harness.rb`.
|
|
7
|
+
**Source confirmed against:** this repo (file:line references are relative to the repo root).
|
|
8
|
+
|
|
9
|
+
All line numbers are from upkeep-rails v0.1.0 (this repo).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 2026-05-21 follow-up on v0.1.1 and v0.1.2
|
|
14
|
+
|
|
15
|
+
The companion app at
|
|
16
|
+
`/Volumes/FelipeSSD/professional/upkeep/rails/stress-test-upkeep/stress_app`
|
|
17
|
+
verified that 0.1.1 fixed importmap wiring, cold first-paint activation, scalar
|
|
18
|
+
page replay, identity rejection, and token expiry. The remaining correctness
|
|
19
|
+
failure was `shared stream survives remove`: after a rendered member was
|
|
20
|
+
removed, the next shared render-site replay reached the browser but used Turbo
|
|
21
|
+
Stream `replace` against a custom render-site wrapper while the template
|
|
22
|
+
contained only child `<li>` elements. Turbo removed the target container, so
|
|
23
|
+
later streams had no target.
|
|
24
|
+
|
|
25
|
+
The fix is to emit Turbo Stream `update method="morph"` for render-site replay
|
|
26
|
+
fallbacks and keep `replace method="morph"` only for member/fragment
|
|
27
|
+
replacements. In 0.1.2, render sites are legal existing HTML elements marked
|
|
28
|
+
with `data-upkeep-render-site`; Upkeep no longer emits a custom structural
|
|
29
|
+
wrapper. `update` swaps the render site's children and preserves the real
|
|
30
|
+
container that future streams target. Page-level fallbacks now use Turbo Stream
|
|
31
|
+
`refresh method="morph" scroll="preserve"` so Turbo owns page refresh behavior
|
|
32
|
+
instead of Upkeep replacing `<html>` or calling `document.write`.
|
|
33
|
+
|
|
34
|
+
Turbo interop was tightened at the client boundary too: the injected marker is
|
|
35
|
+
now a body-scoped `<upkeep-subscription-source data-upkeep-subscription
|
|
36
|
+
data-turbo-temporary>` custom element. The generated browser client registers it
|
|
37
|
+
as a Turbo stream source via `Turbo.session.connectStreamSource`, and DOM
|
|
38
|
+
connect/disconnect handles ActionCable subscribe/unsubscribe.
|
|
39
|
+
|
|
40
|
+
Two upgrade traps were also confirmed:
|
|
41
|
+
|
|
42
|
+
- `app/javascript/upkeep/subscription.js` is intentionally vendored, but it must
|
|
43
|
+
be refreshed when the server-side subscription payload changes. The generated
|
|
44
|
+
client now logs channel rejection, and the README documents rerunning the
|
|
45
|
+
installer or comparing the file after gem upgrades.
|
|
46
|
+
- `config.upkeep.*` set from `config/initializers/upkeep.rb` is too late for the
|
|
47
|
+
Railtie path. The installer now writes `Upkeep::Rails.configure do |config|`
|
|
48
|
+
in the initializer. `config.upkeep.*` remains appropriate in Rails
|
|
49
|
+
application or environment config that runs before initializers.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Summary
|
|
54
|
+
|
|
55
|
+
| # | Finding | Type | Status |
|
|
56
|
+
|---|---------|------|--------|
|
|
57
|
+
| 1 | importmap installer never pins the browser client | Bug (clear fix) | Fixed + test |
|
|
58
|
+
| 2 | member `remove` broadcasts to a shared stream nobody listens on | Bug (clear fix) | Fixed + test |
|
|
59
|
+
| 3 | a delivery error turns an already-committed write into HTTP 500 | Bug (clear fix, safety half) | Fixed + test |
|
|
60
|
+
| 4 | scalar/page-level replay target can't be found → raises | Bug (clear fix) | Fixed + test |
|
|
61
|
+
| P1 | benchmark suite: AR-store `fetch` raises `RecordNotFound`, test expects `KeyError` | Pre-existing failure | Fixed + test |
|
|
62
|
+
| P2 | benchmark suite: identified-user streamed update delivers 0 broadcasts | Pre-existing failure (corroborates A) | Fixed + test |
|
|
63
|
+
| A | identity-bound pages need an explicit request-to-connection bridge | Framework limitation + decision | Implemented + documented |
|
|
64
|
+
| B | first paint should activate without creating an app guest identity | Framework limitation + decision | Fixed + test |
|
|
65
|
+
| C | commit-triggered delivery needs a job boundary | Architecture decision | Implemented + test |
|
|
66
|
+
|
|
67
|
+
### What already works well (don't regress these)
|
|
68
|
+
- Anonymous-public collection pipeline (capture → commit → invalidate → broadcast → Turbo
|
|
69
|
+
render) is correct and fast: p50 ~50–60 ms, p95 ~80–130 ms on localhost, **0 cross-board
|
|
70
|
+
leaks in every run**; append/replace/remove all 100% when a board has a single subscriber.
|
|
71
|
+
- Opaque relations are refused exactly as documented (no marker injected; the page still
|
|
72
|
+
renders as plain Rails HTML under `refused_boundary_behavior = :warn`).
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Part 1 — Unequivocal fixes
|
|
77
|
+
|
|
78
|
+
These have a single defensible fix and now have regression coverage. This
|
|
79
|
+
section records the diagnosis so the changes can be reviewed.
|
|
80
|
+
|
|
81
|
+
### Fix 1 — importmap installer never pins the browser client (critical)
|
|
82
|
+
- **Symptom:** in a stock Rails 8 + importmap + propshaft app, after
|
|
83
|
+
`bin/rails generate upkeep:install`, the cable client asset 404s, no `/cable` WebSocket
|
|
84
|
+
opens, and **nothing is ever delivered** (0 WebSocket upgrades observed).
|
|
85
|
+
- **Root cause:** `lib/generators/upkeep/install/install_generator.rb` —
|
|
86
|
+
`append_application_import` writes `import "./upkeep/subscription"` into
|
|
87
|
+
`app/javascript/application.js`, but `pin_action_cable` only pins
|
|
88
|
+
`@hotwired/turbo-rails` and `@rails/actioncable`. The `upkeep/subscription` module is
|
|
89
|
+
never pinned, so under propshaft's digested URLs the relative specifier resolves to
|
|
90
|
+
`/assets/upkeep/subscription` (no digest) → 404.
|
|
91
|
+
- **Fix:** generator should pin the module (`pin "upkeep/subscription", to:
|
|
92
|
+
"upkeep/subscription.js"`, or `pin_all_from "app/javascript/upkeep", under: "upkeep"`)
|
|
93
|
+
and import it by bare specifier.
|
|
94
|
+
- **Impact:** this breaks the *default* Rails 8 stack, i.e. essentially every new adopter.
|
|
95
|
+
|
|
96
|
+
### Fix 2 — member `remove` not delivered to *shared* (2+) subscribers
|
|
97
|
+
- **Symptom:** with 2+ anonymous subscribers sharing a collection page, deleting a rendered
|
|
98
|
+
member never reaches them. With 1 subscriber it works. append/replace work in both cases.
|
|
99
|
+
- **Root cause:** subscriptions register shared streams **only for render-site frames** —
|
|
100
|
+
`lib/upkeep/shared_streams.rb` `names_for_graph` skips frames where
|
|
101
|
+
`kind != "render_site"`, keying the stream on the render-site target. But delivery computes
|
|
102
|
+
a remove's shared stream from the operation's **own** target —
|
|
103
|
+
`lib/upkeep/delivery/turbo_streams.rb:244` `shared_stream_name_for` →
|
|
104
|
+
`SharedStreams.stream_name(target: planned_target.target, …)`, and for a remove that target
|
|
105
|
+
is the member (`#card_<id>`), not the render-site. Different `target.kind/id` → different
|
|
106
|
+
SHA → broadcast to `upkeep:shared:<member-hash>` while subscribers listen on
|
|
107
|
+
`upkeep:shared:<render-site-hash>`. (append/replace work because they deopt to the
|
|
108
|
+
render-site target, whose hash matches.)
|
|
109
|
+
- **Fix:** member-level shared delivery should resolve to the enclosing render-site's shared
|
|
110
|
+
stream (the one the subscription registered).
|
|
111
|
+
- **Repro:** `SUBSCRIBERS=2 BOARDS=1 bin/rails stress:run` → "collection remove" goes 0/2.
|
|
112
|
+
|
|
113
|
+
### Fix 3 — a delivery error turns an already-committed write into HTTP 500 (safety half)
|
|
114
|
+
- **Symptom:** once a page-replay subscriber exists (see Fix 4), an unrelated `create` that
|
|
115
|
+
has already **committed** returns 500 because Upkeep's after-commit delivery raises.
|
|
116
|
+
- **Root cause:** delivery runs **inline in the writer's request**. `lib/upkeep/runtime.rb:1005`
|
|
117
|
+
registers `ActiveRecord::Base.after_commit { ChangeLog.record(...) }`, and
|
|
118
|
+
`lib/upkeep/rails/controller_runtime.rb:99` calls
|
|
119
|
+
`Upkeep::Rails.deliver_changes!(changes)` synchronously at the end of the action. Any
|
|
120
|
+
exception in rendering/targeting propagates out of the committed request → 500. One broken
|
|
121
|
+
subscriber page poisons unrelated writers.
|
|
122
|
+
- **Fix (unequivocal part):** fault-isolate inline delivery — a render/target error must be
|
|
123
|
+
rescued and surfaced via instrumentation, never propagate to the committed write; other
|
|
124
|
+
targets still deliver.
|
|
125
|
+
- **Out of scope here (see Decision C):** moving delivery off the request entirely (async).
|
|
126
|
+
|
|
127
|
+
### Fix 4 — scalar / page-level replay target can't be found → raises
|
|
128
|
+
- **Symptom:** a page-level (`pluck`/scalar) dependency fails to update and intermittently
|
|
129
|
+
raises `RuntimeError: target not found in full rerender`.
|
|
130
|
+
- **Root cause:** `lib/upkeep/targeting.rb:92` raises when `node_for` can't locate the target.
|
|
131
|
+
A page-level dependency yields a page target with the **inner template** id, e.g.
|
|
132
|
+
`page:rails:boards/stats`, and `node_for` (kind `"page"`, line ~99) looks for
|
|
133
|
+
`[data-upkeep-page-frame="page:rails:boards/stats"]`. But the rendered HTML only contains
|
|
134
|
+
`data-upkeep-page-frame="page:rails:layouts/application"` — **the inner template's
|
|
135
|
+
page-frame marker is never emitted into the DOM** (confirmed: the stats page's only
|
|
136
|
+
page-frame attribute is the layout's). So the target node never exists.
|
|
137
|
+
- **Fix:** make the emitted DOM page-frame markers consistent with the page targets Upkeep
|
|
138
|
+
records (emit the inner-template marker), or resolve page targets to the page frame that
|
|
139
|
+
actually exists.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Part 2 — Decisions / framework limitations
|
|
144
|
+
|
|
145
|
+
These are the higher-level constraints found by the stress app. Decisions A, B,
|
|
146
|
+
and C are implemented in this worktree.
|
|
147
|
+
|
|
148
|
+
### Decision A — identity-bound pages need an explicit request-to-connection bridge
|
|
149
|
+
|
|
150
|
+
**Decision made.** Upkeep does not infer subscriber identity from method names
|
|
151
|
+
such as `Current.user`, `current_user`, or Devise helpers. Runtime heuristics are
|
|
152
|
+
too weak: the gem cannot know whether `user` means the subscriber, an author, an
|
|
153
|
+
impersonator, or unrelated display state.
|
|
154
|
+
|
|
155
|
+
Instead, identity is now a declared bridge:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
Upkeep::Rails.configure do |config|
|
|
159
|
+
config.identify :viewer, current: ["Current", :user] do
|
|
160
|
+
subscribe { |connection| connection.current_user }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The declaration has two sides:
|
|
166
|
+
- **capture side** (`current:`, `session:`, `cookie:`, or `warden:`): the value
|
|
167
|
+
observed while rendering the HTTP response;
|
|
168
|
+
- **subscribe side** (`subscribe { ... }`): the same value resolved from the
|
|
169
|
+
ActionCable connection when the browser subscribes.
|
|
170
|
+
|
|
171
|
+
The identity name (`:viewer`, `:account`, `:tenant`, etc.) is part of the
|
|
172
|
+
canonical subscriber id. If a page captures multiple declared identities, the
|
|
173
|
+
ActionCable subscription must match all of them.
|
|
174
|
+
|
|
175
|
+
Declared identity values can be absent. The default absence rule is `nil`, so a
|
|
176
|
+
logged-out page that checks `Current.user` or `session[:user_id]` can remain
|
|
177
|
+
anonymous-public. Apps that use another sentinel declare it with `absent_if`.
|
|
178
|
+
|
|
179
|
+
**What changed in the repo.**
|
|
180
|
+
- `Upkeep::Rails.configuration.identify` declares identities.
|
|
181
|
+
- `SubscriberIdentity` derives request identity only from configured mappings.
|
|
182
|
+
- `Upkeep::Rails::Cable::Channel` authorizes an identified subscription against
|
|
183
|
+
the identity names stored with that subscription.
|
|
184
|
+
- Undeclared non-absent `CurrentAttributes` and Warden identity reads refuse live
|
|
185
|
+
registration with `identity_setup_required` / `unidentified_identity` instead
|
|
186
|
+
of silently guessing.
|
|
187
|
+
- The install generator writes identity examples into
|
|
188
|
+
`config/initializers/upkeep.rb` and prints setup guidance when it detects
|
|
189
|
+
obvious request-side identity usage.
|
|
190
|
+
- The README and `docs/architecture/identity-and-sharing.md` document the API.
|
|
191
|
+
|
|
192
|
+
**Why this is better than Devise/Warden-only.** Devise remains easy to wire, but
|
|
193
|
+
the API also supports non-Devise apps, tenant/account identities, session-backed
|
|
194
|
+
apps, cookie-backed partitioning, and multiple identity components without
|
|
195
|
+
runtime name guessing.
|
|
196
|
+
|
|
197
|
+
### Decision B — first paint should activate without creating an app guest identity
|
|
198
|
+
|
|
199
|
+
**Decision made.** Upkeep should not solve first-paint activation by inventing a
|
|
200
|
+
global guest cookie or by asking every app to decide whether each session/cookie
|
|
201
|
+
identity can be auto-created. That pushes too much identity design onto
|
|
202
|
+
developers and risks turning public pages private.
|
|
203
|
+
|
|
204
|
+
The cleaner fix is a transport-level activation token. Every injected marker now
|
|
205
|
+
includes a stateless signed token for that subscription id. The browser sends it
|
|
206
|
+
when subscribing over ActionCable, and the channel verifies it before fetching
|
|
207
|
+
or activating the subscription.
|
|
208
|
+
|
|
209
|
+
That token answers: "did this browser receive this exact rendered response?"
|
|
210
|
+
It does not answer: "which subscriber boundary is this?" Identified pages still verify
|
|
211
|
+
the declared identity bridge from Decision A after token verification.
|
|
212
|
+
|
|
213
|
+
This keeps cold first paint live for subscription activation without requiring
|
|
214
|
+
an Upkeep-owned guest cookie. If a page is truly identity-specific, the app
|
|
215
|
+
still declares that identity explicitly.
|
|
216
|
+
|
|
217
|
+
### Decision C — commit→delivery should use the app's job backend
|
|
218
|
+
|
|
219
|
+
**Decision made.** Commit-triggered delivery now has an Active Job boundary.
|
|
220
|
+
When a request commits Active Record changes, Upkeep can enqueue
|
|
221
|
+
`Upkeep::Rails::DeliveryJob` instead of planning, rendering, and broadcasting in
|
|
222
|
+
the writer's request.
|
|
223
|
+
|
|
224
|
+
The default Rails production configuration is
|
|
225
|
+
`config.upkeep.delivery_adapter = :active_job`, with
|
|
226
|
+
`config.upkeep.delivery_queue = :upkeep_realtime`. The job backend is the app's
|
|
227
|
+
normal Active Job backend: Solid Queue, Sidekiq, GoodJob, or another adapter.
|
|
228
|
+
Upkeep does not depend directly on Sidekiq or Solid Queue.
|
|
229
|
+
|
|
230
|
+
ActionCable remains a separate layer. The job worker still broadcasts through
|
|
231
|
+
`ActionCable.server.broadcast`, so multi-process apps need a shared cable
|
|
232
|
+
adapter such as Redis, Solid Cable, or PostgreSQL. Solid Queue plus Solid Cable
|
|
233
|
+
is the no-Redis path. Sidekiq plus Redis-backed ActionCable is the traditional
|
|
234
|
+
Redis path.
|
|
235
|
+
|
|
236
|
+
If enqueueing fails after the write commits, Upkeep instruments
|
|
237
|
+
`delivery_enqueue_error.upkeep` and does not turn the committed write into an
|
|
238
|
+
HTTP 500. Once the job is enqueued, job execution failures are left to the
|
|
239
|
+
configured Active Job backend's retry/error policy.
|
|
240
|
+
|
|
241
|
+
Development and tests can still use `config.upkeep.delivery_adapter = :async`
|
|
242
|
+
for process-local batching or `:inline` for explicit synchronous debugging.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Part 3 — Pre-existing test-suite failures (fixed)
|
|
247
|
+
|
|
248
|
+
The core unit suite is green: `bundle exec rake test` → **200 runs, 0
|
|
249
|
+
failures**. The benchmark app is green: `benchmark/upkeep-app/bin/rails test` →
|
|
250
|
+
**7 runs, 0 failures**. The broader `bundle exec rake proof` task is also
|
|
251
|
+
green, including both benchmark apps and the proof runner.
|
|
252
|
+
|
|
253
|
+
### P1 — `UpkeepChannelTest#test_keeps_connection_state_out_of_transport_on_unsubscribe`
|
|
254
|
+
`test/channels/upkeep_channel_test.rb:35`
|
|
255
|
+
```
|
|
256
|
+
[KeyError] exception expected, not
|
|
257
|
+
Class: <ActiveRecord::RecordNotFound>
|
|
258
|
+
Message: Couldn't find Upkeep::Subscriptions::ActiveRecordStore::SubscriptionRecord with 'id'=...
|
|
259
|
+
```
|
|
260
|
+
**What it means:** after unsubscribe the subscription is gone, but `Upkeep::Rails.subscriptions.fetch(id)`
|
|
261
|
+
raises a **different exception class depending on the store backend** — `KeyError` (memory store, what
|
|
262
|
+
the test asserts) vs `ActiveRecord::RecordNotFound` (AR store, what actually runs). The store
|
|
263
|
+
abstraction leaks its backend through the not-found error type. Low severity (production code in
|
|
264
|
+
`cable/channel.rb#unsubscribed` already rescues *both*), but it's a real contract gap.
|
|
265
|
+
**Fix:** both stores now normalize missing subscriptions to
|
|
266
|
+
`Upkeep::Subscriptions::NotFound < KeyError`, and the channel only needs the
|
|
267
|
+
store-level not-found contract.
|
|
268
|
+
|
|
269
|
+
### P2 — `BenchmarkSurfaceTest#test_delivers_a_streamed_update_through_the_derived_subscriber_stream`
|
|
270
|
+
`test/integration/benchmark_surface_test.rb:85`
|
|
271
|
+
```
|
|
272
|
+
Expected: 1
|
|
273
|
+
Actual: 0
|
|
274
|
+
```
|
|
275
|
+
**What it means:** a **signed-in user** loads a board, a card is updated, and the test expects exactly
|
|
276
|
+
one Turbo Stream broadcast on that user's derived subscriber stream — but **zero** are captured. This is
|
|
277
|
+
the gem's *own* test for identified-subscriber delivery, and it is red at HEAD. It is direct,
|
|
278
|
+
in-repo corroboration of **Decision A**: identity-bound (per-user) delivery does not currently produce
|
|
279
|
+
the expected broadcast. Treat P2 as the failing acceptance test that Decision A must turn green; it is
|
|
280
|
+
the best regression target for whatever identity approach is chosen.
|
|
281
|
+
**Fix:** the benchmark app declares a subscriber identity with `current: ["Current", :user]`
|
|
282
|
+
and resolves it from `cable.current_user`. The benchmark acceptance test now
|
|
283
|
+
activates the persisted subscription before capturing broadcasts, matching the
|
|
284
|
+
ActiveRecord store's on-subscribe indexing model.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Appendix — reproducing
|
|
289
|
+
|
|
290
|
+
From the companion stress app:
|
|
291
|
+
|
|
292
|
+
```sh
|
|
293
|
+
cd ../stress-test-upkeep/stress_app
|
|
294
|
+
bin/rails db:prepare
|
|
295
|
+
bin/rails server -p 3001 # terminal 1
|
|
296
|
+
bin/rails stress:run # terminal 2 (defaults)
|
|
297
|
+
|
|
298
|
+
# targeted repros:
|
|
299
|
+
SUBSCRIBERS=2 BOARDS=1 bin/rails stress:run # Fix 2 (shared remove): "collection remove" 0/2
|
|
300
|
+
SUBSCRIBERS=5 BOARDS=5 bin/rails stress:run # everything green for the unshared case
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Tunables: `URL BOARDS CARDS_PER_BOARD SUBSCRIBERS WRITES WRITE_CONCURRENCY SETTLE
|
|
304
|
+
RECV_TIMEOUT HEADLESS CHROME`. The harness reports delivery completeness, cross-board leaks,
|
|
305
|
+
latency percentiles, and per-boundary correctness.
|
|
306
|
+
|
|
307
|
+
**Deliberate stress-app choices:** the layout omits `csrf_meta_tags`/`csp_meta_tag` (both read
|
|
308
|
+
the session; with the explicit identity model, non-absent declared values partition delivery while
|
|
309
|
+
absent values remain anonymous-public); writes use `skip_forgery_protection`; single Puma process with the
|
|
310
|
+
async cable adapter (multi-worker needs a shared adapter per the README).
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
The Rails package green bar includes the gem tests, the maintained benchmark
|
|
4
|
+
apps, and the proof runner. The benchmark apps live inside this repo under
|
|
5
|
+
`benchmark/`.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
Run the gem test suite:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
mise exec -- ruby -S rake test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run the full gate:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
mise exec -- ruby bin/test
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`bin/test` runs:
|
|
22
|
+
|
|
23
|
+
- all tests under `test/`;
|
|
24
|
+
- `benchmark/upkeep-app`'s Rails test suite;
|
|
25
|
+
- `benchmark/turbo-app`'s Rails test suite;
|
|
26
|
+
- `bin/run`, which writes proof reports to `results/`.
|
|
27
|
+
|
|
28
|
+
Run the proof runner directly:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
mise exec -- ruby bin/run
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What The Gate Covers
|
|
35
|
+
|
|
36
|
+
Gem tests cover:
|
|
37
|
+
|
|
38
|
+
- Action View frame capture and replay;
|
|
39
|
+
- controller request replay;
|
|
40
|
+
- Active Record/Arel query dependency analysis;
|
|
41
|
+
- subscription storage and reverse-index lookups;
|
|
42
|
+
- refused opaque collection boundaries;
|
|
43
|
+
- column-scoped collection lookup keys;
|
|
44
|
+
- invalidation planning;
|
|
45
|
+
- Turbo Stream delivery partitioning;
|
|
46
|
+
- render grouping before replay;
|
|
47
|
+
- ActionCable subscriber identity and channel behavior;
|
|
48
|
+
- transport retry and backpressure behavior.
|
|
49
|
+
|
|
50
|
+
The Upkeep benchmark app covers:
|
|
51
|
+
|
|
52
|
+
- authenticated board rendering;
|
|
53
|
+
- room rendering;
|
|
54
|
+
- shared feed rendering;
|
|
55
|
+
- authorization boundaries;
|
|
56
|
+
- helper-hidden render idioms;
|
|
57
|
+
- automatic subscription registration;
|
|
58
|
+
- streamed delivery through canonical subscriber streams.
|
|
59
|
+
|
|
60
|
+
The Turbo benchmark app remains the comparison app for workload parity.
|
|
61
|
+
|
|
62
|
+
## Store Choice
|
|
63
|
+
|
|
64
|
+
Generated apps use the in-process memory subscription store in `test` by
|
|
65
|
+
default. That is deliberate: app tests usually need to prove the public
|
|
66
|
+
behavior of the subscription lifecycle, not database durability.
|
|
67
|
+
|
|
68
|
+
Use memory-backed tests for the normal request/system suite:
|
|
69
|
+
|
|
70
|
+
- successful HTML GETs inject the subscription marker;
|
|
71
|
+
- the subscription can be activated;
|
|
72
|
+
- mutations select the right streams;
|
|
73
|
+
- broadcasts contain the expected rendered bytes;
|
|
74
|
+
- unauthorized or unrelated bytes are not delivered.
|
|
75
|
+
|
|
76
|
+
Keep a smaller ActiveRecord-backed test or CI path for storage-specific
|
|
77
|
+
coverage:
|
|
78
|
+
|
|
79
|
+
- the generated migration matches the required schema;
|
|
80
|
+
- subscriptions and reverse-index rows persist;
|
|
81
|
+
- lookup survives store reload;
|
|
82
|
+
- pruning removes durable rows;
|
|
83
|
+
- Active Job delivery works with the app's queue adapter.
|
|
84
|
+
|
|
85
|
+
Avoid asserting a store class in general app behavior tests. The memory and
|
|
86
|
+
ActiveRecord stores share the same public lifecycle contract, so app tests
|
|
87
|
+
should mostly assert markers, activation, streams, broadcasts, and rendered
|
|
88
|
+
bytes. Assert `Upkeep::Subscriptions::ActiveRecordStore` only in tests whose
|
|
89
|
+
purpose is durable storage.
|
|
90
|
+
|
|
91
|
+
## App-Level Assertions
|
|
92
|
+
|
|
93
|
+
Maintained Rails apps should assert that successful HTML GETs register a
|
|
94
|
+
subscription:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
include Upkeep::Rails::Testing
|
|
98
|
+
|
|
99
|
+
get board_path(board)
|
|
100
|
+
assert_response :success
|
|
101
|
+
assert_upkeep_subscription_registered
|
|
102
|
+
activate_upkeep_subscription!
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
They should also assert that unauthorized bytes do not render:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
get board_path(private_board)
|
|
109
|
+
assert_response :forbidden
|
|
110
|
+
refute_includes response.body, "Private card"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
For streamed delivery, include `ActionCable::TestHelper`, capture the
|
|
114
|
+
registered stream names through `Upkeep::Rails::Testing`, perform a mutation,
|
|
115
|
+
drain delivery through the testing namespace, and assert the broadcast payload:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
include ActionCable::TestHelper
|
|
119
|
+
include Upkeep::Rails::Testing
|
|
120
|
+
|
|
121
|
+
broadcasts = capture_upkeep_broadcasts do
|
|
122
|
+
patch board_card_path(board, card), params: { card: { title: "Updated" } }
|
|
123
|
+
assert_response :ok
|
|
124
|
+
drain_upkeep_delivery!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
assert_includes broadcasts.join, "Updated"
|
|
128
|
+
refute_includes broadcasts.join, "Other subscriber secret"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Result Reports
|
|
132
|
+
|
|
133
|
+
The proof runner writes:
|
|
134
|
+
|
|
135
|
+
- `results/herb_surface.json`
|
|
136
|
+
- `results/active_record_surface.json`
|
|
137
|
+
- `results/end_to_end_proof.json`
|
|
138
|
+
- `results/identity_safety_proof.json`
|
|
139
|
+
- `results/auth_surfaces_proof.json`
|
|
140
|
+
|
|
141
|
+
These reports are debugging artifacts for maintained app work. They are useful
|
|
142
|
+
when a change alters frame coverage, dependency metadata, identity
|
|
143
|
+
partitioning, or target selection.
|