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,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.
|