upkeep-rails 0.1.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.
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 +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -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/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -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 +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -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 +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -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 +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -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 +53 -0
- metadata +296 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Upkeep Rails Production Roadmap
|
|
2
|
+
|
|
3
|
+
This repo contains the Rails-native reactive rendering gem and its in-repo
|
|
4
|
+
dogfood apps.
|
|
5
|
+
The production path is organized around structural proof, not host-maintained
|
|
6
|
+
configuration. The framework must derive dependency and identity inputs from
|
|
7
|
+
the render and request surfaces it observes.
|
|
8
|
+
|
|
9
|
+
## Runtime Boundary
|
|
10
|
+
|
|
11
|
+
Upkeep keeps three production concerns separate:
|
|
12
|
+
|
|
13
|
+
- Subscription store: the queryable graph and reverse index used to decide
|
|
14
|
+
which subscribers and targets are affected by a lifecycle event. The current
|
|
15
|
+
production store is `config.upkeep.subscription_store = :active_record`.
|
|
16
|
+
`:memory` is an explicit development/test store only.
|
|
17
|
+
- Dispatcher: the async execution boundary that moves delivery work off the
|
|
18
|
+
request thread. The current dispatcher is process-local batching. A future
|
|
19
|
+
ActiveJob dispatcher can use Solid Queue, Redis-backed queues, or whichever
|
|
20
|
+
queue adapter the host app has configured.
|
|
21
|
+
- Broadcast bus: the final delivery path to browser connections. Upkeep uses
|
|
22
|
+
ActionCable; multi-worker deployments need a shared ActionCable adapter such
|
|
23
|
+
as Redis or Solid Cable.
|
|
24
|
+
|
|
25
|
+
Missing production subscription tables are a boot/configuration error, not a
|
|
26
|
+
reason to fall back to memory.
|
|
27
|
+
|
|
28
|
+
## Design Boundary
|
|
29
|
+
|
|
30
|
+
Rails runtime renderers are the authority for render shape. Upkeep should hook
|
|
31
|
+
the normalized renderer choke points Rails already owns:
|
|
32
|
+
|
|
33
|
+
- `ActionView::TemplateRenderer` for template/page frames.
|
|
34
|
+
- `ActionView::PartialRenderer` and `ActionView::ObjectRenderer` for partial
|
|
35
|
+
and object-derived partial frames.
|
|
36
|
+
- `ActionView::CollectionRenderer` for collection render-site frames and child
|
|
37
|
+
partial frames.
|
|
38
|
+
- `ActionView::Renderer` only as an entry boundary when a lower hook cannot
|
|
39
|
+
prove the needed frame.
|
|
40
|
+
|
|
41
|
+
Herb is an address and validation layer. It can produce stable source-location
|
|
42
|
+
ids, verify single-root fragment eligibility, and improve static DOM tagging.
|
|
43
|
+
It must not become a catalog of every Rails render spelling. If support for a
|
|
44
|
+
render shape requires syntax-specific cases, the hook is at the wrong layer or
|
|
45
|
+
the runtime proof is incomplete.
|
|
46
|
+
|
|
47
|
+
## Production Gates
|
|
48
|
+
|
|
49
|
+
### Gate 1: Gem-Shaped Runtime
|
|
50
|
+
|
|
51
|
+
- Gem specification, load paths, `Upkeep::Rails` namespace, and `Railtie`.
|
|
52
|
+
- Idempotent install hooks for ActionView, Active Record, ActionDispatch,
|
|
53
|
+
Warden, and `ActiveSupport::CurrentAttributes`.
|
|
54
|
+
- Runtime code separated from proof-only templates and in-memory domain models.
|
|
55
|
+
- Test runner that exercises both the proof harness and Rails integration tests.
|
|
56
|
+
|
|
57
|
+
Exit criteria:
|
|
58
|
+
|
|
59
|
+
- A Rails app can require the gem and boot with hooks installed.
|
|
60
|
+
- Hooks are controlled by environment/config.
|
|
61
|
+
- `bin/test` runs the gem tests and the proof runner.
|
|
62
|
+
|
|
63
|
+
### Gate 2: ActionView Graph Capture
|
|
64
|
+
|
|
65
|
+
- Template, partial, object partial, and collection render paths create graph
|
|
66
|
+
frames from Rails-resolved templates and locals.
|
|
67
|
+
- Collection renders create render-site frames without parsing render syntax.
|
|
68
|
+
- Active Record attribute and collection dependencies attach to the current
|
|
69
|
+
frame.
|
|
70
|
+
- Current user, Warden user, CurrentAttributes, session, cookie, and request
|
|
71
|
+
dependencies attach to the frame that reads them.
|
|
72
|
+
|
|
73
|
+
Exit criteria:
|
|
74
|
+
|
|
75
|
+
- Tests prove several render spellings produce equivalent graph shape through
|
|
76
|
+
Rails renderer hooks.
|
|
77
|
+
- The graph report explains selected targets through frame nodes, dependency
|
|
78
|
+
nodes, owner edges, and containment edges.
|
|
79
|
+
|
|
80
|
+
### Gate 3: Canonical Replay Inputs
|
|
81
|
+
|
|
82
|
+
- Page replay stores controller/action, params, request environment, assigns,
|
|
83
|
+
lookup context, variants, formats, and observed ambient identity inputs.
|
|
84
|
+
- Render-site replay stores the Rails-resolved collection boundary and enough
|
|
85
|
+
canonical inputs to rerender membership for one subscriber.
|
|
86
|
+
- Fragment replay stores Rails-resolved template identity, locals, variants,
|
|
87
|
+
formats, and identity inputs.
|
|
88
|
+
- Replay never depends on host-declared dependency lists.
|
|
89
|
+
|
|
90
|
+
Exit criteria:
|
|
91
|
+
|
|
92
|
+
- Page, render-site, and fragment recipes render the selected target bytes in a
|
|
93
|
+
real Rails app.
|
|
94
|
+
- Replay fails closed when canonical inputs are not structurally available.
|
|
95
|
+
|
|
96
|
+
### Gate 4: Subscription Storage And Reverse Index
|
|
97
|
+
|
|
98
|
+
- Store per-subscriber graph, identity signature, replay recipes, and selected
|
|
99
|
+
target metadata in the configured subscription store.
|
|
100
|
+
- Build reverse indexes from lifecycle-selectable dependency keys to
|
|
101
|
+
subscriber graph owners.
|
|
102
|
+
- Walk from changed dependency to affected frames across stored graphs.
|
|
103
|
+
- Deduplicate by subscriber, target id, and identity signature.
|
|
104
|
+
- Fail fast when production is configured for `:active_record` but the Upkeep
|
|
105
|
+
subscription tables are missing.
|
|
106
|
+
|
|
107
|
+
Exit criteria:
|
|
108
|
+
|
|
109
|
+
- An Active Record commit selects affected subscriber frames without scanning
|
|
110
|
+
every stored graph.
|
|
111
|
+
- Identity-partitioned targets produce separate payloads unless identity
|
|
112
|
+
signatures prove equivalence.
|
|
113
|
+
|
|
114
|
+
### Gate 5: Delivery Pipeline
|
|
115
|
+
|
|
116
|
+
- Generate Turbo Stream replacement payloads for selected targets.
|
|
117
|
+
- Partition payloads by identity signature.
|
|
118
|
+
- Handle disconnect cleanup, retries, and backpressure.
|
|
119
|
+
- Keep delivery separate from correctness proof; delivery consumes selected
|
|
120
|
+
target payloads and does not decide who is eligible.
|
|
121
|
+
|
|
122
|
+
Exit criteria:
|
|
123
|
+
|
|
124
|
+
- Benchmark app tests prove no cross-subscriber payload sharing when identity
|
|
125
|
+
observations differ.
|
|
126
|
+
- Delivery reports expose selected dependency, frame, identity, and payload
|
|
127
|
+
digest evidence.
|
|
128
|
+
|
|
129
|
+
### Gate 6: Production Hardening
|
|
130
|
+
|
|
131
|
+
- Fail-closed handling for unknown dependencies, missing replay inputs, and
|
|
132
|
+
unaddressable DOM roots.
|
|
133
|
+
- Benchmark workload coverage for the maintained Rails apps.
|
|
134
|
+
- Memory, fan-out, and replay latency measurements.
|
|
135
|
+
- Compatibility matrix across supported Rails versions.
|
|
136
|
+
|
|
137
|
+
Exit criteria:
|
|
138
|
+
|
|
139
|
+
- The gem can be used by the maintained benchmark app with no proof-only code
|
|
140
|
+
paths.
|
|
141
|
+
- The green bar includes unit tests, Rails integration tests, proof runner, and
|
|
142
|
+
benchmark app tests.
|
|
143
|
+
|
|
144
|
+
## First Session
|
|
145
|
+
|
|
146
|
+
The first session targets Gates 1 and 2:
|
|
147
|
+
|
|
148
|
+
1. Add gem/Railtie structure with idempotent install hooks.
|
|
149
|
+
2. Add an ActionView capture proof that renders through Rails, not the in-memory
|
|
150
|
+
proof harness.
|
|
151
|
+
3. Prove multiple render spellings produce graph frames through renderer hooks.
|
|
152
|
+
4. Keep subscription storage and Turbo delivery out of the session unless the
|
|
153
|
+
renderer proof lands cleanly.
|
|
154
|
+
|
|
155
|
+
The session is complete when the repo has commits for the roadmap, gem
|
|
156
|
+
structure, and Rails-renderer graph capture with passing tests.
|
|
157
|
+
|
|
158
|
+
## Second Session
|
|
159
|
+
|
|
160
|
+
The second session targets the first Gate 3 proof:
|
|
161
|
+
|
|
162
|
+
1. Attach replay recipes to Rails ActionView page, fragment, and render-site
|
|
163
|
+
frames.
|
|
164
|
+
2. Replay Rails-resolved templates with captured locals and fresh Active Record
|
|
165
|
+
records or relations.
|
|
166
|
+
3. Prove fragment replay reflects a changed record attribute.
|
|
167
|
+
4. Prove render-site replay reflects a changed collection membership.
|
|
168
|
+
5. Keep controller/request replay and delivery out of the session unless the
|
|
169
|
+
ActionView replay proof lands cleanly.
|
|
170
|
+
|
|
171
|
+
The session is complete when Rails renderer tests prove selected ActionView
|
|
172
|
+
targets can be rerendered from frame recipes without a full-page extraction.
|
|
173
|
+
|
|
174
|
+
Current coverage:
|
|
175
|
+
|
|
176
|
+
- Page recipes rerender Rails templates with fresh Active Record relations.
|
|
177
|
+
- Render-site recipes rerender Rails collection boundaries with fresh
|
|
178
|
+
membership.
|
|
179
|
+
- Fragment recipes rerender Rails-resolved partial templates with fresh Active
|
|
180
|
+
Record records.
|
|
181
|
+
|
|
182
|
+
## Third Session
|
|
183
|
+
|
|
184
|
+
The third session completes the first controller/request replay proof:
|
|
185
|
+
|
|
186
|
+
1. Derive page replay from the controller attached to the Rails view context.
|
|
187
|
+
2. Capture controller class, action name, request method, path, query string,
|
|
188
|
+
and path-parameter keys in the page frame metadata.
|
|
189
|
+
3. Replay the page by re-entering `controller.class.action(action).call(env)`.
|
|
190
|
+
4. Prove controller page replay reruns the action with request parameters and
|
|
191
|
+
fresh Active Record state.
|
|
192
|
+
5. Keep durable subscription storage and delivery out of the session.
|
|
193
|
+
|
|
194
|
+
Current coverage:
|
|
195
|
+
|
|
196
|
+
- Controller-backed page recipes rerun the controller action.
|
|
197
|
+
- Request query parameters are preserved for replay.
|
|
198
|
+
- Controller replay uses fresh Active Record state through the action's own
|
|
199
|
+
query path.
|
|
200
|
+
- Path parameter values are carried in the replay environment; route-level
|
|
201
|
+
dispatch through a full Rails application remains a later Gate 3 step.
|
|
202
|
+
|
|
203
|
+
## Fourth Session
|
|
204
|
+
|
|
205
|
+
The fourth session targets Gate 4:
|
|
206
|
+
|
|
207
|
+
1. Store subscriber render graphs with their replay recipes and identity
|
|
208
|
+
signatures.
|
|
209
|
+
2. Build a reverse index from observed dependency keys to graph owners.
|
|
210
|
+
3. Plan invalidations from committed Active Record changes by walking from
|
|
211
|
+
matched dependency owners to containing frames.
|
|
212
|
+
4. Deduplicate planned work by subscriber, target, and identity signature.
|
|
213
|
+
5. Prove that identity-partitioned targets keep separate payloads and that
|
|
214
|
+
ambient identity observations stay attached to the replay target.
|
|
215
|
+
|
|
216
|
+
Current coverage:
|
|
217
|
+
|
|
218
|
+
- Active Record collection membership changes select render-site targets across
|
|
219
|
+
stored subscriptions through the reverse index.
|
|
220
|
+
- Active Record attribute changes select fragment targets without scanning each
|
|
221
|
+
stored graph.
|
|
222
|
+
- Duplicate subscriptions for the same subscriber, target, and identity
|
|
223
|
+
signature collapse to one planned target.
|
|
224
|
+
- `Current.user` identity observations partition fragment payloads for users
|
|
225
|
+
with different visibility.
|
|
226
|
+
- CurrentAttributes, session, cookie, request, and Warden observations are
|
|
227
|
+
preserved on page targets that read those surfaces.
|
|
228
|
+
- ActiveRecord is the explicit production subscription store. Memory storage is
|
|
229
|
+
still available for development/test, but it is no longer selected as a
|
|
230
|
+
hidden fallback.
|
|
231
|
+
|
|
232
|
+
## Fifth Session
|
|
233
|
+
|
|
234
|
+
The fifth session targets the first Gate 5 delivery pass:
|
|
235
|
+
|
|
236
|
+
1. Convert planned targets into Turbo Stream replacement payloads.
|
|
237
|
+
2. Address page, fragment, and render-site targets through their observed
|
|
238
|
+
Upkeep DOM attributes.
|
|
239
|
+
3. Partition payloads by subscriber, target, identity signature, and rendered
|
|
240
|
+
payload digest.
|
|
241
|
+
4. Share rendered work across subscribers only when the target, identity
|
|
242
|
+
signature, sharing inputs, and replay recipe prove equivalence; merge
|
|
243
|
+
separately rendered payloads when the payload bytes also match.
|
|
244
|
+
5. Report target, subscriber, identity, dependency, and payload digest evidence
|
|
245
|
+
for each stream and envelope.
|
|
246
|
+
|
|
247
|
+
Current coverage:
|
|
248
|
+
|
|
249
|
+
- Public fragment payloads and equivalent shared render-site payloads are
|
|
250
|
+
grouped before replay when the action, target, identity signature, sharing
|
|
251
|
+
signature, replay recipe, and deoptimization reason match.
|
|
252
|
+
- Render-site payloads target the collection wrapper through a Turbo Stream
|
|
253
|
+
`targets` selector.
|
|
254
|
+
- Identity-partitioned fragment payloads produce separate subscriber envelopes
|
|
255
|
+
when visible bytes differ.
|
|
256
|
+
- Delivery reports expose the selected target, identity signature, matched
|
|
257
|
+
dependency keys, subscriber ids, and payload digest.
|
|
258
|
+
|
|
259
|
+
Remaining Gate 5 work:
|
|
260
|
+
|
|
261
|
+
- Keep the Rails runtime path on the broadcast transport and the lower-level
|
|
262
|
+
connection transport covered as separate delivery boundaries.
|
|
263
|
+
|
|
264
|
+
## Sixth Session
|
|
265
|
+
|
|
266
|
+
The sixth session targets the Gate 5 transport boundary:
|
|
267
|
+
|
|
268
|
+
1. Track active subscriber connections by subscriber id.
|
|
269
|
+
2. Deliver subscriber envelopes through an adapter protocol.
|
|
270
|
+
3. Clean queued retry work when a subscriber disconnects.
|
|
271
|
+
4. Retry failed sends up to a bounded attempt count.
|
|
272
|
+
5. Apply per-connection backpressure with a bounded retry queue.
|
|
273
|
+
6. Report delivery outcomes without selecting or filtering eligible targets.
|
|
274
|
+
|
|
275
|
+
Current coverage:
|
|
276
|
+
|
|
277
|
+
- Connected subscribers receive only their own envelopes.
|
|
278
|
+
- Disconnected subscribers are reported without invoking an adapter.
|
|
279
|
+
- Failed sends queue retry work and retry delivery preserves the original
|
|
280
|
+
envelope.
|
|
281
|
+
- Repeated failures drop after the configured retry limit.
|
|
282
|
+
- A full retry queue reports backpressure without growing retained work.
|
|
283
|
+
- The Rails runtime delivery path broadcasts through ActionCable subscriber
|
|
284
|
+
streams without depending on process-local WebSocket connection ownership.
|
|
285
|
+
|
|
286
|
+
Open Gate 5 work:
|
|
287
|
+
|
|
288
|
+
- Keep delivery outcome reporting aligned across the connection transport and
|
|
289
|
+
the broadcast transport.
|
|
290
|
+
- Add an ActiveJob dispatcher mode after the storage boundary is stable, so apps
|
|
291
|
+
can route async delivery through Solid Queue or another configured job
|
|
292
|
+
adapter without changing the subscription store.
|
|
293
|
+
|
|
294
|
+
## Seventh Session
|
|
295
|
+
|
|
296
|
+
The seventh session targets the Rails cable broadcast adapter:
|
|
297
|
+
|
|
298
|
+
1. Add an ActionCable delivery adapter for the transport boundary.
|
|
299
|
+
2. Broadcast each subscriber envelope body to a canonical subscriber stream.
|
|
300
|
+
3. Hide raw subscriber ids from stream names.
|
|
301
|
+
4. Emit delivery notifications with stream, subscriber, digest, and byte-size
|
|
302
|
+
evidence.
|
|
303
|
+
5. Prove ActionCable broadcast failures flow through transport retry handling.
|
|
304
|
+
|
|
305
|
+
Current coverage:
|
|
306
|
+
|
|
307
|
+
- The adapter broadcasts envelope bodies through `ActionCable.server.broadcast`
|
|
308
|
+
compatible servers.
|
|
309
|
+
- Subscriber stream names are derived from a digest of the subscriber id.
|
|
310
|
+
- `deliver.upkeep` notifications expose broadcast evidence without carrying
|
|
311
|
+
rendered payload bytes.
|
|
312
|
+
- Transport retries failed ActionCable broadcasts through the existing retry
|
|
313
|
+
queue.
|
|
314
|
+
- The Rails cable subscription side derives server identity from the
|
|
315
|
+
ActionCable connection and streams from each canonical identity stream.
|
|
316
|
+
- The maintained Upkeep benchmark app covers streamed delivery through the
|
|
317
|
+
derived subscriber stream.
|
|
318
|
+
- The install generator creates subscription storage, an initializer, an
|
|
319
|
+
ActionCable route mount, importmap wiring, and the browser subscription
|
|
320
|
+
bootstrap.
|
|
321
|
+
- The browser subscription bootstrap reads injected subscription markers,
|
|
322
|
+
subscribes through ActionCable, and applies Turbo Stream payloads.
|
|
323
|
+
|
|
324
|
+
## Benchmark Layout
|
|
325
|
+
|
|
326
|
+
The maintained benchmark apps live under `benchmark/upkeep-app` and
|
|
327
|
+
`benchmark/turbo-app` in this repo. The committed benchmark tree contains
|
|
328
|
+
source and harness files; generated results, logs, temporary files, runtime
|
|
329
|
+
databases, Bundler lockfiles, Playwright install artifacts, and credentials are
|
|
330
|
+
excluded.
|
|
331
|
+
|
|
332
|
+
Current coverage:
|
|
333
|
+
|
|
334
|
+
- The Herb surface probe scans the in-repo benchmark apps.
|
|
335
|
+
- `benchmark/bin/run` defaults to the `matrix` family, which targets the
|
|
336
|
+
in-repo `upkeep-app` and `turbo-app`.
|
|
337
|
+
- The gem test suite verifies the benchmark tree points at the in-repo apps
|
|
338
|
+
and excludes generated runtime artifacts.
|
|
339
|
+
- `bin/test` runs the gem tests, both benchmark app test suites, and the proof
|
|
340
|
+
runner as one gate.
|
|
341
|
+
- The benchmark app suites cover authenticated board rendering, room rendering,
|
|
342
|
+
shared feed rendering, authorization boundaries, and helper-hidden render
|
|
343
|
+
idioms.
|
|
344
|
+
- The Rails cable subscription boundary derives subscriber identity from
|
|
345
|
+
ActionCable connection identifiers and rejects unidentified connections.
|
|
346
|
+
- Subscription graphs persist graph snapshots and reverse-index rows before
|
|
347
|
+
registration returns, then register into the active in-process reverse index
|
|
348
|
+
on the request path.
|
|
349
|
+
- Persisted Active Record subscription rows rehydrate recipes that render after
|
|
350
|
+
a fresh store load.
|
|
351
|
+
- Controller requests automatically capture render graphs, register successful
|
|
352
|
+
HTML responses, inject the client subscription marker, and drain committed
|
|
353
|
+
Active Record changes into streamed delivery.
|
|
354
|
+
- The Upkeep benchmark app covers automatic subscription registration and
|
|
355
|
+
streamed delivery through the canonical subscriber stream.
|
|
356
|
+
- Active Record collection dependencies derive table and column coverage from
|
|
357
|
+
the relation's Arel shape. Proven relations index concrete table/column
|
|
358
|
+
lookup keys; unrelated column writes do not select those collections; opaque
|
|
359
|
+
predicates and table sources are refused instead of registering broad
|
|
360
|
+
invalidation dependencies.
|
|
361
|
+
- Identity, request, session, and cookie dependencies stay on the stored graph
|
|
362
|
+
for replay and sharing, but do not create lifecycle invalidation lookup rows.
|
|
363
|
+
- Collection append delivery is gated by Active Record relation shape and
|
|
364
|
+
remains a delivery optimization over the canonical render-site replacement
|
|
365
|
+
recipe.
|
|
366
|
+
|
|
367
|
+
Open work:
|
|
368
|
+
|
|
369
|
+
- Add compatibility matrix runs, memory/fan-out measurements, and replay
|
|
370
|
+
latency gates for the maintained benchmark workloads.
|
|
371
|
+
- Add a multi-worker benchmark gate with the ActiveRecord subscription store
|
|
372
|
+
and a shared ActionCable adapter.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Shared Warm Scale Roadmap
|
|
2
|
+
|
|
3
|
+
This roadmap targets the shape where Upkeep should outperform Turbo: many
|
|
4
|
+
subscribers share an identity-free page and one mutation fans out to all of
|
|
5
|
+
them.
|
|
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 shared benchmark data is the 2026-05-21 ramped 200-subscriber report
|
|
20
|
+
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 ramped identity-free 200-subscriber report:
|
|
38
|
+
|
|
39
|
+
- Report: `benchmark/results/identity-free-feed-compare-20260521113822.md`
|
|
40
|
+
- Upkeep setup p95: `58ms`
|
|
41
|
+
- Turbo setup p95: `38.05ms`
|
|
42
|
+
- Upkeep page render p95: `47ms`
|
|
43
|
+
- Turbo page render p95: `24.05ms`
|
|
44
|
+
- Upkeep WebSocket connect p95: `9ms`
|
|
45
|
+
- Turbo WebSocket connect p95: `11ms`
|
|
46
|
+
- Upkeep subscribe call p95: `6ms`
|
|
47
|
+
- Turbo subscribe call p95: `2ms`
|
|
48
|
+
- Upkeep subscribe ack p95: `5ms`
|
|
49
|
+
- Turbo subscribe ack p95: `1.05ms`
|
|
50
|
+
- Upkeep write POST p95: `58.90ms`
|
|
51
|
+
- Turbo write POST p95: `58.69ms`
|
|
52
|
+
- Upkeep update-settled p95: `191ms`
|
|
53
|
+
- Turbo update-settled p95: `342.10ms`
|
|
54
|
+
- Upkeep render groups: `1`
|
|
55
|
+
- Upkeep represented subscribers: `200`
|
|
56
|
+
- Turbo refresh GETs: `200`
|
|
57
|
+
- Upkeep render dedup savings: `199`
|
|
58
|
+
- Upkeep plans: `1`
|
|
59
|
+
- Upkeep stream batches: `1`
|
|
60
|
+
- Subscription shape cache: `199` hits, `1` miss, `0` bypasses
|
|
61
|
+
- Shape timing: hit key p95 `0.031ms`; hit total p95 `0.049ms`; miss total
|
|
62
|
+
`4.324ms`
|
|
63
|
+
- Setup page server timing: Upkeep p95 `34.28ms`; Turbo p95 `10.12ms`
|
|
64
|
+
- Write request server timing: Upkeep p95 `15.74ms`; Turbo p95 `34.74ms`
|
|
65
|
+
- `GET /feed` request-capture timing: total p95 `33.081ms`; action p95
|
|
66
|
+
`14.896ms`; view p95 `15.456ms`; template p95 `11.104ms`; collection render
|
|
67
|
+
p95 `4.362ms`; SQL p95 `0.191ms`; register p95 `3.814ms`
|
|
68
|
+
- `GET /feed` recorder timing: dependency p95 `3.916ms`; frame p95 `1.364ms`;
|
|
69
|
+
shape trace p95 `3.588ms`
|
|
70
|
+
- Subscribe channel server timing: total p95 `1.884ms`; activation p95
|
|
71
|
+
`1.776ms`
|
|
72
|
+
- Upkeep live deopts: `0`
|
|
73
|
+
|
|
74
|
+
The shared primitive is working: one anonymous shape, one plan, one render, and
|
|
75
|
+
one stream batch fan out to 200 subscribers. This pass removed the full-recorder
|
|
76
|
+
shape key from the cache path, removed the O(n) cohort rebuild from each
|
|
77
|
+
subscriber activation, moved subscription-shape identity into DAG/runtime, and
|
|
78
|
+
made shape hits use a recorder-side rolling trace digest. This pass then
|
|
79
|
+
trimmed the trace and dependency path further by removing unused trace snapshots,
|
|
80
|
+
excluding duplicate manifest data from shape terms, memoizing render-site stream
|
|
81
|
+
signatures, caching static template metadata, and flushing dependencies in
|
|
82
|
+
batches per frame/request. Shape hits and server-side subscription activation
|
|
83
|
+
are no longer shared-path bottlenecks. The next work is reducing Action View
|
|
84
|
+
template/page capture overhead while preserving the one-render fanout path.
|
|
85
|
+
|
|
86
|
+
## Priorities
|
|
87
|
+
|
|
88
|
+
1. Close page/capture fixed cost
|
|
89
|
+
|
|
90
|
+
Upkeep update delivery is much faster, and shape hits are no longer a
|
|
91
|
+
meaningful cost. In the latest shared report, setup page server p95 is
|
|
92
|
+
`34.28ms` versus Turbo `10.12ms`. `GET /feed` request-capture total p95 is
|
|
93
|
+
`33.081ms`, action p95 is `14.896ms`, view p95 is `15.456ms`, and
|
|
94
|
+
registration p95 is `3.814ms`.
|
|
95
|
+
|
|
96
|
+
Candidate work:
|
|
97
|
+
|
|
98
|
+
- Reduce Action View capture overhead around template and collection render
|
|
99
|
+
instrumentation. SQL is only `0.273ms` p95 in the shared report.
|
|
100
|
+
- Continue reducing Action View template capture overhead; template p95 is
|
|
101
|
+
now the largest named subphase at `11.104ms`.
|
|
102
|
+
- Keep dependency flushing batched and use `recorder_dependency_flush_count`
|
|
103
|
+
to separate read count from unique graph inserts.
|
|
104
|
+
- Investigate whether page/layout frame capture can be represented with one
|
|
105
|
+
page boundary without losing layout identity safety.
|
|
106
|
+
- Preserve the rolling trace digest as order-sensitive: false cache misses
|
|
107
|
+
are acceptable, unsafe sharing is not.
|
|
108
|
+
|
|
109
|
+
Target outcome:
|
|
110
|
+
|
|
111
|
+
- 200-subscriber `GET /feed` request-capture total p95 falls from
|
|
112
|
+
`33.081ms` toward `15-20ms`.
|
|
113
|
+
- Page render p95 closes most of the remaining shared setup gap.
|
|
114
|
+
|
|
115
|
+
2. Explain client setup gap
|
|
116
|
+
|
|
117
|
+
Server-side subscribe p95 is `1.884ms` while client-observed subscribe ack
|
|
118
|
+
p95 is `5ms`, and setup p95 is `58ms`. Server/client phase correlation
|
|
119
|
+
now shows that the largest shared setup gap is setup page request time:
|
|
120
|
+
Upkeep `34.28ms` versus Turbo `10.12ms`.
|
|
121
|
+
|
|
122
|
+
Candidate work:
|
|
123
|
+
|
|
124
|
+
- Keep phase labels on page setup, Turbo refresh, write POST, WebSocket
|
|
125
|
+
connect, cable open, subscription registration, and confirmation.
|
|
126
|
+
- Separate browser/k6 scheduling delay from Rails server time when comparing
|
|
127
|
+
setup p95.
|
|
128
|
+
- Filter health checks out of request-capture operation reporting so
|
|
129
|
+
`GET /feed` stays the visible target.
|
|
130
|
+
|
|
131
|
+
Target outcome:
|
|
132
|
+
|
|
133
|
+
- The report continues to explain the difference between `33.081ms`
|
|
134
|
+
server capture p95 and `58ms` client setup p95 without mixing Turbo
|
|
135
|
+
refresh GETs into setup-page timings.
|
|
136
|
+
|
|
137
|
+
3. Explain and reduce write-path variance
|
|
138
|
+
|
|
139
|
+
Latest write POST p95 is `58.90ms` for Upkeep and `58.69ms` for Turbo.
|
|
140
|
+
Server-side write request p95 is `15.74ms` for Upkeep and `34.74ms` for
|
|
141
|
+
Turbo. Keep this instrumented so future fanout work does not hide write-side
|
|
142
|
+
regressions.
|
|
143
|
+
|
|
144
|
+
Candidate work:
|
|
145
|
+
|
|
146
|
+
- Split the current mutation action timing into Active Record write,
|
|
147
|
+
invalidation planning, stream build, ActionCable enqueue, and response
|
|
148
|
+
return.
|
|
149
|
+
- Separate writer VU timing from subscriber delivery timing in the report.
|
|
150
|
+
- Verify the write regression is not caused by shape-cache bookkeeping.
|
|
151
|
+
|
|
152
|
+
Target outcome:
|
|
153
|
+
|
|
154
|
+
- The report identifies whether write latency is database, planning, delivery
|
|
155
|
+
enqueue, or benchmark noise.
|
|
156
|
+
|
|
157
|
+
4. Fanout packing
|
|
158
|
+
|
|
159
|
+
Upkeep still has one unavoidable frame per subscriber, but payload and
|
|
160
|
+
envelope work should happen once per render group.
|
|
161
|
+
|
|
162
|
+
Candidate work:
|
|
163
|
+
|
|
164
|
+
- Serialize payload once.
|
|
165
|
+
- Build frame/envelope once per stream group when possible.
|
|
166
|
+
- Keep per-connection work to stream lookup and socket write.
|
|
167
|
+
|
|
168
|
+
Target outcome:
|
|
169
|
+
|
|
170
|
+
- 500/1000 subscriber runs preserve one render group and avoid a new CPU
|
|
171
|
+
bottleneck in payload packing.
|
|
172
|
+
|
|
173
|
+
5. Benchmark/report hygiene
|
|
174
|
+
|
|
175
|
+
The ramped benchmark is now the right warm shared shape. The report should
|
|
176
|
+
continue to make fixed-cost and shared-cost claims separately.
|
|
177
|
+
|
|
178
|
+
Candidate work:
|
|
179
|
+
|
|
180
|
+
- Keep setup ramp/window fields, `steady_state_setup_leaks`, plans, stream
|
|
181
|
+
batches, represented subscribers, and shape-cache counters in markdown and
|
|
182
|
+
JSON.
|
|
183
|
+
- Keep shape-cache timing, request-capture timing, and server-side subscribe
|
|
184
|
+
timing in markdown and JSON.
|
|
185
|
+
- Keep client setup phase correlation and mutation write phase timing.
|
|
186
|
+
- Keep request-capture timings grouped by operation.
|
|
187
|
+
- Keep cold burst capacity and single-subscriber cold setup as separate
|
|
188
|
+
reports.
|
|
189
|
+
|
|
190
|
+
## Success Criteria
|
|
191
|
+
|
|
192
|
+
- 200-subscriber update-settled p95 remains materially faster than Turbo.
|
|
193
|
+
- 200-subscriber setup p95 explains and narrows the current `58ms` versus
|
|
194
|
+
Turbo `38.05ms` gap while preserving one-render fanout.
|
|
195
|
+
- 500-subscriber report shows one render group, high delivery/render ratio, and
|
|
196
|
+
no setup leaks.
|
|
197
|
+
- Planning/build batch count for the one-write shared feed path stays at one.
|
|
198
|
+
- Shape cache has one miss and N-1 hits for identical anonymous pages.
|
|
199
|
+
- Anonymous deopts remain zero for identity-free pages.
|
|
200
|
+
- Live delivery deopts remain zero for the common feed create case.
|
|
201
|
+
|
|
202
|
+
## Validation
|
|
203
|
+
|
|
204
|
+
Use the mise environment in every shell:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
eval "$(mise activate zsh)"
|
|
208
|
+
bundle exec rake test
|
|
209
|
+
BENCH_FAMILY=render_dedup BENCH_WORKLOAD=identity_free_feed_compare BENCH_TIER=report BENCH_VUS=200 ruby benchmark/bin/run
|
|
210
|
+
BENCH_FAMILY=render_dedup BENCH_WORKLOAD=identity_free_feed_compare BENCH_TIER=report BENCH_VUS=500 ruby benchmark/bin/run
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Use the warm shared benchmark for shared-update economics. Do not use cold
|
|
214
|
+
burst failure or accept-queue behavior to judge render dedup performance.
|