|
| 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. |
0 commit comments