upkeep-rails 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +308 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +306 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/architecture/subscription-store-contract.md +66 -0
  9. data/docs/cost-model-roadmap.md +704 -0
  10. data/docs/guides/getting-started.md +462 -0
  11. data/docs/handoff-2026-05-15.md +230 -0
  12. data/docs/production_roadmap.md +372 -0
  13. data/docs/shared-warm-scale-roadmap.md +214 -0
  14. data/docs/single-subscriber-cold-roadmap.md +192 -0
  15. data/docs/stress-test-findings.md +310 -0
  16. data/docs/testing.md +143 -0
  17. data/lib/generators/upkeep/install/install_generator.rb +127 -0
  18. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
  19. data/lib/generators/upkeep/install/templates/subscription.js +99 -0
  20. data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
  21. data/lib/upkeep/active_record_query.rb +294 -0
  22. data/lib/upkeep/capture/request.rb +150 -0
  23. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  24. data/lib/upkeep/dag.rb +370 -0
  25. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  26. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  27. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  28. data/lib/upkeep/delivery/transport.rb +194 -0
  29. data/lib/upkeep/delivery/turbo_streams.rb +302 -0
  30. data/lib/upkeep/delivery.rb +7 -0
  31. data/lib/upkeep/dependencies.rb +518 -0
  32. data/lib/upkeep/herb/developer_report.rb +135 -0
  33. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  34. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  35. data/lib/upkeep/herb/source_instrumenter.rb +149 -0
  36. data/lib/upkeep/herb/template_manifest.rb +514 -0
  37. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  39. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  40. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  41. data/lib/upkeep/invalidation/planner.rb +360 -0
  42. data/lib/upkeep/invalidation.rb +7 -0
  43. data/lib/upkeep/rails/action_view_capture.rb +821 -0
  44. data/lib/upkeep/rails/activation_token.rb +55 -0
  45. data/lib/upkeep/rails/cable/channel.rb +143 -0
  46. data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
  47. data/lib/upkeep/rails/cable.rb +4 -0
  48. data/lib/upkeep/rails/client_subscription.rb +45 -0
  49. data/lib/upkeep/rails/configuration.rb +245 -0
  50. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  51. data/lib/upkeep/rails/delivery_job.rb +29 -0
  52. data/lib/upkeep/rails/install.rb +28 -0
  53. data/lib/upkeep/rails/railtie.rb +50 -0
  54. data/lib/upkeep/rails/replay.rb +176 -0
  55. data/lib/upkeep/rails/testing.rb +97 -0
  56. data/lib/upkeep/rails.rb +349 -0
  57. data/lib/upkeep/replay.rb +408 -0
  58. data/lib/upkeep/runtime.rb +1100 -0
  59. data/lib/upkeep/shared_streams.rb +72 -0
  60. data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
  61. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
  62. data/lib/upkeep/subscriptions/active_registry.rb +87 -0
  63. data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
  64. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  65. data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
  66. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
  67. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  68. data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
  69. data/lib/upkeep/subscriptions/shape.rb +116 -0
  70. data/lib/upkeep/subscriptions/store.rb +171 -0
  71. data/lib/upkeep/subscriptions.rb +7 -0
  72. data/lib/upkeep/targeting.rb +135 -0
  73. data/lib/upkeep/version.rb +5 -0
  74. data/lib/upkeep-rails.rb +3 -0
  75. data/lib/upkeep.rb +14 -0
  76. data/upkeep-rails.gemspec +54 -0
  77. metadata +320 -0
