upkeep-rails 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/docs/architecture/ambient-inputs-roadmap.md +308 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +306 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/architecture/subscription-store-contract.md +66 -0
- data/docs/cost-model-roadmap.md +704 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/stress-test-findings.md +310 -0
- data/docs/testing.md +143 -0
- data/lib/generators/upkeep/install/install_generator.rb +127 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +99 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +63 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +302 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +518 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +514 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +360 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +821 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +143 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +341 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +45 -0
- data/lib/upkeep/rails/configuration.rb +245 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/delivery_job.rb +29 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +50 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +97 -0
- data/lib/upkeep/rails.rb +349 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1100 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +383 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +407 -0
- data/lib/upkeep/subscriptions/active_registry.rb +87 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +131 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +129 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +223 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +298 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +171 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +54 -0
- metadata +320 -0
|
@@ -0,0 +1,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.
|