Skip to content

Commit 77155df

Browse files
justin808claude
andcommitted
feat: add rolling_deploy_adapter for seeding previous bundle hashes
Introduces a pluggable adapter protocol so applications can eliminate the 410→retry cold-start round-trip for previously-deployed bundle hashes during rolling deploys — not just the current hash that PreSeedRendererCache already handles. Protocol (parallel to remote_bundle_cache_adapter): module MyAdapter def self.previous_bundle_hashes def self.fetch(bundle_hash) def self.upload(bundle_hash, server_bundle:, rsc_bundle: nil, assets:) end ReactOnRailsPro.configure do |config| config.rolling_deploy_adapter = MyAdapter end Integration points: - PreSeedRendererCache.call invokes RollingDeployCacheStager after staging the current hash(es). Seeds each previous hash's bundle + companion assets (loadable-stats.json, RSC manifests) into the same <cache>/<hash>/... layout so hydration stays consistent with the deployed asset pipeline for that hash. - AssetsPrecompile.call auto-invokes adapter.upload(current_hash, ...) after precompile in production-like environments. - PREVIOUS_BUNDLE_HASHES env var (comma-separated) overrides previous_bundle_hashes discovery for CI/testing. - Config validation checks the adapter responds to all three required methods at configure time. Error handling (all degrade gracefully — runtime 410-retry remains the fallback for any seeding failure): - Adapter not configured: no-op. - previous_bundle_hashes raises: warn, skip previous-hash seeding. - fetch returns nil or raises: warn, skip that hash. - upload raises in precompile: warn, don't fail precompile. - Hash matching current hash: deduped. Doctor probes: - Verifies protocol conformance. - Probes previous_bundle_hashes with a 3s timeout; reports latency and hash count. - Warns on empty list ("upload side has never run"). - Surfaces resolved renderer cache dir + hash subdirs present. - Echoes PREVIOUS_BUNDLE_HASHES env if set without an adapter. - Never calls fetch or upload (side effects). Docs: new docs/pro/rolling-deploy-adapters.md with protocol spec and three reference implementations (S3, Control Plane, Filesystem). docs/pro/node-renderer.md points at the new page. Tests: 7 new specs for RollingDeployCacheStager covering all invocation paths plus 5 new doctor specs. Refs: #3122, #3167 (PR B stacked base) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cd307fc commit 77155df

14 files changed

Lines changed: 780 additions & 11 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
3232

3333
- **[Pro]** **Pre-seed renderer cache for Docker builds**: New `react_on_rails_pro:pre_seed_renderer_cache` rake task copies compiled server bundles into the Node Renderer's bundle-hash cache directory structure during Docker image builds, eliminating the 410→retry cold-start latency (200ms–1s+) on the first SSR request after deployment. Supports `RENDERER_SERVER_BUNDLE_CACHE_PATH`, RSC bundles, and rolling-deploy guidance centered on current and previous bundle hashes. The legacy `pre_stage_bundle_for_node_renderer` task now stages the same cache layout via symlinks for same-filesystem workflows. **Note:** `RENDERER_BUNDLE_PATH` is now deprecated in favor of `RENDERER_SERVER_BUNDLE_CACHE_PATH` across both tasks. Existing users with `RENDERER_BUNDLE_PATH` set will see a deprecation warning on stderr. [PR 3124](https://github.com/shakacode/react_on_rails/pull/3124) by [justin808](https://github.com/justin808).
3434

35+
- **[Pro]** **Rolling-deploy adapter protocol**: New `config.rolling_deploy_adapter` pluggable module (protocol: `previous_bundle_hashes`, `fetch`, `upload`) that seeds previously-deployed bundle hashes into the Node Renderer cache, preventing 410→retry for draining-version requests during rolling deploys. `assets:precompile` auto-calls `upload` in production-like environments so the next deploy can fetch the just-built bundle. `PREVIOUS_BUNDLE_HASHES` env var overrides discovery for CI. `react_on_rails:doctor` probes the adapter and reports protocol conformance, discovery latency, and resolved cache dir. Each seeded hash carries its own `loadable-stats.json` / RSC manifests so client-side hydration stays consistent with the deployed asset pipeline for that hash. See `docs/pro/rolling-deploy-adapters.md` for the full protocol spec and reference implementations (S3, Control Plane, Filesystem).
36+
3537
#### Changed
3638

