Fail fast on corrupt seed.zip: validate at upload, no futile retries, terminal 'failed' state (#841)#844
Conversation
…lysis failed - Analysis.seed_zip_error: read-only zip validation (structure + per-entry CRC); used by AnalysesController#upload/#create to reject corrupt seed zips with 422 - Analysis#run_initialization: Zip/Zlib errors are deterministic - fail on first attempt (no 3x retry) and remove partial extraction; transient errors still retry - ResqueJobs::InitializeAnalysis.perform: on failure set job status 'failed' + status_message so the analysis reaches a terminal API-visible state, then re-raise so Resque records the failed job - specs: analysis_init_spec.rb (model/job), analyses_upload_spec.rb (422 contract) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The linux/macos CI jobs already run the full server spec suite via 'openstudio_meta run_rspec', which reproduces the PAT local deployment (delayed_job, local mongo) and writes rspec output to spec/unit-test/logs/rspec.log rather than the CI console. The docker job has not run server model/request specs since Sept 2020 (4958092) - it runs only the docker_stack feature specs. Issue #841 is specific to the resque code path (InitializeAnalysis only runs under resque), so also run the new #841 spec files in the docker job, where RAILS_ENV=docker, resque/redis, and authenticated mongo are in play. They need only rails+mongo, finish in about a second, and leave the database empty, so they run before the feature specs. Verified in a local replica of the CI docker environment (nrel/openstudio-server:develop image + mongo as 'db' + redis as 'queue'): 12 examples, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rking The docker job runs rspec as root inside the web container while the live app serves as an unprivileged user (nobody). The new #841 specs wrote seed zips through paperclip into /mnt/openstudio/server/assets/analyses, leaving that directory root-owned (755). Every later upload from the live app then failed with Errno::EACCES -> HTTP 500, which broke the docker_stack feature specs (custom_gems, 9/10 algo). Leftover factory projects also broke the empty-projects assertion in docker_stack_test_apis_spec. Fix: after(:all) Project.destroy_all in both spec files - paperclip deletes the attachments and prunes the emptied root-owned directory, so the live app recreates it under its own user. Verified in a fresh local replica of the CI docker environment, in CI order (specs first as root, then app-user traffic): 12 examples 0 failures, assets/analyses pruned, projects list empty, valid upload 201 (dir recreated by nobody), corrupt upload 422 through the live nginx/Passenger stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The first docker-job run of the new CI line failed — root-caused and fixed in 3721a72: The 12 new specs themselves passed in the docker (resque) environment, but they broke the feature specs that run after them. Fix: 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Pull request overview
This PR addresses incident #841 by preventing corrupt seed.zip files from entering the analysis pipeline, avoiding futile initialization retries on deterministic ZIP/Zlib failures, and ensuring analyses reach an API-visible terminal failed state when initialization fails (while still letting Resque record the job as failed).
Changes:
- Add deep ZIP validation (
Analysis.seed_zip_error) and return422 Unprocessable Entityduringcreate/uploadwhen a corrupt/truncated/invalid ZIP is detected. - Fail fast on
Zip::Error/Zlib::Errorduring initialization (no retry) and clean up partial extraction directories. - Add Resque job-level rescue handling to mark the analysis/job as
failedbefore re-raising, plus add targeted specs and run them in the docker server test script.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/spec/requests/analyses_upload_spec.rb | Adds request coverage for 422 rejection of corrupt/truncated uploads and 201 acceptance of valid ZIPs. |
| server/spec/models/analysis_init_spec.rb | Adds model/job specs for ZIP validation, fail-fast extraction behavior, cleanup, and terminal failure state propagation. |
| server/app/models/analysis.rb | Introduces seed_zip_error, fail_job!, and fail-fast ZIP/Zlib handling in run_initialization. |
| server/app/jobs/resque_jobs/initialize_analysis.rb | Marks analysis/job failed on initialization exceptions, then re-raises so Resque records failure. |
| server/app/controllers/analyses_controller.rb | Validates uploaded/created seed ZIPs and returns 422 with a descriptive error_message on corruption. |
| docker/server/run-server-tests.sh | Ensures the new model/request specs run in the docker (Resque/Redis/Mongo) CI path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Reject corrupt seed zips at creation time with a 422 instead of letting them fail | ||
| # later in ResqueJobs::InitializeAnalysis and strand the analysis (issue #841) | ||
| if params[:seed_zip].respond_to?(:path) | ||
| zip_error = Analysis.seed_zip_error(params[:seed_zip].path) | ||
| if zip_error | ||
| respond_to do |format| | ||
| format.html { render action: 'new', status: :unprocessable_entity } | ||
| format.json { render json: { status: 'error', error_message: zip_error }, status: :unprocessable_entity } | ||
| end | ||
| return | ||
| end | ||
| end | ||
|
|
There was a problem hiding this comment.
Good catch that this path was broken, though the failure mode is different: there is no new.html.erb view in the app at all (the HTML create path is dead code — analyses are created via the JSON API), so render action: 'new' raises ActionView::MissingTemplate regardless of @analysis. The pre-existing error branch below has the same landmine. Fixed in a8c8855 by rendering a plain-text 422 instead of a template.
| def self.seed_zip_error(file_path) | ||
| # Zip::File.new instead of the Zip::File.open block form: open's implicit close | ||
| # calls commit, which can REWRITE the archive being validated. new reads the | ||
| # central directory and releases the file handle, keeping validation read-only. | ||
| zf = ::Zip::File.new(file_path) | ||
| zf.each do |entry| | ||
| next unless entry.file? | ||
|
|
||
| crc = ::Zlib.crc32 | ||
| entry.get_input_stream do |io| | ||
| while (chunk = io.read(1_048_576)) | ||
| crc = ::Zlib.crc32(chunk, crc) | ||
| end | ||
| end | ||
| return "seed zip entry '#{entry.name}' is corrupt (CRC mismatch)" unless crc == entry.crc | ||
| end |
There was a problem hiding this comment.
Agreed — addressed in a8c8855 with streaming caps counted inside the read loop: SEED_ZIP_MAX_INFLATED_BYTES (10 GB) and SEED_ZIP_MAX_ENTRIES (100k), each returning a descriptive 422-able error when exceeded. No Timeout needed since we control the loop, and no async validation — deferring the check would reintroduce exactly the delayed-failure problem #841 is about. Covered by two new specs using stub_const with tiny caps. Worth noting the pre-existing extraction path in run_initialization still inflates unbounded zips (with a 1-hour timeout) on the web node; bounding that is a separate concern from this PR.
- Analysis.seed_zip_error: cap validation work at SEED_ZIP_MAX_INFLATED_BYTES (10 GB) and SEED_ZIP_MAX_ENTRIES (100k), counted while streaming, so a zip bomb cannot pin a web worker in the request path; 2 new specs via stub_const - AnalysesController#create corrupt-zip path: render plain-text 422 for HTML - 'render action: new' would raise MissingTemplate (no new.html.erb exists) - InitializeAnalysis: bare 'raise' instead of 'raise e' (style only - both preserve the original backtrace when re-raising the same exception object) Verified: 11 model examples locally, 14 total in the CI docker-env replica. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #841.
Problem
During the July 2026 production incident, corrupt
seed.zipuploads produced thousands of failedResqueJobs::InitializeAnalysisjobs, all with"zlib error while inflating". A corrupt zip was accepted at upload, extraction was retried 3 times (a corrupt file never becomes valid on retry), and when the job finally failed the analysis was left inqueued/startedforever with no API-visible failure state and no cleanup —RunAnalysisis only enqueued from anafter_performhook, which Resque skips whenperformraises.One correction to the issue text: a failed Resque job does not block the queue (failed jobs go to the failed list and the worker moves on), and there is no Resque-level retry configured — the "3 times" in the error message is an inline retry loop in
Analysis#run_initialization. So one corrupt upload produces one failed job, and 2641 failed jobs implies roughly that many submissions (likely a client resubmitting). The incident data is worth checking for whether the failed queue held one analysis id or many. The fix targets the real damage: analyses stranded in a non-terminal state, futile retries, and no rejection at upload time.Changes
Reject corrupt zips at upload (422). New
Analysis.seed_zip_error(path)validates zip structure and inflates every entry to verify CRCs.AnalysesController#uploadand#createreturn422with a descriptiveerror_messageinstead of accepting a corrupt file. Validation is strictly read-only: it deliberately avoids theZip::File.openblock form, whose implicitclosecan commit and rewrite the archive being read.Fail fast on corrupt zips during initialization.
Analysis#run_initializationnow rescuesZip::Error/Zlib::Errorseparately: no retries (the failure is deterministic), the partially extracted directory is removed so a later re-run cannot skip-and-reuse stale files, and the error names the analysis. Transient errors keep the existing 3-attempt retry.Terminal, API-visible failure state.
InitializeAnalysis.performnow rescues, calls the newAnalysis#fail_job!(sets the job status tofailedplus a status message), and re-raises so Resque still records the failed job. Analyses no longer sit inqueuedforever;/analyses/:id/status.jsonreportsfailed.failedis a new job-status string;Analysis.status_statesis not referenced by any live code path.Client impact (PAT / OSAF / openstudio-analysis gem)
openstudio_meta run_analysis→OpenStudio::Analysis::ServerApi#new_analysis, which raises on any non-201 upload response, so the meta CLI exits nonzero and PAT reports the failure at submission time, with the server'serror_messagein PAT's console log. Scripted OSAF use of the gem gets the same immediate raise.failedstatus: PAT's run screen stops polling only oncompletedand otherwise displays the raw status string. Afailedanalysis therefore displays as "failed" while polling continues until the user stops it — the same behavior as today's eternally-"queued" stuck analysis, but now the user can see it failed and why (status_message). No string comparison in PAT breaks on the new value. A reasonable PAT follow-up is to also stop polling onfailed.openstudio_meta run_analysisis fire-and-forget, and the gem's remaining status checks are datapoint-level (dp[:status] == 'completed'for report downloads), which this change does not touch.Testing
server/spec/models/analysis_init_spec.rb(9 examples: validator accept/reject including a CRC-mismatch archive that opens and extracts cleanly, fail-fast with no retry plus partial-extraction cleanup, transient-error retry preserved,fail_job!, andInitializeAnalysis.performfailure handling) andserver/spec/requests/analyses_upload_spec.rb(3 examples: 422 for garbage and truncated uploads with descriptive errors, 201 for a valid zip). Red-green verified: with the app changes reverted, 10 of 12 fail (the transient-retry example intentionally passes on the old code).nrel/openstudio-server:developimage, mongo asdb, redis asqueue,RAILS_ENV=docker), and the neighboring existing specs (analysis_spec, requestanalyses_spec) still pass.openstudio_meta run_rspec, which reproduces the PAT local deployment (delayed_job, local mongo) and writes rspec output tospec/unit-test/logs/rspec.lograther than the console — easy to miss when reading CI logs. Both jobs were green on this branch with the new specs included.49580920) — and Enhance InitializeAnalysis resilience for corrupt seed.zip files #841 is specific to the resque path, which only the docker environment exercises. This PR adds the two spec files torun-server-tests.shso the resque/redis/authenticated-mongo environment runs them too (about 1s, before the feature specs, leaves the db empty).0x1Abyte. It passes on Linux/macOS; the validator's acceptance of that exact fixture is also covered directly by a model spec.Deferred (from the issue's proposals)
🤖 Generated with Claude Code