Skip to content

Commit 7537fc2

Browse files
justin808claude
andcommitted
Polish rolling deploy review follow-ups (round 4)
Address claude[bot] re-review on PR #3173 (six inline + summary items). Comments and clarifying notes (no behavior change): - replace_bundle_directory: document the brief between-mv unavailability window, the kernel-atomic fast path, and the cross-FS slow path so the trade-off is explicit at the call site. - sweep_stale_temporary_directories: explain the no-op-on-missing-cache_dir behavior is desired for Docker rebuilds but skips one cycle on persistent-volume deploys where the cache_dir was wiped in between. - valid_required_rsc_payload?: note that this only checks basename presence; existence is validated downstream by valid_asset_payload?. - handle_missing_adapter: explicit `return nil` after warn so the caller's `return handle_missing_adapter` semantics don't depend on warn's value. - docs/pro/rolling-deploy-adapters.md: new "When upload runs" section documenting that staging/qa/preview/etc. all publish to the adapter and pointing operators at a per-env-initializer escape hatch. Behavior changes (defensive): - seed_previous_hash: explicit FileUtils.mkdir_p(staging_dir) before the first stage_previous_file call so a permission error surfaces with "Failed to seed previous bundle hash" attribution instead of being side-effect-created downstream by copy_file_atomically. New tests: - TEMPORARY_DIRECTORY_PATTERN consistency: assert it matches both temporary_bundle_directory output AND the .previous-<pid>-<hex> backup suffix produced by replace_bundle_directory, guarding against a future rename of either suffix drifting the sweep regex. - configuration: regression spec for kwarg-only upload signature `def upload(bundle:, assets:)` (no positional bundle_hash) — should be rejected. - assets_precompile: assert publish_current_bundle_if_configured *does* publish in non-dev/non-test envs (staging, production, qa, preview, custom), guarding the skip list from being widened by accident. All 33 rolling_deploy_cache_stager_spec, 92 configuration+assets_precompile spec examples pass. RuboCop clean on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0835a1 commit 7537fc2

5 files changed

Lines changed: 83 additions & 1 deletion

File tree

docs/pro/rolling-deploy-adapters.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ PREVIOUS_BUNDLE_HASHES=abc123,def456 rake react_on_rails_pro:pre_seed_renderer_c
9595

9696
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.
9797

98+
## When `upload` runs
99+
100+
`assets:precompile` invokes `upload` for the current build's bundle hashes whenever an adapter is configured **and** `Rails.env` is anything other than `development` or `test`. That includes `staging`, `production`, custom envs like `qa` or `preview`, and any other non-dev/non-test value.
101+
102+
In practice this means a `staging` deploy hits the same artifact store as production — it must have the same credentials and write access. This is intentional: a `staging`-→-`staging` rolling deploy needs the previous staging hash seeded, and a `staging`-→-`production` promotion benefits from staging having warmed the store. If you need to keep `staging` out of the artifact store entirely, set `config.rolling_deploy_adapter = nil` in a `staging`-specific initializer rather than relying on env-based skipping at the gem level.
103+
98104
## Edge cases and error handling
99105

100106
| Scenario | Behavior |

react_on_rails_pro/lib/react_on_rails_pro/rolling_deploy_cache_stager.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,15 @@ def self.call(cache_dir:, current_hashes:, mode:)
6666

6767
def self.handle_missing_adapter
6868
env_override = ENV["PREVIOUS_BUNDLE_HASHES"].to_s.strip
69-
return if env_override.empty?
69+
return nil if env_override.empty?
7070

7171
# PREVIOUS_BUNDLE_HASHES overrides *discovery*; the adapter is still required
7272
# to fetch the actual bundle files. Refuse to proceed rather than raise a raw
7373
# NoMethodError on the nil adapter.
7474
warn "[ReactOnRailsPro] PREVIOUS_BUNDLE_HASHES=#{env_override.inspect} is set but no " \
7575
"rolling_deploy_adapter is configured. Rolling-deploy seeding requires both. " \
7676
"Set config.rolling_deploy_adapter to enable. Skipping previous-hash seeding."
77+
nil
7778
end
7879
private_class_method :handle_missing_adapter
7980

@@ -120,6 +121,10 @@ def self.seed_previous_hash(adapter, hash, cache_dir, mode)
120121

