Skip to content

Harden RunSimulateDataPoint worker against permanent lock deadlock and stale-job re-dispatch#845

Merged
brianlball merged 3 commits into
developfrom
fix/run-simulate-data-point-hardening
Jul 17, 2026
Merged

Harden RunSimulateDataPoint worker against permanent lock deadlock and stale-job re-dispatch#845
brianlball merged 3 commits into
developfrom
fix/run-simulate-data-point-hardening

Conversation

@anchapin

@anchapin anchapin commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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_lock released its flock on failure but never deleted the lock file, and never wrote a receipt. The waiter loop in initialize_worker only 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 full initialize_worker_timeout (8h default), and then, due to a second bug, fell through to return true anyway: reporting fake success and letting perform proceed 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 in ensure (minor fd leak).
  • initialize_worker's receipt-wait loop: detect lock_holder_gone (lock file disappears without a receipt appearing) and return false immediately instead of looping to timeout. On genuine Timeout::Error, explicitly delete the stale lock file and return false instead of the old fall-through return true.
  • Zip extraction retry: add rescue ::Zip::Error, ::Zlib::Error ahead of the generic retry rescue — fail fast on deterministic corruption (no futile 3x retry), remove the partial analysis_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_point typo fix (5 occurrences: 3 in dj_jobs/run_simulate_data_point.rb, 2 in dj_jobs/urban_opt.rb, which is included into the same class): the invalid-timeout fallback branches referenced an uninitialized class variable instead of the instance variable, since ebc2361b (2019). Confirmed also present on origin/develop. Previously any invalid timeout value raised NameError instead of applying the intended 28800s default.
  • resque_jobs/run_simulate_data_point.rb#perform: added a narrow analysis.run_flag == false guard (checked ahead of the existing completed-status guard) to skip stale queue payloads for analyses explicitly stopped/quarantined via stop_analysis/soft_stop_analysis. Deliberately not a blanket status == 'completed' check — that would break the admin manual-retry workflow, since requeue/requeue_started don't reset status before re-enqueueing.

Testing

Added 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 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.:

  • old fall-through logic returns true after a real Timeout::Error with the lock file still present — spec expecting false + lock gone would fail against it.
  • old write_lock (flock-release only) leaves the lock file on disk after the block raises — spec expecting it gone would fail against it.
  • old 3-retry rescue triggers a 2nd extract_archive call — expect(...).to receive(:extract_archive).once raises MockExpectationError against it.
  • old @@data_point reference raises NameError (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's 28800 readback behavior is correct.

No project-wide test suite/linter run (per repo convention, left to CI).

Not in scope here

@brianlball

Copy link
Copy Markdown
Contributor

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 return true after rescue Timeout::Error, lock file surviving write_lock failure, @@data_point never assigned anywhere), then ran this PR's specs against develop's app code with a local mongo: 10/14 fail on unfixed code, every core claim reproducing. Against this branch's app code: green on Linux CI, 12/14 on Windows — which surfaced the flaw below.

Finding: the cleanup deleted files whose fds were still open

Windows cannot unlink an open file, and FileUtils.rm_f/rm_rf swallow that failure silently:

  1. write_lock's rescue ran FileUtils.rm_f lock_file_path before the ensure closed the fd — so on Windows the stale lock survives and the 8h deadlock this PR fixes remains, silently. Desktop/PAT-on-Windows is exactly the deployment write_lock's own comment says it protects ("threadsafe for non-docker deployments, i.e. desktop usage").
  2. The corrupt-zip rescue ran FileUtils.rm_rf(analysis_dir) while still inside the write_lock block — the lock fd (inside analysis_dir) is open at that point, so the directory survives on Windows.

Repro: spec/models/dj_run_simulate_data_point_hardening_spec.rb:127 and :173 fail on Windows against 5ab3176.

Fix (7c10e4c)

  • write_lock rescue: unlock + close the fd, then rm_f; the ensure only closes if the rescue hasn't already.
  • Corrupt-zip rescue: sweep the extracted content but leave the (still-open) lock file for write_lock to delete; raise a typed CorruptAnalysisZip (same message, so the spec assertions hold) and let a new initialize_worker-level rescue remove the emptied analysis_dir after the lock is gone. Dir.rmdir rather than rm_rf there: if another worker has already re-locked and begun re-populating the directory, rmdir fails harmlessly instead of deleting its in-progress files.

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 run_simulate_data_point.rb (the @@data_point upload-timeout fix sits inside #847's upload_file rewrite) — whichever merges second will need a small mechanical rebase.

🤖 Generated with Claude Code

brianlball added a commit that referenced this pull request Jul 16, 2026
…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>
@brianlball
brianlball requested a review from Copilot July 16, 2026 22:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_flag dispatch 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.

Comment on lines +567 to +568
@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}"
Comment on lines +433 to +436
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
Comment on lines +165 to +169
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*'))
anchapin and others added 3 commits July 16, 2026 19:18
…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>
@brianlball
brianlball force-pushed the fix/run-simulate-data-point-hardening branch from 7c10e4c to 8314b34 Compare July 16, 2026 23:27
@brianlball

Copy link
Copy Markdown
Contributor

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 run_flag guard met the re-enabled resque_run_simulation_data_point_spec from #847 — and the spec caught a genuine bug: run_flag defaults to false on every new analysis, so the guard couldn't distinguish "ops stopped this analysis" from "nobody has started it yet". Any datapoint submitted directly against a fresh analysis (batch upload + submit_simulation, a real API/PAT flow) was silently skipped (SKIPPING ... run_flag=false in the job log; status_message stayed empty).

Fix (8314b34): treat run_flag == false as "stopped" only when the analysis has a start_time (its jobs exist iff it was started). The hardening spec's stopped-context now creates the Job a genuinely stopped analysis would have, and there's a new regression example for the never-started case.

Copilot review dispositions

  • Timeout-path lock deletion (accepted, extended): correct catch — a live-but-slow holder can outlive a single waiter timeout (each holder step gets its own initialize_worker_timeout), and deleting its lock lets a third worker initialize the same analysis_dir concurrently on a fresh inode. Implemented the suggested flock(LOCK_EX|LOCK_NB) probe: the timeout path now only clears locks nobody holds. Bonus: the same probe in the wait loop (two consecutive positives, one poll apart, so a holder's open→flock startup gap can't misread) detects SIGKILL/OOM-killed holders immediately — the incident's actual failure mode, which rescue-based cleanup can never see — instead of after the full 8h.
  • @intialize_worker_errs rename (declined): cosmetic; not worth spreading the diff. Can be a standalone cleanup later.
  • Corrupt-zip fixture byte-flip (declined): the concern doesn't hold. String#index finds the local file header (it always precedes the central directory), rubyzip 2.3's OutputStream writes no extra field, so the flip lands deterministically in the DEFLATE stream. And the failure direction is backwards: if the fixture ever failed to corrupt, the .once message expectation and the error-text assertions fail loudly — there's no silent pass.

Verification on the rebased branch

  • Hardening specs: 15/15 (Windows local + Linux container).
  • Full docker-compose stack: resque_run_simulation_data_point_spec 2/2 (the exact CI failure, now green) and dj_run_simulation_data_point_spec 7/7.

🤖 Generated with Claude Code

@brianlball
brianlball merged commit 821fcf7 into develop Jul 17, 2026
5 checks passed
@brianlball
brianlball deleted the fix/run-simulate-data-point-hardening branch July 17, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants