Skip to content

Commit 50bf53b

Browse files
justin808claude
andauthored
feat: rolling_deploy_adapter for seeding previous bundle hashes (#3173)
## Summary Adds a pluggable **`config.rolling_deploy_adapter`** protocol that eliminates 410→retry cold-start round-trips for _previously-deployed_ bundle hashes during rolling deploys — complementing the current-hash seeding already handled by PRs #3124 + #3167. Stacked on #3167 (PR B). Target base is \`jg/3122-unify-renderer-cache-staging\`; will rebase onto \`master\` once PRs A + B merge. Refs #3122. Design doc previously shared: see earlier session context. ## Why PR A seeded the current hash. PR B unified copy/symlink staging. But during a rolling deploy: - Old Rails instances (hash \`abc\`) still drain traffic. - New Rails instances (hash \`def\`) serve new traffic. - New renderer instances receive requests for **both** hashes. Pre-seeding only the current hash leaves \`abc\` cold on new renderers → every draining-version request hits the 410 retry path. This PR closes that gap. ## Protocol ```ruby module MyRollingDeployAdapter def self.previous_bundle_hashes # Array<String> def self.fetch(bundle_hash) # Hash(:bundle, :assets) | nil def self.upload(bundle_hash, bundle:, assets:) end ReactOnRailsPro.configure do |config| config.rolling_deploy_adapter = MyRollingDeployAdapter end ``` Parallel in shape to \`remote_bundle_cache_adapter\` — same duck-typed module pattern, same validation at configure time. ## The loadable-stats wrinkle Each bundle hash must carry its **own** companion \`loadable-stats.json\` and RSC manifests (built in lockstep with that bundle). Otherwise the renderer emits HTML referencing chunk URLs for the wrong build → client-side hydration breakage. The \`fetch\` signature returns bundle + assets _together_ to force callers to think about this; see \`docs/pro/rolling-deploy-adapters.md\` for the full discussion. ## Integration points 1. **\`PreSeedRendererCache.call\`** (from PR B) now invokes \`RollingDeployCacheStager\` after staging the current hash(es). Deduplicates against current hashes. 2. **\`AssetsPrecompile.call\`** auto-invokes \`adapter.upload(current_hash, ...)\` after precompile in production-like environments, closing the publish side so the next deploy can fetch. 3. **\`PREVIOUS_BUNDLE_HASHES\`** env var (comma-separated) overrides \`previous_bundle_hashes\` discovery for CI / testing. ## Error handling — degrades gracefully Runtime 410-retry remains the fallback for any seeding failure, so rolling-deploy seeding can never cause a correctness regression: | Scenario | Behavior | | --------------------------------- | --------------------------------- | | 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. | | Returned hash matches current | Deduped. | ## Doctor (\`react_on_rails:doctor\`) probes - ✅ Protocol conformance (all three required methods). - ✅ \`previous_bundle_hashes\` probe with 10s timeout — reports latency and count. - ⚠️ Empty list (\"upload side has never run on a prior deploy\"). - ℹ️ Resolved renderer cache dir + bundle-hash subdirs present. - ℹ️ \`PREVIOUS_BUNDLE_HASHES\` env set without an adapter. Doctor never calls \`fetch\` or \`upload\` — those have side effects. ## Docs - **New**: \`docs/pro/rolling-deploy-adapters.md\` — protocol spec, three reference implementations (S3, Control Plane, Filesystem), loadable-stats wrinkle discussion, relationship to \`remote_bundle_cache_adapter\`. - \`docs/pro/node-renderer.md\` rolling-deploys section updated to point at the new page. - Sidebar entry added. ## Test plan - [x] 7 new specs for \`RollingDeployCacheStager\` covering every invocation path: adapter unset, hash list discovery, env override, copy mode, symlink mode, fetch nil, fetch raises, previous_bundle_hashes raises, missing :server_bundle in payload, dedup vs current. - [x] 5 new doctor specs for \`check_rolling_deploy_adapter\`. - [x] Existing Pro specs updated where \`instance_double(Configuration, ...)\` needed the new accessor. - [x] 36 Pro specs pass locally (27 from PR B + 9 new). - [x] 168 doctor specs pass locally (1 pre-existing unrelated failure in \`auto_fix_versions\`). - [x] \`bundle exec rubocop\` clean on all changed files. - [ ] CI (will verify after push). ## Relationship to other adapters | | \`remote_bundle_cache_adapter\` | \`rolling_deploy_adapter\` | | ------------ | --------------------------------------------- | ----------------------------------------- | | **Scope** | Webpack build outputs (pre-compile caching) | Deployed bundle hashes (rolling deploy) | | **When** | Build phase | Post-precompile + pre-seed phase | | **Avoids** | Rebuilding webpack when source hasn't changed | 410 retries for draining-version requests | | **Keyed by** | Source digest | Bundle hash | Complementary — configure both if useful. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches Pro deploy-time asset publication and renderer-cache staging paths (`assets:precompile` and `PreSeedRendererCache`), which can impact production deploy behavior if misconfigured. Changes are largely additive and guarded with timeouts/warnings, but involve filesystem operations and external adapter calls. > > **Overview** > Introduces a new Pro `config.rolling_deploy_adapter` protocol (`previous_bundle_hashes`, `fetch`, `upload`) to **seed prior bundle hashes** into the Node Renderer cache during `PreSeedRendererCache`, with `PREVIOUS_BUNDLE_HASHES` available as a discovery override. > > Hooks `assets:precompile` to **publish the current server/RSC bundle hashes + companion assets** via the adapter (best-effort with per-hash timeouts and warnings), and extends `react_on_rails:doctor` to validate/probe the adapter (including cache-dir reporting and env-var misuse warnings). > > Unifies cache staging helpers and updates staging to **auto-include `loadable-stats.json` when present**, adds extensive specs for the stager/doctor/config validation, and documents the protocol with new `docs/pro/rolling-deploy-adapters.md` plus sidebar/node-renderer doc updates (plus a minor lychee exclude tweak). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ca729db. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Rolling-deploy adapter to pre-seed renderer caches and optionally publish bundles during assets precompile. * Automatic inclusion/staging of loadable-stats.json companion assets when present. * **Configuration** * New rolling_deploy_adapter option with validation of the adapter’s required interface. * **Documentation** * Comprehensive rolling-deploy adapter docs, examples, and expanded Node Renderer rolling-deploy guidance; sidebar entry added. * **Improved Diagnostics** * Doctor now reports rolling-deploy adapter status, discovery, and warnings. * **Tests** * Extensive spec coverage for rolling-deploy, pre-seed, publishing, and configuration validation. * **Chores** * CI link-checker exclusion added for an intermittently failing GitHub URL. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/shakacode/react_on_rails/pull/3173) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 03b048f commit 50bf53b

20 files changed

Lines changed: 3123 additions & 98 deletions

.lychee.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ exclude = [
121121
'^https://github\.com/shakacode/react_on_rails/issues/2766$', # Intermittent 502 from GitHub issue pages in CI
122122
'^https://github\.com/rack/rack/blob/main/SPEC\.rdoc$', # Intermittent 502 from GitHub blob pages in CI
123123
'^https://github\.com/shakacode/shakapacker/blob/cdf32835d3e0949952b8b4b53063807f714f9b24/package/environments/base\.js(#.*)?$', # Intermittent 502 from GitHub blob view in CI
124+
'^https://github\.com/K4sku$', # Intermittent 502 from GitHub user page in CI
124125
'^https://github\.com/npm/cli/issues/6253$', # Intermittent 502 from GitHub issue pages in CI
125126
'^https://github\.com/search\?q=gem\+react_on_rails&ref=advsearch&type=repositories&utf8=%E2%9C%93$', # Intermittent 502 from GitHub search in CI
126127

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
3838
- **[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).
3939
- **[Pro]** **Async props with incremental React Server Component rendering**: Added the `stream_react_component_with_async_props` and `rsc_payload_react_component_with_async_props` view helpers, which accept a block to declare props that are fetched concurrently and streamed to the rendering component as each value becomes available. The React component renders its shell immediately (with `<Suspense>` fallbacks) and progressively re-renders as async prop promises resolve, dramatically improving Time to First Byte for pages with slow data fetches. Components access async props through the `getReactOnRailsAsyncProp` function injected into props (typed via the new `WithAsyncProps` TypeScript helper). Requires `config.enable_rsc_support = true`. This is an additive feature — existing `stream_react_component` and `rsc_payload_react_component` calls are unaffected. [PR 2903](https://github.com/shakacode/react_on_rails/pull/2903) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
4040

41+
- **[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). [PR 3173](https://github.com/shakacode/react_on_rails/pull/3173) by [justin808](https://github.com/justin808).
42+
4143
#### Changed
4244

45+
- **[Pro]** **`PreSeedRendererCache` and `PrepareNodeRenderBundles` now auto-stage `loadable-stats.json`**: `ReactOnRailsPro::RendererCacheHelpers.collect_assets` now appends `loadable-stats.json` whenever the file exists on disk, so every caller (rolling-deploy seeding, `pre_seed_renderer_cache`, `pre_stage_bundle_for_node_renderer`) stages it automatically. **Action required for upgraders:** if your `assets_to_copy` config explicitly listed `loadable-stats.json`, remove that entry — otherwise you'll see a "Duplicate asset basenames in assets_to_copy" warning on every stage. The duplicate is harmless (`stage_assets` keeps the last entry per basename), but the warning is noise. [PR 3173](https://github.com/shakacode/react_on_rails/pull/3173) by [justin808](https://github.com/justin808).
4346
- **[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). The auto-invocation at the end of `assets:precompile` defaults to `:symlink` (preserving prior behavior) and now honors `ASSETS_PRECOMPILE_RENDERER_CACHE_MODE=copy|symlink` so Docker builds that run `rake assets:precompile` as the final asset step can opt into copy mode without invoking the rake task separately. `MODE=copy` 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. **Heads-up for custom scripts:** the previous flat layout wrote `$RENDERER_BUNDLE_PATH/<renderer_bundle_file_name>`; any external scripts (health checks, renderer launchers) that read that path directly must now read `$RENDERER_SERVER_BUNDLE_CACHE_PATH/<bundleHash>/<bundleHash>.js` instead. [PR 3124](https://github.com/shakacode/react_on_rails/pull/3124) by [justin808](https://github.com/justin808).
4447
- **[Pro]** **Pro generator now creates the Node Renderer at `renderer/node-renderer.js`**: The canonical location for the Node Renderer entry point is now a dedicated top-level `renderer/` directory instead of `client/`, making it straightforward to exclude from production Docker builds that strip JS sources after bundling. Docs and Pro `spec/dummy` now use the new path consistently. Existing apps are unaffected — the generator skips files that already exist (including a legacy `client/node-renderer.js`). Fixes [Issue 3073](https://github.com/shakacode/react_on_rails/issues/3073). [PR 3165](https://github.com/shakacode/react_on_rails/pull/3165) by [justin808](https://github.com/justin808).
4548
- **[Pro] Documentation standardized on `REACT_RENDERER_URL` env var name**: The configuration example in `docs/oss/configuration/configuration-pro.md` now shows `ENV["REACT_RENDERER_URL"]` instead of the older `ENV["RENDERER_URL"]`, aligning with the rest of the docs and the generator template. Existing apps that read `ENV["RENDERER_URL"]` in their initializer continue to work — the Pro `renderer_url` config is whichever env var the user reads in their initializer; no gem code reads either name directly. Rename the env var in your infrastructure configs (and update the initializer to match) if you want to align with the new convention. `bin/dev` now also warns when `RENDERER_URL` is set without `REACT_RENDERER_URL` so the rename doesn't silently fall back to the default renderer URL. [PR 3142](https://github.com/shakacode/react_on_rails/pull/3142) 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
@@ -171,19 +171,28 @@ RUN bundle exec rake react_on_rails_pro:pre_seed_renderer_cache
171171

172172
### Rolling deploys: seed current and previous bundle hashes
173173

174-
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.
174+
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.
175175

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

178-
- the current server bundle hash
179-
- the previous server bundle hash
180-
- the current and previous RSC bundle hashes as well, if RSC support is enabled
181-
- any required copied assets and RSC manifests in each seeded hash directory
178+
- 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.
179+
- Advertises recent deploys' bundle hashes via `previous_bundle_hashes`.
180+
- Retrieves the bundle + assets for a given historical hash via `fetch`.
182181

183-
`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.
182+
```ruby
183+
# config/initializers/react_on_rails_pro.rb
184+
ReactOnRailsPro.configure do |config|
185+
config.rolling_deploy_adapter = MyApp::S3RollingDeployAdapter
186+
end
187+
```
188+
189+
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.
190+
191+
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.
184192

185193
## Further Reading
186194

195+
- [Rolling-Deploy Adapters](./rolling-deploy-adapters.md) — Protocol spec and reference implementations for `rolling_deploy_adapter`
187196
- [Node Renderer basics](../oss/building-features/node-renderer/basics.md) — Architecture and core concepts
188197
- [JavaScript configuration](../oss/building-features/node-renderer/js-configuration.md) — Node-side config options
189198
- [Error reporting and tracing](../oss/building-features/node-renderer/error-reporting-and-tracing.md) — Monitoring in production

0 commit comments

Comments
 (0)