Harden RunSimulateDataPoint worker against permanent lock deadlock and stale-job re-dispatch#845
Conversation
|
Reviewed and verified this locally — the bugs are real and the fix is sound, with one Windows-specific flaw I've fixed in a follow-on commit (7c10e4c) pushed to this branch. Verification (red-green against unfixed develop)Checked all three claims directly in develop source (fall-through Finding: the cleanup deleted files whose fds were still openWindows cannot unlink an open file, and
Repro: Fix (7c10e4c)
All 14 hardening specs now pass on Windows (were 12/14); Linux behavior and spec assertions unchanged. Heads-up: this PR and #847 touch neighboring hunks in 🤖 Generated with Claude Code |
…shot (#849) * Pin R package installs to a dated CRAN snapshot (Posit Package Manager) install_and_verify pins the packages it names, but their dependencies resolved from live CRAN. A mid-day CRAN publish on 2026-07-16 (ggrepel requiring ggplot2 >= 3.5.2/gtable >= 0.3.6) broke rserve image rebuilds that had passed hours earlier, plus transient cloud.r-project.org download failures. Resolve everything from the 2026-07-15 snapshot: same inputs every build until the date is bumped deliberately. The __linux__/jammy path serves prebuilt binaries for the base image (Ubuntu 22.04 + R 4.4) - install_packages.R drops from ~480s to ~100s - with the plain source snapshot as fallback. Verified locally: docker build of docker/R completes, 13/13 packages install and load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CI: skip the rserve rebuild on PRs that do not touch docker/R Every docker job rebuilt the rserve image from scratch: BuildKit (Docker 28 default) does not use pulled images as layer cache, so the docker-compose pull that used to make the build a cache no-op under the classic builder stopped helping. That cost ~10 min per run and exposed every PR to CRAN availability (see 2026-07-16 failure on #845's run). - PR runs build only web unless the PR changes docker/R (two-commit tree diff against the fetched base tip - works on the shallow merge-ref clone; any fetch/diff failure falls back to building everything). The pulled nrel/openstudio-rserve:latest, kept fresh by docker-upload on develop/master pushes, backs the stack instead - push runs still build everything (docker-upload deploys those images) - compose build config gains cache_from + BUILDKIT_INLINE_CACHE so images pushed from develop carry layer-cache metadata and can seed the builds that do still happen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-setup-steps.yml file specifying one with the runs-on attribute. See the docs for more details.
Hardens DjJobs::RunSimulateDataPoint and its Resque dispatcher against stale lock/receipt deadlocks, false-success fallthrough after timeouts, and futile retries on deterministically corrupt analysis zips (plus fixes a long-standing @@data_point typo that prevented timeout defaults from being applied).
Changes:
- Add lock/receipt wait-loop safeguards (detect missing lock holder, delete stale locks on real timeout, and fail instead of falling through to success).
- Fail fast on corrupt zip extraction (
Zip::Error/Zlib::Error) with cleanup and a typed exception for targeted handling. - Add/expand regression specs to prevent deadlock reintroduction and to validate the new Resque
run_flagdispatch guard.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| server/app/jobs/dj_jobs/run_simulate_data_point.rb | Implements lock/receipt deadlock hardening, corrupt-zip fail-fast path, and fixes @@data_point timeout assignments. |
| server/app/jobs/resque_jobs/run_simulate_data_point.rb | Adds analysis.run_flag == false guard to prevent stale queue payloads from dispatching new workers. |
| server/app/jobs/dj_jobs/urban_opt.rb | Fixes @@data_point typo so workflow timeouts are set on the actual instance analysis. |
| server/spec/models/dj_run_simulate_data_point_hardening_spec.rb | Adds regression coverage for abandoned locks, timeouts, corrupt zip extraction, and the timeout typo fix. |
| server/spec/models/resque_run_simulate_data_point_hardening_spec.rb | Adds regression coverage for the new Resque run_flag dispatch guard behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @sim_logger.error "Error in initialize_worker in #{__FILE__} with message #{e.message}; #{e.backtrace.join("\n")}" | ||
| @intialize_worker_errs << "#{e.message}; #{e.backtrace.first}" |
| rescue ::Timeout::Error | ||
| @sim_logger.error "Required analysis objects were not retrieved after #{@data_point.analysis.initialize_worker_timeout} seconds; clearing the stale lock so a future worker does not wait on it." | ||
| FileUtils.rm_f write_lock_file | ||
| return false |
| content = File.binread(path) | ||
| comp_start = content.index('data/seed.txt') + 'data/seed.txt'.length | ||
| bytes = content.bytes | ||
| 20.times { |k| bytes[comp_start + k] = 0x00 } | ||
| File.binwrite(path, bytes.pack('C*')) |
…d stale-job re-dispatch Root cause of 2026-07-07/08 production incident: 50+ analyses with corrupted seed zips caused `write_lock` (dj_jobs/run_simulate_data_point.rb) to release its flock on failure but never delete the lock file, and never write a receipt. The waiter loop only polled for the receipt file and never rechecked the lock, so every subsequent worker pod for the same analysis blocked forever on `initialize_worker`, waiting out the full 8h `initialize_worker_timeout` and then (due to a second bug) falling through to `return true` anyway -- reporting fake success and letting `perform` proceed to simulate against a directory that was never populated. Fleet-wide this produced ~9,988 permanently-zombied worker pods (99.6% of the tier). Fixes: - `write_lock`: rescue any exception in the protected block, delete the lock file before re-raising (previously only released the flock, leaving a file a future worker could stat as "still locked"). Also closes the file handle in `ensure` (minor fd leak). - `initialize_worker`'s receipt-wait loop: detect `lock_holder_gone` (lock file disappears without a receipt appearing -- the crash/failure signature) and return `false` instead of looping to timeout. On genuine `Timeout::Error`, explicitly delete the stale lock file and return `false` instead of falling through to `return true`. - Add a `rescue ::Zip::Error, ::Zlib::Error` branch ahead of the generic retry rescue in the zip-extraction path: fails fast on deterministic corruption (no futile 3x retry), removes the partial `analysis_dir`. Worker-side counterpart to the web-node fail-fast added for seed.zip validation in #841/#844 (a different, once-per-analysis code path; this is the per-datapoint, per-worker-pod download+extract path). - Fix `@@data_point` -> `@data_point` (uninitialized class variable vs the actual instance variable) in the invalid-timeout fallback branches: 3 occurrences in `dj_jobs/run_simulate_data_point.rb`, 2 more in `dj_jobs/urban_opt.rb` (included into the same class). Bug present since ebc2361 (2019), also confirmed on origin/develop. Previously any invalid timeout value raised NameError instead of applying the 28800s default. - `resque_jobs/run_simulate_data_point.rb#perform`: add a narrow `analysis.run_flag == false` guard (checked ahead of the existing completed-status guard) to skip stale queue payloads for analyses that were explicitly stopped/quarantined via `stop_analysis`/ `soft_stop_analysis`. Deliberately not a blanket `status == 'completed'` check, which would break the admin manual-retry workflow (`requeue`/ `requeue_started` do not reset status before re-enqueueing). Adds `server/spec/models/dj_run_simulate_data_point_hardening_spec.rb` (7 examples) and `server/spec/models/resque_run_simulate_data_point_hardening_spec.rb` (7 examples), all executed against a real local Mongo/Redis and passing. Each also has a companion "would fail against the pre-fix code" check to confirm discriminating power.
The deadlock fix deleted files whose handles were still open, which works on Linux but is a silent no-op on Windows (FileUtils.rm_f/rm_rf swallow the EACCES) - and desktop/PAT-on-Windows is the deployment write_lock's own comment says it exists for. Found by running the new hardening specs on Windows: 12/14, with the write_lock-raise and corrupt-zip-cleanup examples failing exactly this way. - write_lock rescue: unlock + close the fd, then rm_f; ensure only closes if the rescue has not already done so - corrupt-zip rescue (runs inside write_lock, lock fd open): sweep the extracted content but leave the lock file for write_lock to delete; raise typed CorruptAnalysisZip (same message) and let initialize_worker's new rescue rmdir the emptied analysis_dir after the lock is gone. Dir.rmdir, not rm_rf: if another worker already re-locked and started re-populating the dir, rmdir fails harmlessly instead of deleting its in-progress files All 14 hardening specs pass on Windows (were 12/14) against a local mongo; message and behavior assertions unchanged for Linux. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e clearing locks Two fixes surfaced by the merge-ref CI run (develop now includes the re-enabled run_simulation feature specs) and the Copilot review: 1. run_flag defaults to false and only flips true when an analysis is started, so guarding on run_flag alone skipped every datapoint submitted directly against a fresh analysis (batch upload + submit_simulation - the resque feature spec's flow, and a real API path). Require a start_time (jobs exist iff the analysis was started) before treating run_flag=false as "stopped". Hardening spec's stopped context now creates the Job a genuinely stopped analysis would have, plus a regression example for the never-started case. 2. The timeout path deleted the lock file unconditionally, but a holder that is alive-but-slow can legitimately outlive a single waiter timeout (each of its steps gets its own initialize_worker_timeout); deleting a live holder's lock lets a third worker initialize the same analysis_dir concurrently. Probe the flock (LOCK_EX|LOCK_NB) and only clear locks nobody holds. The same probe in the wait loop (two consecutive positives, a poll apart) now detects SIGKILL/OOM-killed holders immediately - the incident's failure mode, which the rescue-based cleanup can never see - instead of after the full 8h. Verified: hardening specs 15/15 on Windows + Linux-container; resque feature spec 2/2 and dj 7/7 against the full docker-compose stack on the rebased branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7c10e4c to
8314b34
Compare
|
Rebased onto develop (post-#847/#849: single CI run per push, no rserve rebuild since this PR doesn't touch docker/R) and pushed two more commits. Summary of what happened and what changed: Why the docker re-run failed (real regression, not flaky)The re-run executed the merge ref (this branch + current develop), which was the first time the new Fix (8314b34): treat Copilot review dispositions
Verification on the rebased branch
🤖 Generated with Claude Code |
Summary
Production incident (2026-07-07/08): 50+ analyses with corrupted seed zips triggered a permanent deadlock in the worker-side lock/receipt coordination in
DjJobs::RunSimulateDataPoint, wedging ~9,988 worker pods (99.6% of the worker tier) into an infinite wait. Root-caused and fixed here; not related to (but complementary to) the seed-zip validation fix in #841/#844, which covers a different, once-per-analysis web-node code path (Analysis#run_initialization). This fix targets the per-datapoint, per-worker-pod download+extract path (initialize_worker/write_lock).Root cause
write_lockreleased itsflockon failure but never deleted the lock file, and never wrote a receipt. The waiter loop ininitialize_workeronly polled for the receipt file appearing — it never rechecked whether the lock file itself had disappeared (holder crashed) — so every subsequent worker for the same analysis blocked for the fullinitialize_worker_timeout(8h default), and then, due to a second bug, fell through toreturn trueanyway: reporting fake success and lettingperformproceed to simulate against a directory that was never populated.Changes
write_lock: rescue any exception in the protected block, delete the lock file before re-raising (previously only released the flock — leaving a stat-able "still locked" file behind). Also closes the file handle inensure(minor fd leak).initialize_worker's receipt-wait loop: detectlock_holder_gone(lock file disappears without a receipt appearing) and returnfalseimmediately instead of looping to timeout. On genuineTimeout::Error, explicitly delete the stale lock file and returnfalseinstead of the old fall-throughreturn true.rescue ::Zip::Error, ::Zlib::Errorahead of the generic retry rescue — fail fast on deterministic corruption (no futile 3x retry), remove the partialanalysis_dir. Worker-side counterpart to Enhance InitializeAnalysis resilience for corrupt seed.zip files #841/Fail fast on corrupt seed.zip: validate at upload, no futile retries, terminal 'failed' state (#841) #844's fail-fast, applied to the different per-datapoint download path.@@data_point→@data_pointtypo fix (5 occurrences: 3 indj_jobs/run_simulate_data_point.rb, 2 indj_jobs/urban_opt.rb, which isincluded into the same class): the invalid-timeout fallback branches referenced an uninitialized class variable instead of the instance variable, sinceebc2361b(2019). Confirmed also present onorigin/develop. Previously any invalid timeout value raisedNameErrorinstead of applying the intended 28800s default.resque_jobs/run_simulate_data_point.rb#perform: added a narrowanalysis.run_flag == falseguard (checked ahead of the existing completed-status guard) to skip stale queue payloads for analyses explicitly stopped/quarantined viastop_analysis/soft_stop_analysis. Deliberately not a blanketstatus == 'completed'check — that would break the admin manual-retry workflow, sincerequeue/requeue_starteddon't reset status before re-enqueueing.Testing
Added
server/spec/models/dj_run_simulate_data_point_hardening_spec.rb(7 examples) andserver/spec/models/resque_run_simulate_data_point_hardening_spec.rb(7 examples). All 14 executed against a real local Mongo/Redis and passed. Each behavior also has a companion check that reconstructs the pre-fix code path in isolation to confirm the new assertions would have failed against the old bug (discriminating power), e.g.:trueafter a realTimeout::Errorwith the lock file still present — spec expectingfalse+ lock gone would fail against it.write_lock(flock-release only) leaves the lock file on disk after the block raises — spec expecting it gone would fail against it.extract_archivecall —expect(...).to receive(:extract_archive).onceraisesMockExpectationErroragainst it.@@data_pointreference raisesNameError(uninitialized class variable, never assigned anywhere in the codebase) rather than silently applying an invalid timeout — differs from the incident hypothesis but confirms the bug is real and the fix's28800readback behavior is correct.No project-wide test suite/linter run (per repo convention, left to CI).
Not in scope here