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.

Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,703 @@
1
+ # Upkeep Rails Cost Model Roadmap
2
+
3
+ This roadmap tracks the work needed to make `upkeep-rails` competitive on the
4
+ runtime surfaces where it can win.
5
+
6
+ Last updated: 2026-05-21.
7
+
8
+ Current code state:
9
+
10
+ - Base runtime/docs commit: `474fb9c` (`Instrument capture and document Upkeep Rails`).
11
+ - Current pass adds gated request/action profiling, operation-scoped capture
12
+ metrics, mutation action/delivery timings, client/server phase correlation,
13
+ benchmark phase labels, lightweight subscription-shape tracing, static
14
+ template metadata caching, shared-stream signature memoization, and queued
15
+ dependency flushing.
16
+ - Latest verification: `bundle exec rake test` passed with `186` runs and
17
+ `1172` assertions.
18
+ - Latest benchmark data is from the 2026-05-21 identity-free feed compare
19
+ reports below.
20
+
21
+ The target is not "cheaper than hand-written Turbo Streams for every case."
22
+ Hand-written `append`, `prepend`, `remove`, `replace`, and `update` operations
23
+ are cheaper when the app already knows the exact stream, target, and operation.
24
+ Upkeep's target is the space where apps otherwise fall back to
25
+ `broadcast_refreshes_to`, broad fanout, or duplicated manual stream rules.
26
+
27
+ ## Work Lanes
28
+
29
+ Keep the work split into three lanes:
30
+
31
+ - Runtime behavior: change capture, dependency indexing, invalidation planning,
32
+ stream operation proofs, subscription lifecycle, store/dispatcher/broadcast
33
+ boundaries, and delivery semantics.
34
+ - Deoptimization diagnostics: explanations for cases where the runtime falls
35
+ back from a cheaper proven operation to a broader conservative operation.
36
+ - Benchmarks and telemetry: timing, payload, fanout, and comparison reports used
37
+ to validate claims. This lane should measure the runtime; it should not drive
38
+ runtime behavior.
39
+
40
+ ## Current Runtime Shape
41
+
42
+ The current Rails runtime observes Action View render frames, Active Record
43
+ attribute reads, Active Record collection renders, and request identity
44
+ surfaces. It stores subscription graphs, plans invalidations through a reverse
45
+ index, rerenders selected targets, and delivers Turbo Stream HTML over
46
+ ActionCable.
47
+
48
+ The current implementation has these cost boundaries:
49
+
50
+ - Change events carry explicit lifecycle type plus table, model, id, changed
51
+ attributes, and normalized old/new values for row-level writes.
52
+ - Collection invalidation lookup is keyed by proven table/column pairs and then
53
+ matched against structurally visible predicate values where available.
54
+ - Delivery emits rendered HTML in Turbo Stream envelopes. It does not currently
55
+ emit keyed HTML diff ops.
56
+ - Collection `append`, `prepend`, `remove`, and stable member `replace` exist
57
+ as guarded operation optimizations. Member updates that leave the relation or
58
+ change rendered order still fall back to render-site replacement.
59
+ - Subscription rows and reverse-index entries persist. Registration is
60
+ live-first: the active in-process registry is updated synchronously, while
61
+ durable Active Record rows are written by a coalesced writer. Channel
62
+ subscribe does not write liveness metadata; explicit touch/prune maintenance
63
+ owns stale-row cleanup.
64
+ - Production subscription storage is explicit: `:active_record` is the supported
65
+ queryable reverse-index store, while `:memory` is an explicit development/test
66
+ store. There is no runtime fallback from ActiveRecord to memory.
67
+ - The dispatcher and broadcast bus are separate from subscription storage. The
68
+ current dispatcher is process-local async batching; a future ActiveJob
69
+ dispatcher may use Solid Queue, Redis-backed queues, or any app queue adapter.
70
+ Broadcast delivery still goes through ActionCable and needs a shared
71
+ ActionCable adapter in multi-worker deployments.
72
+ - Planning and Turbo Stream batch building report selected actions,
73
+ deoptimization reasons, render counts, render duration, payload bytes, and
74
+ envelope counts. Benchmarking and broader telemetry stay separate from
75
+ runtime proof work.
76
+ - Identity, request, session, and cookie dependencies are recorded on the graph
77
+ for replay and sharing, but they are not indexed as lifecycle invalidation
78
+ lookup rows.
79
+ - Request capture, subscription registration, and subscription shape compilation
80
+ are separate runtime boundaries. `Capture::Request` owns the rendered response
81
+ and recorder, `Subscriptions::Registrar` owns identity/registration, and
82
+ `Subscriptions::ShapeCache` owns reusable identity-free subscription shapes.
83
+ - Request capture now reports server-side phases for pending delivery, action
84
+ capture, response body handling, request signature, registration, client
85
+ marker injection, and post-action delivery.
86
+ - Subscription-shape identity is a DAG/runtime concept. `DAG::SubscriptionShape`
87
+ owns the reusable structural signature, and `Runtime::Recorder` traces the
88
+ shape terms as frames/dependencies/containment are recorded. The trace stores
89
+ seen term keys rather than full component snapshots, and dependency reads are
90
+ queued by owner before being flushed into the DAG. Normal capture hits use an
91
+ order-sensitive rolling trace digest; this can create false cache misses if
92
+ record order differs, but it cannot create unsafe sharing.
93
+ - Repeated identity-free anonymous pages share compiled subscription shape and
94
+ reverse-index entry templates. The initial HTML response is not shared.
95
+
96
+ ## Current Measured State
97
+
98
+ Latest identity-free shared report:
99
+
100
+ - Report: `benchmark/results/identity-free-feed-compare-20260521113822.md`
101
+ - Shape cache: `199` hits, `1` miss, `0` bypasses.
102
+ - Upkeep setup p95: `58ms`; Turbo setup p95: `38.05ms`.
103
+ - Upkeep page render p95: `47ms`; Turbo page render p95: `24.05ms`.
104
+ - Upkeep WebSocket connect p95: `9ms`; Turbo WebSocket connect p95: `11ms`.
105
+ - Upkeep subscribe call p95: `6ms`; Turbo subscribe call p95: `2ms`.
106
+ - Upkeep subscribe ack p95: `5ms`; Turbo subscribe ack p95: `1.05ms`.
107
+ - Upkeep write POST p95: `58.90ms`; Turbo write POST p95: `58.69ms`.
108
+ - Upkeep update-settled p95: `191ms`; Turbo update-settled p95: `342.10ms`.
109
+ - Upkeep setup page server p95: `34.28ms`; Turbo setup page server p95:
110
+ `10.12ms`.
111
+ - Upkeep write request server p95: `15.74ms`; Turbo write request server p95:
112
+ `34.74ms`.
113
+ - Upkeep `GET /feed` request-capture total p95: `33.081ms`; action p95:
114
+ `14.896ms`; view p95: `15.456ms`; template p95: `11.104ms`; collection
115
+ render p95: `4.362ms`; SQL p95: `0.191ms`; registration p95: `3.814ms`.
116
+ - Upkeep recorder profile inside `GET /feed`: dependency p95 `3.916ms`, frame
117
+ p95 `1.364ms`, shape trace p95 `3.588ms`.
118
+ - Upkeep shape miss total: `4.324ms`; shape hit key p95: `0.031ms`; shape hit
119
+ total p95: `0.049ms`.
120
+ - Upkeep server-side subscribe total p95: `1.884ms`.
121
+
122
+ Latest identity-free single-subscriber report:
123
+
124
+ - Report: `benchmark/results/identity-free-feed-compare-20260521114026.md`
125
+ - Shape cache: `1` miss, `0` hits, `0` bypasses.
126
+ - Upkeep setup p95: `140ms`; Turbo setup p95: `104ms`.
127
+ - Upkeep page render p95: `91ms`; Turbo page render p95: `73ms`.
128
+ - Upkeep WebSocket connect p95: `38ms`; Turbo WebSocket connect p95: `17ms`.
129
+ - Upkeep subscribe call p95: `10ms`; Turbo subscribe call p95: `14ms`.
130
+ - Upkeep subscribe ack p95: `10ms`; Turbo subscribe ack p95: `13ms`.
131
+ - Upkeep write POST p95: `38.85ms`; Turbo write POST p95: `66.37ms`.
132
+ - Upkeep update-settled p95: `90ms`; Turbo update-settled p95: `96ms`.
133
+ - Upkeep setup page server p95: `83.88ms`; Turbo setup page server p95:
134
+ `66.02ms`.
135
+ - Upkeep write request server p95: `23.78ms`; Turbo write request server p95:
136
+ `32.72ms`.
137
+ - Upkeep `GET /feed` request-capture total p95: `82.921ms`; action p95:
138
+ `70.594ms`; view p95: `55.578ms`; template p95: `42.352ms`; collection
139
+ render p95: `13.226ms`; SQL p95: `1.577ms`; registration p95: `12.129ms`.
140
+ - Upkeep recorder profile inside `GET /feed`: dependency p95 `5.783ms`, frame
141
+ p95 `2.418ms`, shape trace p95 `4.264ms`.
142
+ - Upkeep shape miss total: `4.221ms`; server-side subscribe total:
143
+ `2.085ms`.
144
+ - The single-subscriber run is intentionally tracked as fixed-cost evidence,
145
+ but the one-sample p95 is noisy. Use it to find cost centers, not to claim a
146
+ stable product-level percentile.
147
+
148
+ Next cost-model actions:
149
+
150
+ - Continue structural reductions in `GET /feed` capture/render work. The latest
151
+ pass cut shared recorder shape trace from `8.107ms` to `3.588ms`, frame work
152
+ from `3.521ms` to `1.364ms`, and collection render capture from `9.356ms` to
153
+ `4.362ms`. The remaining shared setup gap is now mostly Action View template
154
+ capture plus page request overhead, not SQL, shape hits, response-body
155
+ handling, or subscribe activation.
156
+ - Keep request-capture profiling gated by listeners so production capture does
157
+ not pay profiler clock overhead. Benchmark data should use these fields for
158
+ diagnosis, not as always-on runtime work.
159
+ - Preserve implicit anonymous sharing. Developers should not declare a special
160
+ anonymous mode; the runtime should prove the absence of identity dependencies,
161
+ record why it refused sharing when identity is observed, and keep identity
162
+ diagnostics separate from benchmark-only telemetry.
163
+ - Use client/server timing correlation to separate real Rails work from k6 or
164
+ network scheduling delay. The current shared setup gap is mainly setup page
165
+ server time: Upkeep `34.28ms` versus Turbo `10.12ms`.
166
+ - Keep write POST phase telemetry, because shared update economics are strong
167
+ but write latency should stay flat as fanout packing changes.
168
+ - Run 500/1000-subscriber shared reports to validate fanout packing after the
169
+ 200-subscriber one-render path is stable.
170
+ - Keep refused-boundary and live-deoptimization reporting separate. Runtime
171
+ refusal and delivery deopt telemetry exist; benchmark aggregation and
172
+ source-level suggestions still need to be made complete.
173
+ - Keep shared-response HTML reuse out of the core path; share subscription
174
+ structure and update renders, not initial response bytes.
175
+
176
+ ## Phase 1: Normalize Change Events
177
+
178
+ Goal: make every Active Record write produce a lifecycle event that can support
179
+ precise invalidation.
180
+
181
+ Work:
182
+
183
+ - Emit explicit `create`, `update`, and `destroy` event types.
184
+ - Include `old_values`, `new_values`, and per-attribute change pairs.
185
+ - Preserve `changed_attributes` for existing planner compatibility.
186
+ - Keep bulk `update_all` and `delete_all` events explicit and conservative.
187
+ - Add tests for create, update, destroy, bulk update, and bulk delete event
188
+ shape.
189
+
190
+ Exit criteria:
191
+
192
+ - Destroy events can select collection dependencies.
193
+ - Future predicate matching can evaluate old and new row values without
194
+ reaching back into deleted records.
195
+ - Existing invalidation tests continue to pass.
196
+
197
+ ## Phase 2: Predicate-Aware Collection Invalidation
198
+
199
+ Goal: reduce over-selection for collection dependencies.
200
+
201
+ Status: structural predicate metadata has landed, opaque collection predicates
202
+ are refused instead of broadening, and reverse-index keys now use proven
203
+ collection table+column coverage instead of table-only collection lookup.
204
+
205
+ Work:
206
+
207
+ - Persist normalized predicate metadata from structural Arel analysis.
208
+ - Index exact equality predicates where values are structurally visible.
209
+ - Match update events against both old and new values so moved rows invalidate
210
+ source and destination collections.
211
+ - Keep collection reverse-index lookup keyed by proven table+column pairs.
212
+
213
+ Exit criteria:
214
+
215
+ - Updating an unrelated row in the same table no longer selects unrelated
216
+ equality-filtered collections.
217
+ - Moving a row between filter buckets selects both affected render sites.
218
+ - Opaque predicates refuse the reactive boundary instead of registering broad
219
+ invalidation.
220
+
221
+ ## Phase 3: Provable Stream Operations
222
+
223
+ Goal: emit cheaper operations only when the runtime can prove them.
224
+
225
+ Status: create, destroy, and stable member-update operations have landed.
226
+ Ordering and membership-changing updates still intentionally use render-site
227
+ replacement until a separate move/removal proof exists.
228
+
229
+ Work:
230
+
231
+ - Keep `append` gated by relation shape and replay proof.
232
+ - Add `prepend` for creates when ordering proves the new row belongs first.
233
+ - Add `remove` for destroys when the prior rendered member target is known.
234
+ - Add member `replace` for updates when the row was already rendered, still
235
+ belongs to the relation after the write, and replay proves the prior rendered
236
+ member order is unchanged.
237
+ - Fall back to render-site replacement whenever proof conditions fail.
238
+
239
+ Exit criteria:
240
+
241
+ - Operation planning is cheaper than full render-site replacement for common
242
+ create/destroy/member-update paths.
243
+ - Tests cover success and fallback cases for each operation.
244
+ - Member updates that change relation membership or ordering use render-site
245
+ replacement until a cheaper move operation has its own proof.
246
+
247
+ ## Phase 4: Subscription Lifecycle And Backpressure
248
+
249
+ Goal: bound retained work as clients navigate, disconnect, and churn.
250
+
251
+ Status: production storage now fails fast to the ActiveRecord store. Memory
252
+ storage is explicit development/test configuration only. ActiveRecord storage
253
+ has been split into an active registry, persistent reverse index, durable
254
+ subscription persistence, layered lookup, JSON snapshot codec, and async durable
255
+ writer. The hot registration path now indexes live subscriptions in-process and
256
+ enqueues durable writes without issuing SQL in the request thread. Channel
257
+ unsubscribe now unregisters live subscriptions, cancels queued durable writes,
258
+ removes persisted rows for inflight work, and deletes reverse-index entries
259
+ incrementally instead of rebuilding the index.
260
+
261
+ Measured gate after the cleanup:
262
+
263
+ - `benchmark/store_perf.rb`: ActiveRecord live registration is 0.047ms/op with
264
+ zero SQL, durable drain is 534ms for 1,000 subscriptions, and cold fetch of
265
+ 100 subscriptions is 17.9ms.
266
+ - `matrix/cold_connect_churn_chat` upkeep-only gate
267
+ (`20260514231017`): setup p95 is 1,531.8ms, login p95 is 819.3ms, page p95
268
+ is 761.0ms, WebSocket connect p95 is 758.8ms, subscribe ack p95 is 7ms, and
269
+ subscription registration p95 is 0.17ms.
270
+ - Typed reverse-index rows (`20260514231954`) keep setup p95 in the same range
271
+ at 1,511ms while lowering real persistence batch p95 to 546.6ms.
272
+ - Lifecycle cancellation (`20260514232743`) lowers cold churn setup p95 to
273
+ 1,097.75ms, leaves subscription and reverse-index table counts at zero after
274
+ disconnect, and keeps real persistence batch p95 at 137.6ms.
275
+
276
+ Work:
277
+
278
+ - Keep subscription storage, dispatching, and ActionCable broadcast bus
279
+ configuration as separate contracts.
280
+ - Keep last-seen timestamps as explicit maintenance writes, not subscribe-time
281
+ writes.
282
+ - Add expiry pruning for stale subscription and reverse-index rows.
283
+ - Deduplicate replacement subscriptions for the same subscriber and page where
284
+ the old graph is no longer useful.
285
+ - Keep per-delivery retry and queue bounds visible in metrics.
286
+ - Move durable write work out of the Puma process or behind an app queue adapter
287
+ before claiming high-churn multi-worker support.
288
+
289
+ Exit criteria:
290
+
291
+ - Reverse-index size does not grow unbounded under navigation churn.
292
+ - Channel disconnect cleanup is covered by unit tests and the cold churn gate.
293
+ - Expiry can be run safely from app jobs or maintenance tasks.
294
+
295
+ ## Phase 5: Deoptimization Diagnostics
296
+
297
+ Goal: keep the deoptimization surface small and actionable without mixing
298
+ benchmarking into the runtime path.
299
+
300
+ Status: collection operation fallbacks report deoptimization reasons in planner
301
+ notifications and Turbo Stream reports. Opaque Active Record collection
302
+ boundaries now raise in development/test and warn/refuse subscription
303
+ registration when configured for production-style warnings. Request-capture
304
+ telemetry reports refusal/deoptimization context, and the README now documents
305
+ what is not capturable, why, and the developer experience for each class.
306
+ Refused-boundary benchmark aggregation, source locations, and complete
307
+ refactor suggestions are still pending.
308
+
309
+ Work:
310
+
311
+ - Keep successful cheap operations free of diagnostic noise.
312
+ - Report why a create/update/destroy deoptimized to render-site replacement.
313
+ - Keep only "live deopts" where correctness is already proven and Upkeep merely
314
+ lost a cheaper operation.
315
+ - Refuse boundaries when dependency, identity, replay, or DOM target proof is
316
+ missing and no safe enclosing target exists.
317
+ - Remove predicate/table broad fallback from collection subscription capture;
318
+ keep table coverage only for write-event description where no subscription is
319
+ registered from it.
320
+ - Include a stable reason and a concrete refactor suggestion with every refused
321
+ boundary.
322
+ - Keep transport retry/backpressure reporting in the lifecycle lane, not the
323
+ invalidation deoptimization surface.
324
+
325
+ Exit criteria:
326
+
327
+ - A report can distinguish "optimized operation proved" from "deoptimized to
328
+ replacement because proof failed."
329
+ - A report can distinguish "live deopt" from "refused boundary."
330
+ - Deoptimization names are stable enough for app logs and benchmark summaries.
331
+
332
+ ## Phase 6: Benchmarks And Telemetry
333
+
334
+ Goal: make the cost model inspectable in production and benchmarks.
335
+
336
+ Status: planner counts, payload bytes, envelope counts, render counts, render
337
+ duration, render-group attribution, shape-cache timing, server-side subscribe
338
+ timing, and request-capture phase timing have landed in the identity-free feed
339
+ reports. Reverse-index lookup timing, mutation write phase timing, client/server
340
+ setup correlation, broadcast timing, and broader benchmark summaries are still
341
+ pending.
342
+
343
+ Work:
344
+
345
+ - Instrument reverse-index lookup time and candidate count.
346
+ - Instrument mutation write phases: database write, invalidation planning,
347
+ stream build, ActionCable enqueue, and response return.
348
+ - Correlate client setup phases with server timings: page response, WebSocket
349
+ open, cable confirmation, and subscription confirmation.
350
+ - Instrument broadcast time and end-to-end dispatch time.
351
+ - Add a multi-worker benchmark gate that uses the ActiveRecord subscription
352
+ store, a shared ActionCable adapter, and enough writer VUs to exercise
353
+ delivery across workers.
354
+ - Add benchmark summaries that compare Upkeep against Turbo refresh and against
355
+ hand-written Turbo stream operations separately.
356
+
357
+ Exit criteria:
358
+
359
+ - A benchmark run can say where time went without reading logs.
360
+ - Upkeep claims are split by baseline: refresh replacement, manual stream
361
+ replacement, and identity-partitioned rendering.
362
+
363
+ ## Large-Scale Direction: Proven Incremental Rendering
364
+
365
+ Goal: make Upkeep a deterministic incremental rendering system, not an
366
+ automatic Turbo wrapper with heuristic fallbacks.
367
+
368
+ The large-scale rule is: prove, compile, or refuse/deopt visibly. Production
369
+ behavior should not depend on "probably safe" invalidation, hidden broad
370
+ fallback, or local-only state that can miss another worker's subscriptions.
371
+
372
+ The parallel Herb roadmap is the template-structure track for this direction:
373
+ Herb should provide source-derived manifests, stable frame/render-site
374
+ addresses, manifest fingerprints, diff classification, and developer guidance.
375
+ Rails runtime observation still owns the data and identity surfaces a subscriber
376
+ actually read.
377
+
378
+ ### Strict Mode
379
+
380
+ Strict mode is the production posture for large installations.
381
+
382
+ - Keep live deoptimizations only when Upkeep has already proven the write
383
+ affects the subscriber and can safely re-render a known enclosing target.
384
+ - Refuse reactive boundaries when table sources, predicates, identity inputs,
385
+ replay inputs, or DOM targets cannot be proven.
386
+ - Raise refused boundaries in development/test so apps learn about unsupported
387
+ shapes before production.
388
+ - Warn and refuse registration in production for unsafe boundaries. Production
389
+ warning must not silently widen into broad reactivity.
390
+ - Treat manifest/runtime mismatches as named refused-boundary or live-deopt
391
+ reasons such as missing manifest, stale fingerprint, helper-hidden
392
+ collection, multi-root partial, or missing render site.
393
+ - Keep the development/debug path available, but make production claims against
394
+ strict behavior only.
395
+
396
+ Exit criteria:
397
+
398
+ - Development/test raises for unsafe reactive boundaries.
399
+ - Production warns and marks unsafe boundaries non-reactive.
400
+ - Benchmark reports separate strict successful proofs from deoptimized or
401
+ non-reactive frames.
402
+
403
+ ### Deoptimization Policy
404
+
405
+ Use two buckets:
406
+
407
+ - Live deopt: correctness is proven, but a cheaper Turbo Stream operation could
408
+ not be proven.
409
+ - Refused boundary: correctness is not proven, so Upkeep must not register the
410
+ boundary implicitly.
411
+
412
+ Live deopt examples:
413
+
414
+ ```ruby
415
+ Card.where(board_id: board.id).order(:position)
416
+ ```
417
+
418
+ If a create cannot be proven as append or prepend, replace the render site and
419
+ report `collection_create_position_unproven`.
420
+
421
+ ```ruby
422
+ Card.where(board_id: board.id).order(:title)
423
+ ```
424
+
425
+ If an update may move the member, replace the render site and report
426
+ `collection_member_replace_unproven`.
427
+
428
+ ```ruby
429
+ Card.transaction do
430
+ first.update!(title: "A")
431
+ second.update!(title: "B")
432
+ end
433
+ ```
434
+
435
+ If multiple changes cannot be reduced to deterministic member operations,
436
+ replace the render site and report `collection_multi_change_fallback`.
437
+
438
+ Refused-boundary examples and guidance:
439
+
440
+ ```ruby
441
+ Card.where("status = ?", "open")
442
+ ```
443
+
444
+ Reason: opaque predicate. Refactor to structural Active Record or Arel:
445
+
446
+ ```ruby
447
+ Card.where(status: "open")
448
+ ```
449
+
450
+ ```ruby
451
+ Card.joins("INNER JOIN authors ON authors.id = cards.author_id")
452
+ ```
453
+
454
+ Reason: opaque table source. Refactor to an association or structural Arel
455
+ join:
456
+
457
+ ```ruby
458
+ Card.joins(:author)
459
+ ```
460
+
461
+ ```erb
462
+ <%= card_list(@cards) %>
463
+ ```
464
+
465
+ Reason: helper-hidden collection. Refactor to Rails collection rendering or a
466
+ future explicit supported boundary:
467
+
468
+ ```erb
469
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
470
+ ```
471
+
472
+ ```erb
473
+ <li id="<%= dom_id(card) %>"><%= card.title %></li>
474
+ <li>extra</li>
475
+ ```
476
+
477
+ Reason: multi-root member partial. Refactor member partials to a single root
478
+ when member-level operations are expected. If the parent render site is known,
479
+ Upkeep may keep the boundary live by replacing the render site; otherwise it
480
+ refuses the boundary.
481
+
482
+ Exit criteria:
483
+
484
+ - Every refused boundary includes reason, source location where available, and
485
+ at least one refactor suggestion.
486
+ - Production reports count refused boundaries separately.
487
+ - Benchmarks exclude refused boundaries from strict performance claims.
488
+
489
+ ### Herb Manifest Structure Layer
490
+
491
+ Large-scale Upkeep needs one template-structure authority. The Herb worktree is
492
+ moving in that direction: a `TemplateManifest` describes parse status, root
493
+ shape, render sites, helper-lowered HTML, frontend tag targets, and a stable
494
+ fingerprint.
495
+
496
+ - Use Herb as the compile-time structural layer for Rails views.
497
+ - Keep Rails runtime observation as the source of data reads, identity reads,
498
+ and actual subscriber ownership.
499
+ - Apply source instrumentation before ActionView compiles supported ERB
500
+ templates, then retire post-render marker inference when equivalence is
501
+ proven.
502
+ - Attach manifest path and fingerprint provenance to page, fragment, and
503
+ render-site frames.
504
+ - Check selected targets against manifest entries and report concrete
505
+ deoptimization reasons when runtime shape does not match source topology.
506
+ - Use `Herb.diff`-style classification to distinguish content-only manifest
507
+ refreshes, topology rebuilds, and unsafe full rebuilds.
508
+ - Turn manifest coverage and fallback reasons into developer guidance so apps
509
+ can improve strict-mode eligibility intentionally.
510
+
511
+ Exit criteria:
512
+
513
+ - Herb manifests discover every render site and single-root fragment used by
514
+ maintained benchmark apps.
515
+ - Runtime frames and selected targets either match manifest provenance or carry
516
+ stable deoptimization reasons.
517
+ - ActionView integration produces equivalent HTML except for expected Upkeep
518
+ markers.
519
+ - Herb becomes the single template structure path after the production gates
520
+ pass; duplicate non-Herb marker/replay paths are removed.
521
+
522
+ ### Typed Subscription Indexes
523
+
524
+ The ActiveRecord store should keep durable state inspectable before it moves all
525
+ lookup access onto fully typed columns.
526
+
527
+ Status: the in-memory and persisted reverse-index key set now avoids table-only
528
+ collection candidates, avoids any-id attribute rows for record-specific
529
+ dependencies, and no longer indexes identity/request dependencies that
530
+ lifecycle events cannot select. Persisted recorder snapshots use versioned
531
+ JSON-compatible payloads instead of opaque binary Ruby object snapshots, so rows
532
+ can be inspected without object deserialization. Durable subscription rows now
533
+ keep replay, frame, and identity graph data; lifecycle dependency payloads live
534
+ in typed reverse-index rows. Reverse-index rows group owner ids for the same
535
+ subscription/lookup/dependency tuple.
536
+
537
+ Measured typed-row benefit:
538
+
539
+ - `benchmark/store_perf.rb`: cold persistent attribute lookup dropped from
540
+ 38.8ms/op to 23.2ms/op, and cold collection lookup dropped from 38.7ms/op to
541
+ 22.7ms/op. Durable drain is effectively flat in the synthetic store benchmark
542
+ because row count is unchanged.
543
+ - `matrix/cold_connect_churn_chat` upkeep-only gate (`20260514231954`): real
544
+ persistence batch p95 is 546.6ms; setup p95 remains 1,511ms, so this is a
545
+ stale/multi-worker lookup optimization rather than a cold-admission fix.
546
+
547
+ Work:
548
+
549
+ - Add database indexes that match the typed planner access pattern instead of
550
+ querying only by lookup digest.
551
+ - Keep JSON snapshots only for replay/debug payloads and collection metadata
552
+ that cannot be represented structurally yet.
553
+ - Extend typed rows for future dependency keys: lifecycle, target id,
554
+ subscriber/identity digest, and target kind.
555
+
556
+ Exit criteria:
557
+
558
+ - A row-level write can query the reverse index through typed indexed columns.
559
+ - Persisted lookup does not need to unmarshal lookup-key or lifecycle dependency
560
+ snapshots to prove a match.
561
+
562
+ ### Store Versioning And Worker Coherence
563
+
564
+ Multi-worker correctness needs a shared coherence signal.
565
+
566
+ - Add a monotonic subscription-store version that advances when subscriptions
567
+ or index rows are added, pruned, or materially changed.
568
+ - Let each worker track the version covered by its active in-process registry.
569
+ - Use the active registry without a warm-path `COUNT(*)` only when the local
570
+ version is current.
571
+ - Fetch deltas or fall back to persisted lookup when the worker is stale.
572
+
573
+ Exit criteria:
574
+
575
+ - Warm same-worker planning avoids repeated count queries.
576
+ - Cross-worker planning still sees subscriptions registered by another worker.
577
+ - Benchmarks can report active, persistent, and stale-worker lookup modes.
578
+
579
+ ### Compiled Subscription Plans
580
+
581
+ Subscription capture should produce compact execution artifacts.
582
+
583
+ - Compile the render graph into dependency rows, replay recipes, target
584
+ addresses, identity signatures, and operation-proof inputs at registration
585
+ time.
586
+ - Reference source-derived manifest entries from replay recipes where Herb
587
+ proves the frame or render-site address.
588
+ - Avoid repeatedly interpreting large recorder snapshots during planning.
589
+ - Store graph snapshots as debug/replay evidence, not the primary planning
590
+ data structure.
591
+ - Keep compiled artifacts deterministic and rebuildable from captured evidence.
592
+
593
+ Exit criteria:
594
+
595
+ - Planning uses compiled dependency/target artifacts for common paths.
596
+ - Recorder graph hydration is not required for every invalidation lookup.
597
+
598
+ ### Exact Collection Delta Proofs
599
+
600
+ Large scale depends on narrow operations, not render-site replacement.
601
+
602
+ - Extend collection proofs beyond append, prepend, remove, and stable member
603
+ replace.
604
+ - Prove membership moves using old/new values, relation predicates, and order
605
+ expressions.
606
+ - Emit deterministic Turbo Stream operations when relation shape proves the
607
+ exact delta.
608
+ - Refuse or deopt visibly when limit, offset, grouping, custom SQL, or
609
+ unobservable order semantics make the delta unprovable.
610
+
611
+ Exit criteria:
612
+
613
+ - Common create/update/destroy paths on ordered filtered collections avoid full
614
+ render-site replacement.
615
+ - Move and membership-change proofs have success and deoptimization tests.
616
+
617
+ ### Content-Addressed Render Dedup
618
+
619
+ Fanout should scale with distinct output, not raw subscriber count.
620
+
621
+ Status: delivery groups planned targets by action, target, identity signature,
622
+ sharing signature, replay recipe signature, and deoptimization reason before
623
+ rendering. Public fragments with equivalent replay inputs now render once for
624
+ multiple subscribers; shared render-site streams already use the same grouping
625
+ boundary.
626
+
627
+ - Key rendered payloads by target recipe, identity signature, data version, and
628
+ operation kind.
629
+ - Share rendered Turbo Stream payloads across subscribers with equivalent
630
+ target, identity, and data inputs.
631
+ - Cache only proof-valid render outputs; do not share when identity or ambient
632
+ dependencies differ.
633
+ - Report subscriber-renders saved by content-addressed dedup in benchmark
634
+ output.
635
+
636
+ Exit criteria:
637
+
638
+ - A write to a shared/public fragment renders once for many subscribers.
639
+ - Identity-specific output produces separate payloads only when visible bytes
640
+ or identity dependencies differ.
641
+
642
+ ### Partitioned Indexes
643
+
644
+ The reverse index should not behave like one global table at large scale.
645
+
646
+ - Partition by tenant, stream namespace, app-defined shard key, or canonical
647
+ subscriber scope where Rails can prove the boundary.
648
+ - Carry partition keys through subscription capture, dependency rows, and
649
+ lifecycle events.
650
+ - Reject or deopt boundaries where the partition key is not structurally
651
+ observable.
652
+
653
+ Exit criteria:
654
+
655
+ - A tenant-scoped write queries a bounded partition.
656
+ - Benchmarks can compare global lookup versus partitioned lookup cost.
657
+
658
+ ### Deterministic Coalescing
659
+
660
+ Batching should be semantics-preserving, not timing-based guesswork.
661
+
662
+ - Coalesce work by exact target key, data version, operation kind, and
663
+ supersession rules.
664
+ - Drop earlier work only when a later operation deterministically supersedes it
665
+ for the same target.
666
+ - Preserve ordering when operations are not provably commutative or
667
+ superseding.
668
+
669
+ Exit criteria:
670
+
671
+ - Bursty writes to the same target produce bounded delivery work.
672
+ - Coalescing reports why work was merged, superseded, or preserved.
673
+
674
+ ### ActiveJob Dispatcher
675
+
676
+ Queueing should isolate latency after correctness is already deterministic.
677
+
678
+ - Add a dispatcher boundary that can enqueue delivery through ActiveJob.
679
+ - Let host apps use Solid Queue, Redis-backed queues, or another configured
680
+ queue adapter without changing the subscription store.
681
+ - Keep subscription store, dispatcher, and ActionCable broadcast bus separate.
682
+ - Build fork/process lifecycle hooks before claiming multi-worker support.
683
+
684
+ Exit criteria:
685
+
686
+ - The request thread can hand off delivery work to ActiveJob.
687
+ - Queue retry/backpressure is reported separately from invalidation proof and
688
+ render cost.
689
+
690
+ ## Benchmark Positioning
691
+
692
+ The benchmark matrix should keep these baselines separate:
693
+
694
+ - Turbo refresh: Upkeep should aim to reduce page GET fanout and full-page
695
+ rerender cost.
696
+ - Turbo append/prepend/remove: Turbo should be expected to win when the stream
697
+ operation is manually known.
698
+ - Identity-specific output: compare correctness and per-identity render cost,
699
+ not only payload latency.
700
+
701
+ The first production milestone is a warm-subscription filtered collection page
702
+ where Upkeep beats Turbo refresh with explicit change events, predicate-aware
703
+ invalidation, stale subscription cleanup, and cost attribution.