121122
bundle_dir = bundle_directory(cache_dir, hash)
122123
staging_dir = temporary_bundle_directory(bundle_dir)
124+
# Create the staging dir explicitly so a permission error here surfaces with a
125+
# clear "Failed to seed previous bundle hash" attribution rather than as a
126+
# downstream copy/symlink failure inside `stage_previous_file`.
127+
FileUtils.mkdir_p(staging_dir)
123128
stage_previous_file(
124129
payload[:bundle],
125130
File.join(staging_dir, "#{hash}.js"),
@@ -230,6 +235,11 @@ def self.warn_non_file_asset_payload(hash, non_file_assets)
230235
end
231236
private_class_method :warn_non_file_asset_payload
232237

238+
# Only checks that the required RSC basenames *appear* in the payload's asset
239+
# list. Existence and file-ness of those paths on disk are validated downstream
240+
# by `valid_asset_payload?`, which attributes any missing required RSC files
241+
# via `warn_missing_asset_payload`. Splitting the two passes lets each warning
242+
# attribute the failure mode (contract gap vs. dangling path) accurately.
233243
def self.valid_required_rsc_payload?(asset_paths, hash)
234244
missing = required_rsc_asset_basenames - asset_paths.map { |path| File.basename(path) }
235245
return true if missing.empty?
@@ -286,6 +296,13 @@ def self.stage_file(src, dest, mode, log_prefix)
286296
end
287297
private_class_method :stage_file
288298

299+
# No-ops when `cache_dir` does not exist yet. On Docker-style deploys (cache
300+
# rebuilt from an immutable image layer each release) that's the desired
301+
# behavior. On persistent-volume deploys where the cache survives across
302+
# releases but `cache_dir` was wiped between runs, orphaned `.staging-*` /
303+
# `.previous-*` dirs from a prior run would re-appear when the volume is
304+
# remounted under a fresh, empty `cache_dir` — they'll be swept on the next
305+
# successful seeding pass once `cache_dir` is recreated, not this one.
289306
def self.sweep_stale_temporary_directories(cache_dir)
290307
return unless Dir.exist?(cache_dir)
291308

@@ -348,6 +365,15 @@ def self.bundle_directory(cache_dir, hash)
348365
end
349366
private_class_method :bundle_directory
350367

368+
# There is a brief window between the two `mv` calls below where `bundle_dir`
369+
# does not exist on disk. A renderer lookup for this hash during that window
370+
# would miss the cache and fall back to the runtime 410-retry path — a single
371+
# cold-start, not a correctness regression. On Linux/same-filesystem deploys
372+
# `File.rename` is atomic at the kernel level, so the window is sub-millisecond.
373+
# On cross-filesystem or NFS mounts, `FileUtils.mv` falls back to copy+delete
374+
# and the window can widen to seconds. The trade-off is intentional: this
375+
# design favors full-replacement atomicity (no half-staged dir ever observed)
376+
# over zero-downtime swap, since the 410-retry fallback bounds the worst case.
351377
def self.replace_bundle_directory(staging_dir, bundle_dir)
352378
backup_dir = nil
353379
if File.exist?(bundle_dir)

react_on_rails_pro/spec/dummy/spec/rolling_deploy_cache_stager_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,4 +563,20 @@ def source_file(name, contents: "// #{name}")
563563
expect(described_class::DISCOVERY_TIMEOUT_SECONDS).to eq(10)
564564
end
565565
end
566+
567+
# Guards against a future rename of the temp/backup suffixes drifting out of
568+
# sync with TEMPORARY_DIRECTORY_PATTERN — that would silently break the sweep.
569+
describe "TEMPORARY_DIRECTORY_PATTERN" do
570+
let(:bundle_dir) { "/tmp/cache/abc123" }
571+
572+
it "matches temporary_bundle_directory output" do
573+
staging_dir = described_class.send(:temporary_bundle_directory, bundle_dir)
574+
expect(File.basename(staging_dir)).to match(described_class::TEMPORARY_DIRECTORY_PATTERN)
575+
end
576+
577+
it "matches the .previous-<pid>-<hex> backup suffix produced by replace_bundle_directory" do
578+
backup_basename = "abc123.previous-#{Process.pid}-#{SecureRandom.hex(6)}"
579+
expect(backup_basename).to match(described_class::TEMPORARY_DIRECTORY_PATTERN)
580+
end
581+
end
566582
end

react_on_rails_pro/spec/react_on_rails_pro/assets_precompile_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,21 @@ def upload(*); end
383383
end
384384
end
385385

386+
# Documents the intentional behavior that any non-dev/non-test env (staging,
387+
# production, qa, preview, custom envs) publishes to the configured adapter.
388+
# Guards against the skip list being widened by accident — staging deploys
389+
# need to seed the artifact store so the next staging-→-staging or
390+
# staging-→-production rolling deploy can pre-seed the previous hash.
391+
it "publishes in environments other than development and test (e.g. staging)" do
392+
allow(adapter).to receive(:upload)
393+
%w[staging production qa preview anything-else].each do |env_name|
394+
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new(env_name))
395+
described_class.publish_current_bundle_if_configured
396+
end
397+
398+
expect(adapter).to have_received(:upload).with("abc123", bundle: server_bundle, assets: []).exactly(5).times
399+
end
400+
386401
it "warns and continues when upload times out" do
387402
stub_const("ReactOnRailsPro::AssetsPrecompile::UPLOAD_TIMEOUT_SECONDS", 0.05)
388403
allow(adapter).to receive(:upload) { sleep 1 }

react_on_rails_pro/spec/react_on_rails_pro/configuration_spec.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,25 @@ def self.upload(_hash, _options); end
239239
end.to raise_error(ReactOnRailsPro::Error, /upload\(bundle_hash, bundle:, assets:\)/)
240240
end
241241

242+
# Regression: `accepts_bundle_hash_argument?` rejects signatures that have
243+
# zero positional parameters even when the required upload keywords are
244+
# present. A plausible mistake is to swap the positional `bundle_hash` for
245+
# a `bundle_hash:` keyword; the adapter would still raise at upload time
246+
# because the stager passes `bundle_hash` positionally.
247+
it "rejects kwarg-only upload signatures with no positional bundle_hash" do
248+
adapter = Class.new do
249+
def self.previous_bundle_hashes = []
250+
def self.fetch(_hash) = nil
251+
def self.upload(bundle:, assets:) = [bundle, assets]
252+
end
253+
254+
expect do
255+
ReactOnRailsPro.configure do |config|
256+
config.rolling_deploy_adapter = adapter
257+
end
258+
end.to raise_error(ReactOnRailsPro::Error, /upload\(bundle_hash, bundle:, assets:\)/)
259+
end
260+
242261
it "rejects adapters that require extra positional upload arguments" do
243262
adapter = Class.new do
244263
def self.previous_bundle_hashes = []

0 commit comments

Comments
 (0)