upkeep-rails 0.1.0

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

Potentially problematic release.


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

Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,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.