Repository navigation
Conversation
ABS-issued multi-VM ondemand requests were occasionally being marked 'failed' and having their VMs destroyed even when all requested VMs had already finished cloning. Root cause was two independent, racing code paths with no shared lock: - the ondemand_provisioner thread's periodic sweep (check_ondemand_requests_ready -> request_expired?), and - the per-VM pending-check thread (_check_pending_vm -> move_pending_vm_to_ready), spawned fresh per VM per check cycle. Both read/write the same vmpooler__odrequest__<id> status and the vmpooler__provisioning__processing zset with only a per-VM mutex (vm_mutex) protecting VM state, not the request as a whole. This fixes it three ways: 1. In check_ondemand_request_ready, check vms_ready? before request_expired?, so a request that just became fully provisioned is marked ready instead of being killed by a same-tick expiry check. 2. Guard request_expired? against a nil score. redis.zscore returns nil once an entry is removed from the processing set; nil.to_i is 0, which previously produced a multi-decade "elapsed" delta that trivially exceeded any configured ondemand_request_ttl. 3. Add a per-request_id mutex (request_mutex, mirroring the existing vm_mutex pattern) around the critical sections in both check_ondemand_request_ready and move_pending_vm_to_ready, so the expiry sweep and a VM's completion callback can no longer interleave on the same request.
| end | ||
|
|
||
| def request_mutex(request_id) | ||
| @request_mutex[request_id] || @request_mutex[request_id] = Mutex.new |
There was a problem hiding this comment.
This pattern of creating mutexes is racy. It's possible for calls from two threads to interleave:
th1: @request_mutex[request_id] #=> nil
th2: @request_mutex[request_id] #=> nil
th1: @request_mutex[request_id] = Mutex.new
th2: @request_mutex[request_id] = Mutex.new
Threads 1 and 2 would then synchronize on different mutexes, breaking mutual exclusion, and thread 2 would overwrite thread 1's mutex in the hash.
Suggest using:
| @request_mutex[request_id] || @request_mutex[request_id] = Mutex.new | |
| @request_mutex.compute_if_absent(request_id) { Mutex.new } |
Could you change pool_mutex and vm_mutex too?
There was a problem hiding this comment.
Good catch, thanks — you're right that this is exactly the kind of race this PR is meant to fix, and I'd introduced a new instance of it. Fixed in 283e57f: switched pool_mutex, vm_mutex, and request_mutex to compute_if_absent.
One adjustment: compute_if_absent isn't available on Concurrent::Hash under MRI — it's just a plain Hash subclass there and only gets real atomic operations under JRuby's native ConcurrentHashMap backing (confirmed this app's CI/spec suite runs under MRI while the deployed image runs JRuby, so it needs to work on both). Switched the backing collections (@reconfigure_pool, @vm_mutex, @request_mutex) from Concurrent::Hash to Concurrent::Map, which provides compute_if_absent consistently on both runtimes. Full spec suite (452 examples) still passes.
joshcooper pointed out that request_mutex (and the pre-existing pool_mutex/vm_mutex it was modeled on) had a check-then-act race: @request_mutex[request_id] || @request_mutex[request_id] = Mutex.new Two threads can both observe nil for the same key before either writes, ending up synchronized on two different Mutex objects for the same request_id/vm/pool - silently defeating mutual exclusion, which is exactly the class of bug this PR exists to fix. Switched pool_mutex, vm_mutex, and request_mutex to the atomic compute_if_absent. That method isn't available on Concurrent::Hash under MRI (it's just a Hash subclass there, only gaining real atomic ops under JRuby's native backing), so also switched the backing collections from Concurrent::Hash to Concurrent::Map, which provides compute_if_absent consistently on both the JRuby runtime this app deploys on and the MRI Ruby the test suite runs under.
There was a problem hiding this comment.
🟡 Changes recommended
The on-demand completion path recursively acquires a non-reentrant mutex and raises ThreadError.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds request-level synchronization and safer expiry handling for on-demand VM provisioning.
Changes:
- Checks readiness before expiration and handles missing Redis scores.
- Adds per-request mutexes around provisioning transitions.
- Updates regression tests for readiness and expiry behavior.
File summaries
| File | Description |
|---|---|
lib/vmpooler/pool_manager.rb |
Adds synchronization and revises request completion logic. |
spec/unit/pool_manager_spec.rb |
Tests readiness ordering and nil-score handling. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
move_pending_vm_to_ready already holds request_mutex(request_id) for the whole request_id branch, then called check_ondemand_request_ready at the end of that same branch - which immediately tried to synchronize on the exact same mutex. Ruby's Mutex is not reentrant, so this raised ThreadError: deadlock; recursive locking on every single on-demand VM that reached the ready state, i.e. the happy path for every successful on-demand provision. Split check_ondemand_request_ready into: - check_ondemand_request_ready: acquires request_mutex(request_id), used by callers that don't already hold it (the ondemand_provisioner sweep). - check_ondemand_request_ready_locked: the actual logic, with no locking of its own, for callers that already hold the lock (move_pending_vm_to_ready). Added a regression test that calls move_pending_vm_to_ready for real (no stubbing of the ready-check) and asserts it completes without raising - verified this test fails with ThreadError against the prior shape of the code, and passes now.
There was a problem hiding this comment.
🟡 Changes recommended
The central concurrency fix lacks a deterministic test exercising the competing threads.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
mutual exclusion between move_pending_vm_to_ready and the expiry sweep The previous regression test only proved the recursive-locking deadlock was gone; it passed even with the request_mutex wrap removed entirely from move_pending_vm_to_ready, because the earlier reordering fix (check vms_ready? before request_expired?) alone was enough to save that specific scenario once the vm was already added to the fulfillment set - the lock's own contribution wasn't actually being exercised. This test instruments both call sites' shared critical section (check_ondemand_request_ready_locked) directly and, using Queue-based thread coordination (no sleep/timing-based coordination for the deterministic parts): - pauses move_pending_vm_to_ready mid-critical-section (still holding request_mutex) right after it has added the vm to the fulfillment set but before marking the request ready - starts the sweep concurrently and proves it cannot enter its own critical section within a generous 0.3s window while the lock is held (Timeout-bounded check, since Queue#pop's timeout: keyword isn't available on the jruby target this runs under in CI) - tracks concurrent entries into the shared critical section and asserts they never exceed 1 - asserts the final state is correct (request ready, vm running, not destroyed) Verified this test fails cleanly and quickly (no hang) when the request_mutex wrap is temporarily removed from move_pending_vm_to_ready - the blocked/entered assertion reports :entered instead of :blocked - and passes with the fix restored.
There was a problem hiding this comment.
🟡 Changes recommended
Mutex cleanup can create multiple active locks for one request, defeating the new synchronization guarantee.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
request_mutex used the same compute_if_absent + delete pattern as vm_mutex/pool_mutex, but deleting a request_id's entry once it goes terminal (ready/failed) is unsafe here in a way it inherently isn't for a bug-free vm_mutex/pool_mutex: a caller that already fetched the old Mutex object (e.g. blocked on it, or about to synchronize) is unaffected by the map entry being deleted, but a *later* caller for the same request_id, finding no entry, gets handed a brand new Mutex - letting both callers enter the request's critical section concurrently. This directly reintroduces the class of race this PR exists to fix, and is realistic here: a multi-VM request's sibling VM completion threads and the expiry sweep are exactly the concurrent callers this lock is meant to serialize. Replaced the growable per-request_id map with a fixed-size array of 256 pre-allocated mutexes (request_id hashed to a stripe). Nothing is ever created or destroyed per-request, so there's no map entry to go stale and no deletion race is possible; unrelated request_ids occasionally sharing a stripe just adds incidental contention, not a correctness issue. Removed dereference_request_mutex entirely along with both call sites. Verified: full suite (455 examples) still passes, rubocop clean, and the two-thread concurrency test still fails cleanly (:entered instead of :blocked, no hang) when the request_mutex wrap is temporarily removed from move_pending_vm_to_ready under this new implementation, confirming it still actually enforces mutual exclusion.
Reviewing my own diff with fresh eyes surfaced several things worth fixing before this goes further: - Guard against resurrecting a terminal request. check_ondemand_request_ready_locked's ready branch unconditionally wrote status='ready'. The ondemand delete endpoint sets 'deleted' *without* removing the request from the processing set, so the sweep keeps visiting it. It isn't currently exploitable (the delete also dels the fulfillment sets, so vms_ready? is false), but that's load-bearing coincidence and reordering readiness ahead of expiry made the branch more reachable. Now bails out early on failed/deleted and zrems the request so it stops being revisited - which also preserves the old cleanup behaviour, since previously such requests were only dropped from the set by being rewritten to 'failed'. - Document the lock ordering. _check_pending_vm holds vm_mutex while calling move_pending_vm_to_ready, which takes request_mutex, so the order is vm_mutex -> request_mutex. There's no reverse path today, but remove_vms_for_failed_request destroys VMs without their vm_mutex and "add vm_mutex there" is a plausible future change that would deadlock. Recorded the constraint. - Document the scope limitation. request_mutex is an in-process Mutex: it does not coordinate with the api process (which mutates request status and VM queues in the delete endpoint) and provides no protection if the manager runs more than one replica. - Warn on check_ondemand_request_ready_locked itself that it requires the lock; previously that note only lived on the other method. - Move REQUEST_MUTEX_STRIPE_COUNT up to the class's constant block. - Note in the concurrency test that its 0.3s window is deliberately one-sided (can under-report a regression, can never invent one), and that max_active only covers the _locked call while the :blocked assertion covers the rest of the critical section. Verified the six new terminal-status tests all fail with the guard removed. Full suite: 461 examples, 0 failures; rubocop clean.
|
I went back over this PR as a reviewer rather than an author, and pushed f85ba68 for what came out of that. I've also rewritten the PR description, which was stale (it still described only the original three changes and cited the wrong test count). Fixed in f85ba68:
Two things I'd rather you decide than quietly decide myself (both now in the description):
Also flagging that Full suite: 461 examples, 0 failures; rubocop clean. Each new test verified to fail against the unfixed code. |
There was a problem hiding this comment.
🟡 Changes recommended
Cross-process status resurrection remains possible, and the intended TTL semantics require resolution.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
lib/vmpooler/pool_manager.rb:2462
- The terminal-status guard and this write are separate Redis operations, so the API can set
deletedafter the guard/readiness checks and this line can overwrite it withreadyafter the API has already moved the VMs tocompleted. The in-process mutex cannot prevent that cross-process TOCTOU race (or the same race between manager replicas). Make the status transition conditional/atomic in Redis and coordinate the associated request-set/queue mutations so a terminal status cannot be resurrected.
redis.hset(ondemand_hash_key, 'status', 'ready')
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
| # Check readiness before expiry: a request that just became fully | ||
| # provisioned must not be killed by a concurrent expiry sweep landing | ||
| # on the same tick. | ||
| if vms_ready?(request_id, redis) |
Problem
ABS-issued on-demand requests are intermittently marked
'failed'— and their VMs destroyed — even when the VMs had already finished provisioning.Traced in production (vmpooler 3.9.1) across several occurrences. Representative case, a 4-VM request:
Elapsed-to-failure across occurrences was 167s / 357s / 378s — nowhere near a stable TTL boundary, and
vmpooler_runningwas well underabs_capacity_aware_scheduler_capacity_maximumthroughout, so this is a race rather than genuine capacity or timeout exhaustion.Root cause
Two unsynchronized code paths act on the same on-demand request:
ondemand_provisionerthread's periodic expiry sweep (check_ondemand_requests_ready→request_expired?)_check_pending_vm→move_pending_vm_to_ready), spawned as a freshThread.newper VM per cycleBoth read/write
vmpooler__odrequest__<id>andvmpooler__provisioning__processing. The only existing lock (vm_mutex) is per-VM, so nothing prevented these from interleaving on the samerequest_id.Changes
check_ondemand_request_ready. Previously expiry was evaluated first, so a request that had just become fully provisioned could still be killed by a sweep landing on the same tick.request_expired?against anilscore.redis.zscorereturnsnilonce a request leaves the processing set, andnil.to_iis0, sodelta = Time.now.to_i - 0produced a multi-decade "elapsed" value that exceeded any configured TTL regardless of the request's real age.request_mutex) around the critical sections in bothcheck_ondemand_request_readyandmove_pending_vm_to_ready. Split into a lock-acquiring entry point and a lock-free_lockedvariant, sincemove_pending_vm_to_readyalready holds the lock when it reaches the readiness check and Ruby'sMutexis not reentrant.request_idmap. Acompute_if_absent+deletemap risks handing two callers two differentMutexobjects for the samerequest_id(one already holding a reference to the deleted entry, a later one creating a fresh entry), which would defeat the guarantee entirely. A fixed 256-entry array needs no creation or deletion.failed/deletedand drops the request from the processing set.pool_mutex/vm_mutex/request_mutexto atomiccompute_if_absent(per review), which required moving the backing collections fromConcurrent::HashtoConcurrent::Map—Concurrent::Hashis a plainHashsubclass under MRI and only gains atomic operations under JRuby's native backing, and this needs to work on both.1.
request_mutexis an in-processMutex, and the state it guards is not process-local.bin/vmpoolerrunsapiandmanageras separately deployable modes, and they are deployed separately (the manager pod's args are["manager"]). The ondemand delete endpoint mutates the very same state from the api process, non-atomically and unsynchronized:The race actually observed in production was manager-internal (sweep vs. VM completion threads), so this fix does address the reported bug. But it follows that (a) a class of cross-process races remains unaddressed, and (b) the fix silently stops working if the manager is ever scaled beyond one replica, and nothing in the code or chart enforces that. A fully general fix would need redis-level atomicity (Lua,
WATCH/MULTI, or a redis lock). I've documented the limitation in the code but would rather you decide whether that's acceptable here or whether the replica count should be constrained.2. Preferring readiness over expiry changes behaviour for late-completing requests.
Before: an expired request was failed and its VMs destroyed promptly. After: if all VMs are present, it is fulfilled regardless of age. For a request that overshoots its TTL and then completes, ABS has already given up (its own client-side timeout), so those VMs are marked ready for a request nobody is polling and only get reaped by normal
vm_lifetime— so capacity lingers rather than being reclaimed immediately. I think not destroying VMs that actually succeeded is the better default, but it is a deliberate trade-off and could reasonably be bounded to a grace window instead of applied unconditionally.Testing
bundle exec rspec spec/unit/pool_manager_spec.rb spec/integration/api/v3/ondemandvm_spec.rb→ 461 examples, 0 failuresbundle exec rubocop lib/vmpooler/pool_manager.rb→ no offensesThreadError: deadlock; recursive lockingwith the_lockedsplit revertedmove_pending_vm_to_readymid-critical-section and proves the sweep cannot enter its own; reports:enteredinstead of:blockedwith therequest_mutexwrap removedcheck_ondemand_request_readyandrequest_expired?levelsAn earlier version of the concurrency test passed even with the lock entirely removed (the readiness reorder alone was carrying it); that's why the current one instruments the shared critical section directly rather than asserting on end state.
Also noted, not fixed here
vm_mutex/dereference_mutexhas the same delete-then-recreate hazard described in change 4 above. Left alone deliberately — it's pre-existing and used throughout the VM lifecycle rather than just the on-demand path this PR touches, so it seemed better as a separate change.