3739
- **[Pro]** **Unified renderer cache staging**: `ReactOnRailsPro::PreSeedRendererCache.call(mode: :copy | :symlink)` is now the single entry point for staging the Node Renderer cache. Both modes produce the same `<cache>/<bundleHash>/<bundleHash>.js` layout. The `react_on_rails_pro:pre_seed_renderer_cache` rake task accepts `MODE=copy` (default; Docker/image builds) or `MODE=symlink` (same-filesystem). `MODE=copy` now raises a clear error when neither `RENDERER_SERVER_BUNDLE_CACHE_PATH` nor `RENDERER_BUNDLE_PATH` is set in non-dev/test environments, because the Node renderer's default lookup can differ from the Ruby side and would silently drop pre-seeded bundles in the wrong directory. The legacy `react_on_rails_pro:pre_stage_bundle_for_node_renderer` task and `ReactOnRailsPro::PrepareNodeRenderBundles` class remain as deprecated shims that emit a once-per-process warning and delegate to `mode: :symlink`. `react_on_rails:doctor` flags deploy scripts that still reference the deprecated task. [PR 3167](https://github.com/shakacode/react_on_rails/pull/3167) by [justin808](https://github.com/justin808).

docs/pro/node-renderer.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,28 @@ RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache
163163

164164
### Rolling deploys: seed current and previous bundle hashes
165165

166-
During a rolling deploy, new renderer instances can receive requests for both the current deployed bundle hash and the previous hash while old Rails instances drain. Treat this as a two-hash cache-seeding problem, not a single-file problem.
166+
During a rolling deploy, new renderer instances can receive requests for both the current deployed bundle hash and the previous hash while old Rails instances drain. Treat this as a two-hash cache-seeding problem, not a single-file problem — and each seeded hash must carry its own companion `loadable-stats.json` / RSC manifests built in lockstep with that bundle.
167167

168-
At startup, aim to have the cache contain:
168+
`pre_seed_renderer_cache` handles the current bundle. For previous hashes, configure a **`rolling_deploy_adapter`** that:
169169

170-
- the current server bundle hash
171-
- the previous server bundle hash
172-
- the current and previous RSC bundle hashes as well, if RSC support is enabled
173-
- any required copied assets and RSC manifests in each seeded hash directory
170+
- Publishes each successful deploy's bundle + companion assets to an artifact store (S3, Control Plane image registry, etc.) via its `upload` method — called automatically after `assets:precompile` in production-like environments.
171+
- Advertises recent deploys' bundle hashes via `previous_bundle_hashes`.
172+
- Retrieves the bundle + assets for a given historical hash via `fetch`.
174173

175-
`pre_seed_renderer_cache` seeds the current locally built bundle outputs. For the previous deployed hash, the most practical approach is to publish bundle artifacts keyed by hash after each successful deploy, then fetch the previous hash artifact during the next build and place it into the same `<cache>/<bundleHash>/...` layout before boot.
174+
```ruby
175+
# config/initializers/react_on_rails_pro.rb
176+
ReactOnRailsPro.configure do |config|
177+
config.rolling_deploy_adapter = MyApp::S3RollingDeployAdapter
178+
end
179+
```
180+
181+
During the next build, `pre_seed_renderer_cache` calls `previous_bundle_hashes`, deduplicates against the current hash, then fetches and stages each into `<cache>/<bundleHash>/...` — preventing 410→retry for draining-version requests.
182+
183+
See [Rolling-Deploy Adapters](./rolling-deploy-adapters.md) for the full protocol spec, reference implementations (S3, Control Plane, Filesystem), and a discussion of the loadable-stats wrinkle.
176184

177185
## Further Reading
178186