@@ -0,0 +1,462 @@
1
+ # Getting Started
2
+
3
+ This guide shows the Rails app path: add the package, run the installer,
4
+ configure ActionCable identity, render normal Rails views, and let Active
5
+ Record commits deliver targeted Turbo Stream payloads.
6
+
7
+ ## Add The Package
8
+
9
+ Install the published gem:
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "upkeep-rails"
14
+ ```
15
+
16
+ The Railtie installs hooks when Rails loads Active Record, Action Controller,
17
+ and Action View. The runtime is enabled by default. Production uses the
18
+ ActiveRecord subscription store by default and fails fast when the Upkeep
19
+ subscription tables are missing. The runtime can be disabled per environment:
20
+
21
+ ```ruby
22
+ # config/environments/test.rb
23
+ config.upkeep.enabled = false
24
+ ```
25
+
26
+ Use `config.upkeep.*` from Rails application or environment config. Use
27
+ `Upkeep::Rails.configure` from `config/initializers/upkeep.rb`.
28
+
29
+ Opaque reactive boundaries are refused instead of being widened into broad
30
+ invalidation. Development/test raises by default so unsupported query shapes are
31
+ found early; production warns and skips subscription registration by default:
32
+
33
+ ```ruby
34
+ # config/environments/development.rb
35
+ config.upkeep.refused_boundary_behavior = :raise # or :warn
36
+ ```
37
+
38
+ ## Run The Installer
39
+
40
+ ```sh
41
+ bin/rails generate upkeep:install
42
+ bin/rails db:migrate
43
+ ```
44
+
45
+ The installer creates:
46
+
47
+ - `config/initializers/upkeep.rb`
48
+ - `db/migrate/*_create_upkeep_subscriptions.rb`
49
+ - `app/javascript/upkeep/subscription.js`
50
+ - an import from `app/javascript/application.js`
51
+ - importmap pins for Turbo and ActionCable when `config/importmap.rb` exists
52
+ - an ActionCable mount in `config/routes.rb` when one is not present
53
+
54
+ The browser bootstrap is vendored into the app. After upgrading
55
+ `upkeep-rails`, rerun the installer or compare
56
+ `app/javascript/upkeep/subscription.js` with the gem template so the client
57
+ payload still matches the server channel.
58
+
59
+ ## Subscription Storage
60
+
61
+ The generated initializer keeps production on durable storage and uses memory
62
+ for ordinary app tests:
63
+
64
+ ```ruby
65
+ # config/initializers/upkeep.rb
66
+ Upkeep::Rails.configure do |config|
67
+ app_config = Rails.application.config.upkeep
68
+
69
+ config.enabled = app_config.fetch(:enabled, true)
70
+ config.subscription_store = app_config.fetch(:subscription_store, Rails.env.test? ? :memory : :active_record)
71
+ config.delivery_adapter = app_config.fetch(:delivery_adapter, Rails.env.production? ? :active_job : :async)
72
+ config.delivery_queue = app_config.fetch(:delivery_queue, :upkeep_realtime)
73
+ end
74
+ ```
75
+
76
+ The ActiveRecord store persists subscriptions and reverse-index entries so any
77
+ Puma worker can plan invalidations for subscriptions captured by another
78
+ worker. The memory store has the same public lifecycle but keeps all state in
79
+ the current process. It is the right default for request/system tests that only
80
+ need to prove marker registration, activation, planning, delivery, and rendered
81
+ bytes.
82
+
83
+ Use an ActiveRecord-backed test environment or CI job when you want to exercise
84
+ production-only storage behavior:
85
+
86
+ ```ruby
87
+ # config/environments/test.rb
88
+ config.upkeep.subscription_store = :active_record
89
+ ```
90
+
91
+ The benchmark app uses this migration shape:
92
+
93
+ ```ruby
94
+ create_table :upkeep_subscriptions, id: :string do |t|
95
+ t.string :subscriber_id, null: false
96
+ t.json :recorder_snapshot, null: false
97
+ t.json :metadata
98
+ t.string :subscription_shape_key
99
+ t.timestamps
100
+ end
101
+ add_index :upkeep_subscriptions, :subscriber_id
102
+ add_index :upkeep_subscriptions, :subscription_shape_key,
103
+ name: "idx_upkeep_subscriptions_on_shape_key"
104
+
105
+ create_table :upkeep_subscription_index_entries do |t|
106
+ t.string :subscription_id, null: false
107
+ t.string :lookup_key_digest, null: false
108
+ t.string :dependency_source, null: false
109
+ t.string :lookup_table, null: false
110
+ t.json :lookup_record_id_snapshot
111
+ t.string :lookup_attribute, null: false
112
+ t.string :dependency_table, null: false
113
+ t.string :dependency_predicate_digest
114
+ t.json :dependency_metadata_snapshot
115
+ t.json :owner_ids_snapshot, null: false
116
+ t.timestamps
117
+ end
118
+ add_index :upkeep_subscription_index_entries, :subscription_id
119
+ add_index :upkeep_subscription_index_entries, :lookup_key_digest
120
+ add_foreign_key :upkeep_subscription_index_entries,
121
+ :upkeep_subscriptions,
122
+ column: :subscription_id,
123
+ on_delete: :cascade
124
+
125
+ create_table :upkeep_subscription_shape_index_entries do |t|
126
+ t.string :subscription_shape_key, null: false
127
+ t.string :lookup_key_digest, null: false
128
+ t.string :dependency_source, null: false
129
+ t.string :lookup_table, null: false
130
+ t.json :lookup_record_id_snapshot
131
+ t.string :lookup_attribute, null: false
132
+ t.string :dependency_table, null: false
133
+ t.string :dependency_predicate_digest
134
+ t.json :dependency_metadata_snapshot
135
+ t.json :owner_ids_snapshot, null: false
136
+ t.timestamps
137
+ end
138
+ add_index :upkeep_subscription_shape_index_entries,
139
+ :subscription_shape_key,
140
+ name: "idx_upkeep_sub_shape_entries_on_shape_key"
141
+ add_index :upkeep_subscription_shape_index_entries,
142
+ :lookup_key_digest,
143
+ name: "idx_upkeep_sub_shape_entries_on_lookup_digest"
144
+ ```
145
+
146
+ ## Delivery Jobs And ActionCable
147
+
148
+ Upkeep uses Active Job for production delivery. A mutation request records
149
+ committed Active Record facts, enqueues `Upkeep::Rails::DeliveryJob`, and lets
150
+ that job plan, render, and broadcast the Turbo Stream payloads:
151
+
152
+ ```ruby
153
+ # Sidekiq
154
+ config.active_job.queue_adapter = :sidekiq
155
+
156
+ # or Solid Queue
157
+ config.active_job.queue_adapter = :solid_queue
158
+ ```
159
+
160
+ The job queue does not replace ActionCable. It only decides where Upkeep's
161
+ delivery job runs. The job still calls `ActionCable.server.broadcast`, so
162
+ multi-process apps need a shared ActionCable adapter:
163
+
164
+ ```yml
165
+ # Redis-backed cable
166
+ production:
167
+ adapter: redis
168
+ url: <%= ENV.fetch("REDIS_URL") %>
169
+ channel_prefix: my_app_production
170
+ ```
171
+
172
+ ```yml
173
+ # No Redis: database-backed cable
174
+ production:
175
+ adapter: solid_cable
176
+ connects_to:
177
+ database:
178
+ writing: cable
179
+ ```
180
+
181
+ Solid Queue plus Solid Cable is the no-Redis setup. Sidekiq normally brings
182
+ Redis for jobs; it can still broadcast through either Redis-backed ActionCable
183
+ or Solid Cable.
184
+
185
+ ## Configure Subscriber Identity
186
+
187
+ Mount ActionCable:
188
+
189
+ ```ruby
190
+ # config/routes.rb
191
+ mount ActionCable.server => "/cable"
192
+ ```
193
+
194
+ Declare each identity boundary that should partition live updates:
195
+
196
+ ```ruby
197
+ # config/initializers/upkeep.rb
198
+ Upkeep::Rails.configure do |config|
199
+ config.identify :viewer, current: ["Current", :user] do
200
+ subscribe { |connection| connection.current_user }
201
+ end
202
+ end
203
+ ```
204
+
205
+ Read the declaration in three parts:
206
+
207
+ | Part | Meaning |
208
+ | --- | --- |
209
+ | `:viewer` | The identity component name. Choose the role the value plays: `:viewer`, `:account`, `:tenant`, `:locale`, etc. |
210
+ | `current: ["Current", :user]` | The render-side source. This declaration applies when the page reads `Current.user`. |
211
+ | `subscribe { |connection| connection.current_user }` | The ActionCable-side proof. It must return the same logical user when the browser subscribes. |
212
+
213
+ Choose the source keyword from the API the render path actually reads:
214
+
215
+ | Render code reads | Use |
216
+ | --- | --- |
217
+ | `Current.user` | `current: ["Current", :user]` |
218
+ | Devise `current_user`, `user_signed_in?`, or `warden.user(:user)` | `warden: :user` |
219
+ | `session[:user_id]` | `session: :user_id` |
220
+ | `cookies[:account_id]` | `cookie: :account_id` |
221
+
222
+ If a Devise app copies the signed-in user into `Current.user`, declare the
223
+ source that the page actually reads. Declare both only when both sources are
224
+ genuine render inputs.
225
+
226
+ Expose the matching value on the ActionCable connection. Active Record records,
227
+ scalars, and GlobalID values are supported identity components:
228
+
229
+ ```ruby
230
+ module ApplicationCable
231
+ class Connection < ActionCable::Connection::Base
232
+ identified_by :current_user
233
+
234
+ def connect
235
+ self.current_user = User.find_by(id: request.session[:user_id])
236
+ end
237
+ end
238
+ end
239
+ ```
240
+
241
+ For Devise/Warden, declare the Warden scope and make the cable connection expose
242
+ the same user:
243
+
244
+ ```ruby
245
+ # config/initializers/upkeep.rb
246
+ Upkeep::Rails.configure do |config|
247
+ config.identify :viewer, warden: :user do
248
+ subscribe { |connection| connection.current_user }
249
+ end
250
+ end
251
+ ```
252
+
253
+ ```ruby
254
+ # app/channels/application_cable/connection.rb
255
+ module ApplicationCable
256
+ class Connection < ActionCable::Connection::Base
257
+ identified_by :current_user
258
+
259
+ def connect
260
+ self.current_user = env["warden"]&.user(:user)
261
+ end
262
+ end
263
+ end
264
+ ```
265
+
266
+ Use `warden: :admin` or another scope when the rendered Devise helper is scoped
267
+ to that role. Reject nil users in `connect` only if every cable subscription in
268
+ the app requires login; otherwise nil remains an absent identity for public
269
+ pages.
270
+
271
+ By default, `nil` means the declared boundary is absent. Logged-out pages can
272
+ therefore stay anonymous-public. If your app uses another sentinel, declare it
273
+ with `absent_if`.
274
+
275
+ The `subscribe` block receives an Upkeep connection context. Use
276
+ `connection.current_user`, `connection.session`, and `connection.cookies`; do
277
+ not reach through to ActionCable's raw request object.
278
+
279
+ Clients subscribe to `Upkeep::Rails::Cable::Channel` with the
280
+ `subscription_id` from the injected `<upkeep-subscription-source>` element. The
281
+ server channel validates the subscription id and streams from the canonical
282
+ subscriber stream plus any shared streams attached to that graph.
283
+
284
+ The generated browser bootstrap upgrades that body element into a Turbo stream
285
+ source with `Turbo.session.connectStreamSource`, subscribes through
286
+ `@rails/actioncable`, and lets Turbo process received stream payloads. The
287
+ source is `data-turbo-temporary` so Turbo does not cache stale subscription
288
+ handles.
289
+
290
+ For more than one Puma worker, configure ActionCable with a shared adapter
291
+ such as Redis or Solid Cable. The subscription store decides who should receive
292
+ work; ActionCable decides which worker owns each WebSocket connection.
293
+
294
+ ## Render Normal Rails Views
295
+
296
+ Controllers load normal Active Record models and relations:
297
+
298
+ ```ruby
299
+ class BoardsController < ApplicationController
300
+ def show
301
+ @board = Board.find(params[:id])
302
+ @cards = @board.cards.order(:position)
303
+ end
304
+ end
305
+ ```
306
+
307
+ Templates render normal ERB and partial collections:
308
+
309
+ ```erb
310
+ <main>
311
+ <h1><%= @board.name %></h1>
312
+
313
+ <ul id="cards">
314
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
315
+ </ul>
316
+ </main>
317
+ ```
318
+
319
+ Successful HTML GET responses are captured automatically. Upkeep records page,
320
+ render-site, and fragment frames from Rails renderer hooks, then injects a
321
+ subscription source into the response.
322
+
323
+ Polymorphic collection shorthand is supported when runtime rendering confirms
324
+ that the rendered object is a collection:
325
+
326
+ ```erb
327
+ <ul id="cards">
328
+ <%= render @cards %>
329
+ </ul>
330
+ ```
331
+
332
+ Rails tag-helper containers are supported too. Herb lowers `tag.*` and
333
+ `content_tag` blocks into template structure, and Upkeep writes its internal
334
+ markers through the helper call:
335
+
336
+ ```erb
337
+ <%= tag.ul id: "cards" do %>
338
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
339
+ <% end %>
340
+ ```
341
+
342
+ Upkeep trusts narrow source-derived targets only when Herb's strict parser
343
+ accepts the template. If strict parsing fails but non-strict parsing recovers,
344
+ the app still renders normally, Upkeep reports the strict diagnostics as
345
+ warnings, and broad page or fragment markers may still be added. Recovered
346
+ render sites are reported as candidates only; fix the strict warnings before
347
+ expecting narrow collection updates from that template.
348
+
349
+ Controller materialization is supported when the rendered value keeps a
350
+ structural relation proof:
351
+
352
+ ```ruby
353
+ def index
354
+ @cards = Card.where(status: "open").order(:position).to_a
355
+ end
356
+ ```
357
+
358
+ ```erb
359
+ <%= render partial: "cards/card", collection: @cards, as: :card %>
360
+ ```
361
+
362
+ Upkeep attaches the collection dependency to the rendered collection boundary,
363
+ not to every controller query. A materialized relation that is never rendered
364
+ as a collection is not a lifecycle dependency by itself.
365
+
366
+ Scalar relation output is tracked as a page-level query dependency:
367
+
368
+ ```ruby
369
+ @tag_names = Tag.where(active: true).pluck(:name)
370
+ ```
371
+
372
+ Simple plucked columns are live and can select a page replay when they change.
373
+ They are not collection dependencies, so they do not participate in
374
+ append/remove/prepend planning.
375
+
376
+ Session, cookie, and request reads are observed inputs:
377
+
378
+ ```ruby
379
+ @viewer = session[:viewer]
380
+ @tag_filter = cookies[:tag_filters]
381
+ @agent = request.user_agent
382
+ ```
383
+
384
+ Replay stores only observed values needed to rerun the page. Unread session
385
+ keys, cookies, and request headers are not copied into the replay payload.
386
+
387
+ ## Mutate Through Active Record
388
+
389
+ Write paths keep doing domain work:
390
+
391
+ ```ruby
392
+ class CardsController < ApplicationController
393
+ def update
394
+ card = Card.find(params[:id])
395
+ card.update!(card_params)
396
+ head :ok
397
+ end
398
+ end
399
+ ```
400
+
401
+ After commit, Upkeep records the changed table, id, and attributes. The planner
402
+ uses the reverse index to select affected subscribers and render targets.
403
+ Collection lookup entries are keyed by proven table and column pairs, so a write
404
+ to `cards.title` does not select a collection whose only proven dependency is
405
+ `cards.status`. Identity, request, session, and cookie reads are still recorded
406
+ on the graph for replay and sharing, but lifecycle writes do not index or select
407
+ them.
408
+
409
+ Bulk writes are observed through Active Record relations:
410
+
411
+ ```ruby
412
+ Card.where(board_id: board.id, status: "open").update_all(status: "done")
413
+ ```
414
+
415
+ For structurally visible relations, Upkeep derives the involved tables and
416
+ columns through Arel. Opaque collection relations are refused instead of being
417
+ registered as broad reactive dependencies; rewrite them with structural
418
+ Active Record or Arel predicates before relying on Upkeep updates.
419
+
420
+ Non-GET controller actions do not register subscriptions. They still capture
421
+ Active Record lifecycle changes and deliver to existing subscribers.
422
+
423
+ ## Verify The Integration
424
+
425
+ The benchmark app checks the integration path with `Upkeep::Rails::Testing`:
426
+
427
+ ```ruby
428
+ include Upkeep::Rails::Testing
429
+
430
+ get board_path(board)
431
+ assert_response :success
432
+ assert_upkeep_subscription_registered
433
+ activate_upkeep_subscription!
434
+ ```
435
+
436
+ For a streamed mutation, capture ActionCable broadcasts for the subscription's
437
+ stream names, perform the mutation, drain delivery, and assert the payload:
438
+
439
+ ```ruby
440
+ include ActionCable::TestHelper
441
+ include Upkeep::Rails::Testing
442
+
443
+ broadcasts = capture_upkeep_broadcasts do
444
+ patch board_card_path(board, card), params: { card: { title: "Updated" } }
445
+ drain_upkeep_delivery!
446
+ end
447
+
448
+ assert_includes broadcasts.join, "Updated"
449
+ ```
450
+
451
+ ## Current Boundaries
452
+
453
+ - HTML GET responses are the subscription capture path.
454
+ - Non-GET requests are mutation/delivery paths, not subscription capture paths.
455
+ - Non-HTML templates are outside the Herb-backed template planning surface.
456
+ - The app does not declare query dependencies, and subscriber identity is only
457
+ created from explicit `config.identify` declarations. If a render depends
458
+ on hidden process state outside the observed Rails surfaces, Upkeep cannot
459
+ prove subscriber ownership for that value.
460
+ - Opaque relation predicates, raw SQL joins/sources, and opaque pluck
461
+ expressions raise in development/test by default. In warning mode they refuse
462
+ the boundary and skip subscription registration instead of broadening.
@@ -0,0 +1,230 @@
1
+ # Upkeep Rails Handoff - 2026-05-15
2
+
3
+ This handoff captures the current state of the `lobsters-upkeep-benchmark`
4
+ branch after the subscription storage and cold-churn work.
5
+
6
+ ## Current Position
7
+
8
+ Upkeep is much healthier than when cold setup was taking tens of seconds, but
9
+ it is still behind Turbo on the cold connection churn gate.
10
+
11
+ Latest same-run comparison:
12
+
13
+ - Report:
14
+ `benchmark/results/matrix-compare-20260514233725.md`
15
+ - Workload:
16
+ `matrix/cold_connect_churn_chat`
17
+ - Shape:
18
+ 200 users, 1 Puma worker, 5 Puma threads, Upkeep on port 3000, Turbo on port
19
+ 3001.
20
+
21
+ | p95 metric | Upkeep | Turbo | Read |
22
+ | --- | ---: | ---: | --- |
23
+ | Setup total | 1229 ms | 408.55 ms | Upkeep is about 3.0x slower |
24
+ | Login HTTP | 456.9 ms | 140 ms | Upkeep is about 3.3x slower |
25
+ | Page request | 389.9 ms | 134.55 ms | Upkeep is about 2.9x slower |
26
+ | WebSocket connect | 361.5 ms | 138.06 ms | Upkeep is about 2.6x slower |
27
+ | Subscribe ack | 6 ms | 4 ms | Close |
28
+ | Subscription registration | 0.13 ms | 0.15 ms | Upkeep is slightly faster |
29
+
30
+ Interpretation:
31
+
32
+ - Subscription registration is not the current bottleneck.
33
+ - The remaining gap is cold admission: login, page render with recording,
34
+ WebSocket connect, and subscription ack under churn.
35
+ - Upkeep had one subscription timeout in the latest comparison run. Treat the
36
+ result as useful signal, but do not call the gate clean until the timeout is
37
+ explained or eliminated.
38
+
39
+ ## What Cold Admission Means
40
+
41
+ Cold admission is the time for a brand-new client to become a live subscriber
42
+ while the app is already running. In the benchmark, `setup_total` starts before
43
+ login and stops after the ActionCable subscription ack.
44
+
45
+ The path is:
46
+
47
+ 1. Log in.
48
+ 2. Render `/rooms/:id`.
49
+ 3. Extract the subscription marker from HTML.
50
+ 4. Open `/cable`.
51
+ 5. Subscribe to the channel.
52
+ 6. Receive the ack.
53
+
54
+ Turbo is expected to have a smaller cold path because it emits a signed stream
55
+ name. Upkeep has to render while recording dependencies, emit a subscription
56
+ id, register the graph, and then accept the socket subscription. That is an
57
+ inherent constant tax, but the current 3x gap is not inherent.
58
+
59
+ ## Landed Work
60
+
61
+ Recent commits to know:
62
+
63
+ - `d201dc9` - `Optimize ActiveRecord subscription persistence`
64
+ - `4acb7cb` - `Use typed persistent reverse index rows`
65
+ - `bfb8c93` - `Cancel stale subscriptions on unsubscribe`
66
+
67
+ The ActiveRecord subscription store is now split into smaller concerns:
68
+
69
+ - `ActiveRecordStore` coordinates the store.
70
+ - `ActiveRegistry` handles live in-process subscriptions.
71
+ - `AsyncDurableWriter` batches durable writes.
72
+ - `ActiveRecordSubscriptionPersistence` owns database writes and deletes.
73
+ - `PersistentReverseIndex` and `LayeredReverseIndex` handle persistent and
74
+ live lookup.
75
+ - `JsonSnapshot` owns inspectable replay/frame payload encoding.
76
+
77
+ Current storage rules:
78
+
79
+ - Production storage uses the ActiveRecord store.
80
+ - The memory store is for development/test configuration.
81
+ - Registration is live-first: the in-process registry updates synchronously,
82
+ then durable writes happen in a coalesced writer.
83
+ - Channel unsubscribe unregisters the active subscription, cancels queued
84
+ durable writes, deletes persisted rows for inflight work, and removes
85
+ reverse-index entries incrementally.
86
+
87
+ Measured lifecycle result:
88
+
89
+ - Upkeep-only run `20260514232743` lowered cold churn setup p95 to
90
+ 1097.75 ms.
91
+ - Final `upkeep_subscriptions` row count: 0.
92
+ - Final `upkeep_subscription_index_entries` row count: 0.
93
+ - Real persistence batch p95: 137.6 ms.
94
+
95
+ ## Current Design Boundaries
96
+
97
+ Keep these contracts separate:
98
+
99
+ - Subscription storage: active and durable graph lookup.
100
+ - Dispatching: render/delivery work scheduling.
101
+ - ActionCable broadcast bus: how messages reach connected clients.
102
+ - Benchmarking/telemetry: measurement only, not runtime policy.
103
+
104
+ Multi-worker support is not done just because subscriptions persist. A
105
+ multi-worker deployment needs:
106
+
107
+ - A shared ActionCable adapter.
108
+ - Durable graph lookup for workers that did not render the page.
109
+ - Dispatch work that can leave the Puma process or use the app queue adapter.
110
+ - Metrics that separate queue delay, invalidation proof, render cost, and
111
+ fanout cost.
112
+
113
+ Do not add a second production persistence path. The direction is one
114
+ production store contract and explicit queue/bus configuration around it.
115
+
116
+ ## Deoptimization Policy
117
+
118
+ Keep the deoptimization surface small and actionable.
119
+
120
+ Live deoptimizations stay only when Upkeep has already proven correctness and
121
+ is merely choosing a broader operation than the cheapest possible Turbo Stream.
122
+ Missing proof for a reactive boundary should refuse registration in a way that
123
+ raises in development/test and warns or refuses in production-style settings.
124
+
125
+ Every refusal should eventually include:
126
+
127
+ - Stable reason name.
128
+ - The concrete proof that was missing.
129
+ - A refactor suggestion.
130
+ - Enough context to connect it to a template or relation boundary.
131
+
132
+ ## What Not To Chase Next
133
+
134
+ Fibers are not the next optimization for the measured bottleneck. The slow path
135
+ is blocking ActiveRecord/SQLite persistence plus render/recording work under
136
+ contention. Fibers would not make those writes non-blocking.
137
+
138
+ A previous experiment with lowering the durable writer thread priority made
139
+ setup p95 worse, around 2914 ms. Do not reintroduce it without a benchmark that
140
+ proves a different result.
141
+
142
+ Do not optimize subscription registration first. It is already at 0.13 ms p95
143
+ in the latest comparison, roughly equal to Turbo.
144
+
145
+ ## Recommended Next Steps
146
+
147
+ 1. Diagnose the cold-admission gap with phase-level server and client evidence.
148
+
149
+ The server p95 phases in the latest report are small compared with the
150
+ client-observed gap:
151
+
152
+ | Server phase p95 | Upkeep | Turbo |
153
+ | --- | ---: | ---: |
154
+ | `sessions#create` | 8.28 ms | 5.98 ms |
155
+ | `rooms#show` | 12.69 ms | 3.83 ms |
156
+ | Subscription registration | 0.13 ms | 0.15 ms |
157
+ | Subscription confirmation | 0.06 ms | 0.04 ms |
158
+
159
+ That mismatch suggests queueing, contention, connection scheduling, or
160
+ missing instrumentation around the page/socket boundary. Add measurement
161
+ before changing behavior.
162
+
163
+ 2. Explain the single Upkeep subscription timeout.
164
+
165
+ Check `benchmark/results/matrix-chat_upkeep_cold_connect_churn-20260514233725.log`
166
+ and `benchmark/results/upkeep-app-server-20260514233725.log`. Determine
167
+ whether the timeout is ActionCable scheduling, subscription lookup, test
168
+ harness timing, or server pressure.
169
+
170
+ 3. Break down `/rooms/:id` render recording cost.
171
+
172
+ Compare Upkeep and Turbo page render work at the template/component level.
173
+ Separate normal Rails render time from dependency recording, marker
174
+ generation, subscription graph construction, and HTML size.
175
+
176
+ 4. Add an ActionCable connect phase for Upkeep in the comparison report.
177
+
178
+ The latest report shows Turbo cable connect p95 but `--` for Upkeep. That
179
+ makes the cold admission gap harder to localize.
180
+
181
+ 5. Only after the gap is localized, implement the smallest optimization and
182
+ rerun the same gate.
183
+
184
+ The loop should stay:
185
+
186
+ - Implement one optimization.
187
+ - Remove superseded code paths in the same pass.
188
+ - Update `docs/cost-model-roadmap.md` with the measured result.
189
+ - Run tests.
190
+ - Commit.
191
+
192
+ ## Commands
193
+
194
+ Activate the expected Ruby/toolchain before Ruby or Bundler commands:
195
+
196
+ ```sh
197
+ eval "$(mise activate zsh)"
198
+ ```
199
+
200
+ Run the full test suite:
201
+
202
+ ```sh
203
+ eval "$(mise activate zsh)" && ruby -S bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].sort.each { |file| require_relative file }'
204
+ ```
205
+
206
+ Run the cold churn comparison:
207
+
208
+ ```sh
209
+ mkdir -p /tmp/upkeep-asdf-helper
210
+ ln -sf /usr/bin/true /tmp/upkeep-asdf-helper/asdf
211
+ eval "$(mise activate zsh)"
212
+ PATH="/tmp/upkeep-asdf-helper:$PATH" BENCH_FAMILY=matrix BENCH_WORKLOAD=cold_connect_churn_chat BENCH_TIER=gate ruby benchmark/bin/run
213
+ ```
214
+
215
+ Run the Upkeep-only gate:
216
+
217
+ ```sh
218
+ mkdir -p /tmp/upkeep-asdf-helper
219
+ ln -sf /usr/bin/true /tmp/upkeep-asdf-helper/asdf
220
+ eval "$(mise activate zsh)"
221
+ PATH="/tmp/upkeep-asdf-helper:$PATH" BENCH_UPKEEP_ONLY=1 BENCH_FAMILY=matrix BENCH_WORKLOAD=cold_connect_churn_chat BENCH_TIER=gate ruby benchmark/bin/run
222
+ ```
223
+
224
+ ## Current Bottom Line
225
+
226
+ Upkeep now has a credible subscription storage architecture and the hot
227
+ registration path is no longer the issue. The project should reframe the next
228
+ performance work around cold admission and reliability under churn. Turbo is
229
+ still ahead there; Upkeep has to get the cold tax low enough that precise warm
230
+ invalidation and shared delivery work can pay it back.