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,282 @@
|
|
|
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
|
+
Opaque reactive boundaries are refused instead of being widened into broad
|
|
27
|
+
invalidation. Development/test raises by default so unsupported query shapes are
|
|
28
|
+
found early; production warns and skips subscription registration by default:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
config.upkeep.refused_boundary_behavior = :raise # or :warn
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run The Installer
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
bin/rails generate upkeep:install
|
|
38
|
+
bin/rails db:migrate
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The installer creates:
|
|
42
|
+
|
|
43
|
+
- `config/initializers/upkeep.rb`
|
|
44
|
+
- `db/migrate/*_create_upkeep_subscriptions.rb`
|
|
45
|
+
- `app/javascript/upkeep/subscription.js`
|
|
46
|
+
- an import from `app/javascript/application.js`
|
|
47
|
+
- importmap pins for Turbo and ActionCable when `config/importmap.rb` exists
|
|
48
|
+
- an ActionCable mount in `config/routes.rb` when one is not present
|
|
49
|
+
|
|
50
|
+
## Subscription Storage
|
|
51
|
+
|
|
52
|
+
Production subscription storage is explicit:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# config/initializers/upkeep.rb
|
|
56
|
+
Rails.application.configure do
|
|
57
|
+
config.upkeep.enabled = true
|
|
58
|
+
config.upkeep.subscription_store = :active_record
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The ActiveRecord store persists subscriptions and reverse-index entries so any
|
|
63
|
+
Puma worker can plan invalidations for subscriptions captured by another
|
|
64
|
+
worker. For development or isolated tests that do not run the installer
|
|
65
|
+
migration, opt into the in-process store explicitly:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
config.upkeep.subscription_store = :memory
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The benchmark app uses this migration shape:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
create_table :upkeep_subscriptions, id: :string do |t|
|
|
75
|
+
t.string :subscriber_id, null: false
|
|
76
|
+
t.json :recorder_snapshot, null: false
|
|
77
|
+
t.json :metadata
|
|
78
|
+
t.timestamps
|
|
79
|
+
end
|
|
80
|
+
add_index :upkeep_subscriptions, :subscriber_id
|
|
81
|
+
|
|
82
|
+
create_table :upkeep_subscription_index_entries do |t|
|
|
83
|
+
t.string :subscription_id, null: false
|
|
84
|
+
t.string :lookup_key_digest, null: false
|
|
85
|
+
t.string :dependency_source, null: false
|
|
86
|
+
t.string :lookup_table, null: false
|
|
87
|
+
t.json :lookup_record_id_snapshot
|
|
88
|
+
t.string :lookup_attribute, null: false
|
|
89
|
+
t.string :dependency_table, null: false
|
|
90
|
+
t.string :dependency_predicate_digest
|
|
91
|
+
t.json :dependency_metadata_snapshot
|
|
92
|
+
t.json :owner_ids_snapshot, null: false
|
|
93
|
+
t.timestamps
|
|
94
|
+
end
|
|
95
|
+
add_index :upkeep_subscription_index_entries, :subscription_id
|
|
96
|
+
add_index :upkeep_subscription_index_entries, :lookup_key_digest
|
|
97
|
+
add_foreign_key :upkeep_subscription_index_entries,
|
|
98
|
+
:upkeep_subscriptions,
|
|
99
|
+
column: :subscription_id,
|
|
100
|
+
on_delete: :cascade
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Configure ActionCable Identity
|
|
104
|
+
|
|
105
|
+
Mount ActionCable:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# config/routes.rb
|
|
109
|
+
mount ActionCable.server => "/cable"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Expose a canonical server identity on the ActionCable connection. Active Record
|
|
113
|
+
records, scalars, and GlobalID values are supported identity components:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
module ApplicationCable
|
|
117
|
+
class Connection < ActionCable::Connection::Base
|
|
118
|
+
identified_by :current_user
|
|
119
|
+
|
|
120
|
+
def connect
|
|
121
|
+
self.current_user = User.find_by(id: request.session[:user_id]) ||
|
|
122
|
+
reject_unauthorized_connection
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Clients subscribe to `Upkeep::Rails::Cable::Channel` with the
|
|
129
|
+
`subscription_id` from the injected `data-upkeep-subscription` marker. The
|
|
130
|
+
server channel validates the subscription id and streams from the canonical
|
|
131
|
+
subscriber stream plus any shared streams attached to that graph.
|
|
132
|
+
|
|
133
|
+
The generated browser bootstrap reads those markers, subscribes through
|
|
134
|
+
`@rails/actioncable`, and applies received Turbo Stream payloads.
|
|
135
|
+
|
|
136
|
+
For more than one Puma worker, configure ActionCable with a shared adapter
|
|
137
|
+
such as Redis or Solid Cable. The subscription store decides who should receive
|
|
138
|
+
work; ActionCable decides which worker owns each WebSocket connection.
|
|
139
|
+
|
|
140
|
+
## Render Normal Rails Views
|
|
141
|
+
|
|
142
|
+
Controllers load normal Active Record models and relations:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class BoardsController < ApplicationController
|
|
146
|
+
def show
|
|
147
|
+
@board = Board.find(params[:id])
|
|
148
|
+
@cards = @board.cards.order(:position)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Templates render normal ERB and partial collections:
|
|
154
|
+
|
|
155
|
+
```erb
|
|
156
|
+
<main>
|
|
157
|
+
<h1><%= @board.name %></h1>
|
|
158
|
+
|
|
159
|
+
<ul id="cards">
|
|
160
|
+
<%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
161
|
+
</ul>
|
|
162
|
+
</main>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Successful HTML GET responses are captured automatically. Upkeep records page,
|
|
166
|
+
render-site, and fragment frames from Rails renderer hooks, then injects a
|
|
167
|
+
subscription marker into the response.
|
|
168
|
+
|
|
169
|
+
Controller materialization is supported when the rendered value keeps a
|
|
170
|
+
structural relation proof:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
def index
|
|
174
|
+
@cards = Card.where(status: "open").order(:position).to_a
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```erb
|
|
179
|
+
<%= render partial: "cards/card", collection: @cards, as: :card %>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Upkeep attaches the collection dependency to the rendered collection boundary,
|
|
183
|
+
not to every controller query. A materialized relation that is never rendered
|
|
184
|
+
as a collection is not a lifecycle dependency by itself.
|
|
185
|
+
|
|
186
|
+
Scalar relation output is tracked as a page-level query dependency:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
@tag_names = Tag.where(active: true).pluck(:name)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Simple plucked columns are live and can select a page replay when they change.
|
|
193
|
+
They are not collection dependencies, so they do not participate in
|
|
194
|
+
append/remove/prepend planning.
|
|
195
|
+
|
|
196
|
+
Session, cookie, and request reads are observed inputs:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
@viewer = session[:viewer]
|
|
200
|
+
@tag_filter = cookies[:tag_filters]
|
|
201
|
+
@agent = request.user_agent
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Replay stores only observed values needed to rerun the page. Unread session
|
|
205
|
+
keys, cookies, and request headers are not copied into the replay payload.
|
|
206
|
+
|
|
207
|
+
## Mutate Through Active Record
|
|
208
|
+
|
|
209
|
+
Write paths keep doing domain work:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class CardsController < ApplicationController
|
|
213
|
+
def update
|
|
214
|
+
card = Card.find(params[:id])
|
|
215
|
+
card.update!(card_params)
|
|
216
|
+
head :ok
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
After commit, Upkeep records the changed table, id, and attributes. The planner
|
|
222
|
+
uses the reverse index to select affected subscribers and render targets.
|
|
223
|
+
Collection lookup entries are keyed by proven table and column pairs, so a write
|
|
224
|
+
to `cards.title` does not select a collection whose only proven dependency is
|
|
225
|
+
`cards.status`. Identity, request, session, and cookie reads are still recorded
|
|
226
|
+
on the graph for replay and sharing, but lifecycle writes do not index or select
|
|
227
|
+
them.
|
|
228
|
+
|
|
229
|
+
Bulk writes are observed through Active Record relations:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
Card.where(board_id: board.id, status: "open").update_all(status: "done")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
For structurally visible relations, Upkeep derives the involved tables and
|
|
236
|
+
columns through Arel. Opaque collection relations are refused instead of being
|
|
237
|
+
registered as broad reactive dependencies; rewrite them with structural
|
|
238
|
+
Active Record or Arel predicates before relying on Upkeep updates.
|
|
239
|
+
|
|
240
|
+
Non-GET controller actions do not register subscriptions. They still capture
|
|
241
|
+
Active Record lifecycle changes and deliver to existing subscribers.
|
|
242
|
+
|
|
243
|
+
## Verify The Integration
|
|
244
|
+
|
|
245
|
+
The benchmark app checks the integration path with `Upkeep::Rails::Testing`:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
include Upkeep::Rails::Testing
|
|
249
|
+
|
|
250
|
+
get board_path(board)
|
|
251
|
+
assert_response :success
|
|
252
|
+
assert_upkeep_subscription_registered
|
|
253
|
+
assert_instance_of Upkeep::Subscriptions::ActiveRecordStore,
|
|
254
|
+
Upkeep::Rails.subscriptions
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
For a streamed mutation, capture ActionCable broadcasts for the subscription's
|
|
258
|
+
stream names, perform the mutation, drain delivery, and assert the payload:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
include ActionCable::TestHelper
|
|
262
|
+
include Upkeep::Rails::Testing
|
|
263
|
+
|
|
264
|
+
broadcasts = capture_upkeep_broadcasts do
|
|
265
|
+
patch board_card_path(board, card), params: { card: { title: "Updated" } }
|
|
266
|
+
Upkeep::Rails.drain_delivery!
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
assert_includes broadcasts.join, "Updated"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Current Boundaries
|
|
273
|
+
|
|
274
|
+
- HTML GET responses are the subscription capture path.
|
|
275
|
+
- Non-GET requests are mutation/delivery paths, not subscription capture paths.
|
|
276
|
+
- Non-HTML templates are outside the Herb-backed template planning surface.
|
|
277
|
+
- The app does not declare query or identity dependencies. If a render depends
|
|
278
|
+
on hidden process state outside the observed Rails surfaces, Upkeep cannot
|
|
279
|
+
prove subscriber ownership for that value.
|
|
280
|
+
- Opaque relation predicates, raw SQL joins/sources, and opaque pluck
|
|
281
|
+
expressions raise in development/test by default. In warning mode they refuse
|
|
282
|
+
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.
|