187+
- [Rolling-Deploy Adapters](./rolling-deploy-adapters.md) — Protocol spec and reference implementations for `rolling_deploy_adapter`
179188
- [Node Renderer basics](../oss/building-features/node-renderer/basics.md) — Architecture and core concepts
180189
- [JavaScript configuration](../oss/building-features/node-renderer/js-configuration.md) — Node-side config options
181190
- [Error reporting and tracing](../oss/building-features/node-renderer/error-reporting-and-tracing.md) — Monitoring in production
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# Rolling-Deploy Adapters
2+
3+
> [!NOTE]
4+
> **Summary for AI agents:** Use this page when the user is configuring a `rolling_deploy_adapter` to eliminate 410→retry cold starts for **previous** deployed bundle hashes during rolling deploys. The [Node Renderer page](./node-renderer.md) covers the current-hash pre-seeding (PR A) and unified copy/symlink staging (PR B); this page is specific to the rolling-deploy adapter protocol introduced alongside them.
5+
6+
## The problem
7+
8+
During a rolling deploy:
9+
10+
- Old Rails instances (bundle hash `abc`) are still draining traffic.
11+
- New Rails instances (bundle hash `def`) serve new traffic.
12+
- New renderer instances receive requests for **both** hashes.
13+
14+
Pre-seeding the current hash (`def`) eliminates the 410→retry only for the new bundle. Requests referencing `abc` still hit a cold cache on new renderers, producing 410 retries per request until the renderer has cached that bundle via upload.
15+
16+
The **`rolling_deploy_adapter`** protocol lets your application fetch previously-deployed bundles (and their companion assets) from an artifact store during the next build, so new renderer instances start warm for every in-flight bundle hash.
17+
18+
## The loadable-stats wrinkle
19+
20+
Each bundle hash has **companion assets** built in lockstep:
21+
22+
- `loadable-stats.json` — maps chunk IDs → asset URLs.
23+
- `react-client-manifest.json`, `react-server-client-manifest.json` (when RSC enabled) — map component IDs → chunk paths.
24+
25+
If the renderer handles a request for bundle `abc` but reads the **new** build's manifests, it emits HTML referencing chunk URLs that the old deployment's asset pipeline never produced → client-side hydration breakage, chunk 404s.
26+
27+
**Therefore: each seeded bundle hash must carry its own companion assets.** The adapter's `fetch(hash)` method returns bundle + assets together so the caller can't forget.
28+
29+
## Protocol
30+
31+
Your adapter must define three class methods:
32+
33+
```ruby
34+
module MyRollingDeployAdapter
35+
# Discovery. Called during pre-seeding to determine which historical
36+
# hashes to fetch. Typically hits the running deployment's /_health
37+
# endpoint or reads a manifest file in the artifact store.
38+
# @return [Array<String>] ordered list of recent bundle hashes.
39+
# @return [] to disable previous-bundle seeding on this build.
40+
def self.previous_bundle_hashes
41+
# ...
42+
end
43+
44+
# Retrieval. Given a bundle hash, fetch the bundle + its companion
45+
# assets to local disk and return their paths.
46+
# @return [Hash, nil] Hash with :server_bundle (required), :rsc_bundle
47+
# (optional), :assets (Array<String>). nil if unavailable — pre-seeding
48+
# logs a warning and continues.
49+
def self.fetch(bundle_hash)
50+
# ...
51+
end
52+
53+
# Publication. Called automatically after assets:precompile in
54+
# production-like environments when the adapter is configured.
55+
# Uploads the current build's bundle + assets keyed by hash so
56+
# future deploys can retrieve them. Errors are warned, not raised.
57+
def self.upload(bundle_hash, server_bundle:, rsc_bundle: nil, assets:)
58+
# ...
59+
end
60+
end
61+
62+
# config/initializers/react_on_rails_pro.rb
63+
ReactOnRailsPro.configure do |config|
64+
config.rolling_deploy_adapter = MyRollingDeployAdapter
65+
end
66+
```
67+
68+
## Env-var override
69+
70+
For CI and testing, set `PREVIOUS_BUNDLE_HASHES` as a comma-separated list to skip `previous_bundle_hashes` discovery:
71+
72+
```bash
73+
PREVIOUS_BUNDLE_HASHES=abc123,def456 rake react_on_rails_pro:pre_seed_renderer_cache
74+
```
75+
76+
This runs the adapter's `fetch(hash)` for each listed hash but skips discovery.
77+
78+
## Edge cases and error handling
79+
80+
| Scenario | Behavior |
81+
| -------------------------------------- | ---------------------------------------------------------------------------- |
82+
| Adapter not configured | No-op. Only the current hash is staged. |
83+
| `previous_bundle_hashes` returns `[]` | Log "No previous bundle hashes to seed" and continue. |
84+
| `previous_bundle_hashes` raises | Warn, skip previous-hash seeding, continue. Current-hash staging unaffected. |
85+
| `fetch(hash)` returns `nil` | Warn, skip that hash. Runtime 410-retry remains the fallback. |
86+
| `fetch(hash)` raises | Warn, skip that hash. Runtime 410-retry remains the fallback. |
87+
| Returned hash matches current hash | Deduplicated — not refetched. |
88+
| `upload` raises in `assets:precompile` | Warn but don't fail precompile. Next deploy degrades, not this one. |
89+
90+
## Reference implementations
91+
92+
These are copy-pasteable starting points. Adapt to your infrastructure.
93+
94+
### S3
95+
96+
Publish bundles + companion assets under `s3://<bucket>/bundles/<hash>/`. A manifest file at `bundles/_manifest.json` tracks the rolling list of recent hashes.
97+
98+
```ruby
99+
require "aws-sdk-s3"
100+
require "fileutils"
101+
require "json"
102+
103+
class S3RollingDeployAdapter
104+
BUCKET = ENV.fetch("ROLLING_DEPLOY_BUCKET")
105+
PREFIX = "bundles"
106+
MANIFEST_KEY = "#{PREFIX}/_manifest.json".freeze
107+
RETENTION = 3
108+
109+
def self.previous_bundle_hashes
110+
resp = s3.get_object(bucket: BUCKET, key: MANIFEST_KEY)
111+
JSON.parse(resp.body.read).fetch("hashes", []).last(RETENTION)
112+
rescue Aws::S3::Errors::NoSuchKey
113+
[]
114+
end
115+
116+
def self.fetch(hash)
117+
dir = Rails.root.join("tmp/rolling-deploy", hash)
118+
FileUtils.mkdir_p(dir)
119+
{
120+
server_bundle: download_to(dir, "server-bundle.js", hash),
121+
rsc_bundle: download_optional(dir, "rsc-bundle.js", hash),
122+
assets: %w[loadable-stats.json react-client-manifest.json react-server-client-manifest.json]
123+
.map { |name| download_optional(dir, name, hash) }
124+
.compact
125+
}
126+
rescue Aws::S3::Errors::NoSuchKey
127+
nil
128+
end
129+
130+
def self.upload(hash, server_bundle:, rsc_bundle: nil, assets:)
131+
put("#{PREFIX}/#{hash}/server-bundle.js", server_bundle)
132+
put("#{PREFIX}/#{hash}/rsc-bundle.js", rsc_bundle) if rsc_bundle
133+
assets.each { |path| put("#{PREFIX}/#{hash}/#{File.basename(path)}", path) }
134+
update_manifest!(hash)
135+
end
136+
137+
# -- helpers (private by convention) --
138+
139+
def self.s3
140+
@s3 ||= Aws::S3::Client.new
141+
end
142+
143+
def self.download_to(dir, name, hash)
144+
path = dir.join(name).to_s
145+
s3.get_object(bucket: BUCKET, key: "#{PREFIX}/#{hash}/#{name}", response_target: path)
146+
path
147+
end
148+
149+
def self.download_optional(dir, name, hash)
150+
download_to(dir, name, hash)
151+
rescue Aws::S3::Errors::NoSuchKey
152+
nil
153+
end
154+
155+
def self.put(key, path)
156+
File.open(path, "rb") { |body| s3.put_object(bucket: BUCKET, key: key, body: body) }
157+
end
158+
159+
def self.update_manifest!(hash)
160+
hashes = previous_bundle_hashes
161+
hashes << hash unless hashes.include?(hash)
162+
s3.put_object(
163+
bucket: BUCKET,
164+
key: MANIFEST_KEY,
165+
body: JSON.generate(hashes: hashes.last(RETENTION + 1))
166+
)
167+
end
168+
end
169+
```
170+
171+
### Control Plane
172+
173+
Uses `cpln` CLI to pull the previous deployment's image layer and extract cache contents. `upload` is a no-op — the image itself is the artifact.
174+
175+
```ruby
176+
class ControlPlaneRollingDeployAdapter
177+
GVC = ENV.fetch("CPLN_GVC")
178+
WORKLOAD = ENV.fetch("CPLN_RAILS_WORKLOAD")
179+
180+
def self.previous_bundle_hashes
181+
output = `cpln workload get #{WORKLOAD} --gvc #{GVC} -o json`
182+
env = JSON.parse(output).dig("spec", "containers", 0, "env") || []
183+
hash = env.find { |e| e["name"] == "REACT_ON_RAILS_BUNDLE_HASH" }&.dig("value")
184+
hash ? [hash] : []
185+
end
186+
187+
def self.fetch(hash)
188+
image = "#{GVC}/app-#{hash}"
189+
tmp = Rails.root.join("tmp/rolling-deploy", hash)
190+
FileUtils.mkdir_p(tmp)
191+
# Extract cache dir contents from the previous image layer:
192+
system("cpln image pull #{image} --output #{tmp}") or return nil
193+
{ server_bundle: tmp.join("server-bundle.js").to_s,
194+
rsc_bundle: File.exist?(tmp.join("rsc-bundle.js")) ? tmp.join("rsc-bundle.js").to_s : nil,
195+
assets: Dir[tmp.join("*.json")] }
196+
end
197+
198+
def self.upload(_hash, server_bundle:, rsc_bundle: nil, assets:)
199+
# No-op: the Docker image IS the artifact. The next build pulls
200+
# via `cpln image pull`.
201+
end
202+
end
203+
```
204+
205+
### Filesystem (testing / volume-mounted deploys)
206+
207+
Reads/writes a local directory specified by `ROLLING_DEPLOY_DIR`. Useful for local experimentation and as the reference test fixture.
208+
209+
```ruby
210+
require "fileutils"
211+
require "json"
212+
213+
class FilesystemRollingDeployAdapter
214+
def self.root
215+
Pathname.new(ENV.fetch("ROLLING_DEPLOY_DIR"))
216+
end
217+
218+
def self.previous_bundle_hashes
219+
manifest = root.join("_manifest.json")
220+
return [] unless manifest.exist?
221+
222+
JSON.parse(manifest.read).fetch("hashes", [])
223+
end
224+
225+
def self.fetch(hash)
226+
dir = root.join(hash)
227+
return nil unless dir.directory?
228+
229+
{ server_bundle: dir.join("server-bundle.js").to_s,
230+
rsc_bundle: (dir.join("rsc-bundle.js").to_s if dir.join("rsc-bundle.js").exist?),
231+
assets: Dir[dir.join("*.json")] }
232+
end
233+
234+
def self.upload(hash, server_bundle:, rsc_bundle: nil, assets:)
235+
dir = root.join(hash)
236+
FileUtils.mkdir_p(dir)
237+
FileUtils.cp(server_bundle, dir.join("server-bundle.js"))
238+
FileUtils.cp(rsc_bundle, dir.join("rsc-bundle.js")) if rsc_bundle
239+
assets.each { |p| FileUtils.cp(p, dir.join(File.basename(p))) }
240+
hashes = (previous_bundle_hashes + [hash]).uniq
241+
root.join("_manifest.json").write(JSON.generate(hashes: hashes))
242+
end
243+
end
244+
```
245+
246+
## Verifying your adapter with `react_on_rails:doctor`
247+
248+
`react_on_rails:doctor` probes a configured `rolling_deploy_adapter` and reports:
249+
250+
- ✅ Whether it responds to all three required methods.
251+
- ✅ Whether `previous_bundle_hashes` returns successfully within 3 seconds, and how many hashes it returned.
252+
- ⚠️ Empty-list returns (often indicates the upload side has never run on a prior deploy).
253+
- ℹ️ The resolved renderer cache dir and how many bundle-hash subdirectories are present.
254+
- ℹ️ Whether `PREVIOUS_BUNDLE_HASHES` env override is set.
255+
256+
Doctor never calls `fetch` or `upload` — those have side effects.
257+
258+
## Relationship to `remote_bundle_cache_adapter`
259+
260+
These two adapters solve different problems and are complementary:
261+
262+
| | `remote_bundle_cache_adapter` | `rolling_deploy_adapter` |
263+
| ------------ | --------------------------------------------- | ----------------------------------------- |
264+
| **Scope** | Webpack build outputs (pre-compile caching) | Deployed bundle hashes (rolling deploy) |
265+
| **When** | Build phase (`assets:precompile`) | Post-precompile + pre-seed phase |
266+
| **Avoids** | Rebuilding webpack when source hasn't changed | 410 retries for draining-version requests |
267+
| **Keyed by** | Source digest | Bundle hash |
268+
269+
You can configure both; they don't interact.

docs/sidebars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ const sidebars: SidebarsConfig = {
196196
'pro/upgrading-to-pro',
197197
'pro/streaming-ssr',
198198
'pro/node-renderer',
199+
'pro/rolling-deploy-adapters',
199200
'pro/fragment-caching',
200201
'pro/js-memory-leaks',
201202
'pro/profiling-server-side-rendering-code',

0 commit comments

Comments
 (0)