You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix: address PR C review feedback for rolling_deploy_adapter
MUST-FIX (2):
- **Nil adapter + PREVIOUS_BUNDLE_HASHES bug** (greptile P1, claude):
when the env override was set without config.rolling_deploy_adapter,
the stager proceeded past the early-return guard and crashed with
NoMethodError at adapter.fetch. Now warns and returns instead.
Doctor promotes the corresponding check from :info to :warning and
explains that both env and adapter are required.
- **RSC previous-bundle staging path** (codex P2): the renderer routes
RSC requests by rsc_bundle_hash, which differs from server_bundle_hash
and is a separate <cache>/<hash>/<hash>.js entry. The original
protocol merged both bundles under a single hash's directory using
basenames, so the RSC bundle never landed where the renderer looks.
Fix: protocol refactor — each hash is now one bundle's cache entry.
fetch(hash) → { bundle: path, assets: [paths] }
upload(hash, bundle:, assets:)
previous_bundle_hashes → flat list including server AND rsc hashes
AssetsPrecompile.publish_current_bundle_if_configured now calls
upload twice when RSC is enabled (once per hash). Stager stages each
hash at <cache>/<hash>/<hash>.js with its companion assets.
SHOULD-FIX (4):
- **Timeout on adapter.fetch** (claude): wrap with Timeout.timeout
(default 30s) so a hung external store can't block pre-seeding or
assets:precompile indefinitely.
- **Relative symlinks** (greptile P2): stage_file in :symlink mode now
uses the same Pathname#realpath + relative_path_from pattern as
PreSeedRendererCache.make_relative_symlink, matching current-hash
staging and surviving cache-dir moves.
- **Remove stray module_function** (claude): was paired with
private_class_method (an unusual combination that leaks private
instance methods to any include-r). Switched to `def self.` for all
methods, private_class_method for helpers.
- **respond_to? instead of methods.include?** (claude): more idiomatic
duck-typing in both Configuration.validate_rolling_deploy_adapter
and Doctor.report_adapter_protocol.
DOC POLISH (3):
- Control Plane reference impl: switched from backtick/system shell
interpolation to Open3.capture2e with array-form args to avoid
shell injection via env-var contents. Returns both server AND RSC
hashes via previous_bundle_hashes.
- Both S3 and Control Plane examples: lazy env-var accessor methods
instead of constants evaluated at require time, so KeyError can't
fire at class-load in environments that don't configure the adapter.
- S3 reference impl: explicit note about the read-modify-write race
in update_manifest!; documents serializing deploys or using
If-Match/ETag as strict-safety alternatives.
- Updated protocol docstrings + all reference impls (S3, Control Plane,
Filesystem) to the new fetch/upload signatures.
Tests: 13 rolling-deploy specs now (was 7) — adds nil-adapter+env
warning, fetch timeout, relative-symlink assertion. Doctor spec
updated for the warn-vs-info change. 38 Pro specs pass, 167/168
doctor specs pass (1 pre-existing unrelated failure).
Refs: #3122, #3167 (PR B), #3173 (this PR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/pro/rolling-deploy-adapters.md
+79-40Lines changed: 79 additions & 40 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -28,33 +28,48 @@ If the renderer handles a request for bundle `abc` but reads the **new** build's
28
28
29
29
## Protocol
30
30
31
+
Each **bundle hash** is a single cache entry. A deploy that has both a server bundle and an RSC bundle contributes **two** hashes — `server_bundle_hash` and `rsc_bundle_hash`. The protocol is opaque about which kind of bundle a hash represents; the adapter just stores and retrieves files keyed by hash.
32
+
31
33
Your adapter must define three class methods:
32
34
33
35
```ruby
34
36
moduleMyRollingDeployAdapter
35
37
# Discovery. Called during pre-seeding to determine which historical
36
38
# hashes to fetch. Typically hits the running deployment's /_health
37
39
# endpoint or reads a manifest file in the artifact store.
40
+
#
41
+
# When RSC is enabled, this should return BOTH the server and RSC
42
+
# hashes for each deploy you want to seed — the stager treats every
43
+
# hash as an independent cache entry at <cache>/<hash>/<hash>.js.
44
+
#
38
45
#@return[Array<String>] ordered list of recent bundle hashes.
39
46
#@return [] to disable previous-bundle seeding on this build.
40
47
defself.previous_bundle_hashes
41
48
# ...
42
49
end
43
50
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.
51
+
# Retrieval. Given a bundle hash, fetch the bundle file + its
52
+
# companion assets to local disk and return their paths.
53
+
#
54
+
#@return[Hash, nil] Hash with keys :bundle (String path to the
55
+
# bundle file, required) and :assets (Array<String> of companion
56
+
# asset paths like loadable-stats.json / RSC manifests). nil if
57
+
# the bundle is unavailable — pre-seeding logs a warning and
58
+
# continues.
59
+
#
60
+
# Fetch is wrapped in Timeout.timeout(30s) to protect pre-seeding
61
+
# and assets:precompile from hanging on slow external stores.
49
62
defself.fetch(bundle_hash)
50
63
# ...
51
64
end
52
65
53
66
# Publication. Called automatically after assets:precompile in
54
67
# 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.
This runs the adapter's `fetch(hash)` for each listed hash but skips discovery.
91
+
This runs the adapter's `fetch(hash)` for each listed hash but skips discovery. The adapter is still required to fetch the actual bundle files; setting the env var without configuring `config.rolling_deploy_adapter` produces a warning and skips seeding.
77
92
78
93
## Edge cases and error handling
79
94
@@ -93,21 +108,30 @@ These are copy-pasteable starting points. Adapt to your infrastructure.
93
108
94
109
### S3
95
110
96
-
Publish bundles + companion assets under `s3://<bucket>/bundles/<hash>/`. A manifest file at `bundles/_manifest.json` tracks the rolling list of recent hashes.
111
+
Stores each bundle + its companion assets under `s3://<bucket>/bundles/<hash>/bundle.js` + `s3://<bucket>/bundles/<hash>/<asset>`. A manifest file at `bundles/_manifest.json` tracks the rolling list of recent hashes.
112
+
113
+
> [!NOTE]
114
+
> The manifest update is a read-modify-write cycle with no native concurrency guard. Concurrent deploys can lose entries (last writer wins). For strict safety, use S3 conditional writes (`If-Match` with ETag) or a small coordination layer (e.g., a deploy-level mutex, or a database row with optimistic locking). The pattern below is intentionally simple and sufficient when deploys are serialized.
97
115
98
116
```ruby
99
117
require"aws-sdk-s3"
100
118
require"fileutils"
101
119
require"json"
102
120
103
121
classS3RollingDeployAdapter
104
-
BUCKET=ENV.fetch("ROLLING_DEPLOY_BUCKET")
122
+
# Lazy accessors — env vars are read when first used, not at require time.
123
+
# This avoids KeyError at class-load when the bucket isn't configured
124
+
# in dev/test/CI environments that don't use the adapter.
125
+
defself.bucket
126
+
ENV.fetch("ROLLING_DEPLOY_BUCKET")
127
+
end
128
+
105
129
PREFIX="bundles"
106
130
MANIFEST_KEY="#{PREFIX}/_manifest.json".freeze
107
-
RETENTION=3
131
+
RETENTION=6# keep last ~3 deploys' worth (2 hashes per deploy when RSC is enabled)
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
196
197
+
The deploy pipeline is expected to set two env vars on the running workload: `REACT_ON_RAILS_BUNDLE_HASH` (server bundle hash) and, when RSC is enabled, `REACT_ON_RAILS_RSC_BUNDLE_HASH`. Both are returned from `previous_bundle_hashes` so the stager can seed each independently.
198
+
199
+
Uses `Open3.capture2e` with array-form arguments rather than shell interpolation to avoid injection via env-var contents.
200
+
175
201
```ruby
202
+
require"json"
203
+
require"open3"
204
+
require"fileutils"
205
+
176
206
classControlPlaneRollingDeployAdapter
177
-
GVC=ENV.fetch("CPLN_GVC")
178
-
WORKLOAD=ENV.fetch("CPLN_RAILS_WORKLOAD")
207
+
# Lazy accessors — see S3 adapter note on KeyError at require time.
208
+
defself.gvc
209
+
ENV.fetch("CPLN_GVC")
210
+
end
211
+
212
+
defself.workload
213
+
ENV.fetch("CPLN_RAILS_WORKLOAD")
214
+
end
179
215
180
216
defself.previous_bundle_hashes
181
-
output =`cpln workload get #{WORKLOAD} --gvc #{GVC} -o json`
217
+
output, status =Open3.capture2e("cpln", "workload", "get", workload, "--gvc", gvc, "-o", "json")
0 commit comments