Prevent false worker pruning from leaking concurrency permits#11
Conversation
* Keep stale supervised workers while their supervisor heartbeat is healthy * Make claimed execution finalization own and release permits exactly once * Add regression coverage and document pruning behavior
How the
|
jpcamara
left a comment
There was a problem hiding this comment.
I’ve left the requested review feedback inline, with proposed fixes on each relevant line.
* Split unblock_next_blocked_job into release_concurrency_permit and promote_next_blocked_job so promotion runs after the claim transaction commits, matching the failure isolation from 873760d * Returning the permit inside the claim transaction keeps ownership atomic; promoting outside it means a promotion failure can no longer roll back a job that already finished or failed, and avoids holding the semaphore lock while waiting on the blocked row's lock
* Deregister a supervised process's row as soon as its supervisor reaps it, mirroring Process::Prunable#prune, so a dead child no longer stays permanently protected from pruning just because its supervisor is alive * Update the two existing tests whose expected process counts depended on that dead row lingering * Add non-transactional, multi-connection tests that exercise the actual FOR UPDATE serialization in ClaimedExecution finalization: both race outcomes (pruner wins, performer wins) across finish/fail/release paths, and a use_skip_locked=false regression guarding against the reverse lock order between the semaphore and the blocked row
Confirmed in production (CRM DB load incident, 2026-07-16)Full writeup: https://gist.github.com/briceburg-wb/279f0a66e5223092dbefa845aceeea6e We hit this. Decisive evidence: a limit-1 concurrency key with 9–11 concurrent claims (calendar config 58070). The trigger matches this PR — a live worker whose heartbeat failed ( Two things before this ships:
Also known upstream (rails/solid_queue rails#735, rails#589, rails#748) and unfixed in any release — worth upstreaming the generic fix later, reconciled with the overlapping rails#689. No action on upstream PRs yet. |
test_kill_worker_individually waited only for the killed worker's row to be reaped, then asserted Process.count == 3. The reap and the replacement worker's registration are separate async events, so CI could observe the transient window where the dead row was gone but the replacement had not registered yet (count 2), failing deterministically across the matrix. Wait for the fleet to return to full strength before asserting.
768ac14 to
715fc74
Compare
|
This is a great one! I'm looking into rails#689 now, as I'm planning to incorporate some of the fixes there, but this is a much clearer description and scenario of the same bug class. The corrupted semaphore count is something I hadn't considered. I'll incorporate some of your changes here and will credit you, of course. |
Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, returns its concurrency permit, and promotes a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and returns the same permit a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and returns its permit, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Promoting the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, returns its concurrency permit, and promotes a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and returns the same permit a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and returns its permit, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Promoting the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and releases its concurrency lock, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Releasing the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and releases its concurrency lock, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Releasing the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and releases its concurrency lock, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Releasing the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state. Only the actor that still owns that row deletes the claim and releases its concurrency lock, all in one transaction; a stale performer whose claim was already pruned does no bookkeeping and cannot signal the semaphore again. Releasing the next blocked job happens after that transaction commits, so a failure there can't roll back a job that already ran. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state, so only the actor that still owns that row changes the job's terminal state and deletes the claim. A stale performer whose claim was already finalized finds it gone and does nothing. Unblocking the next job — releasing the concurrency lock and dispatching the next blocked job — happens after that transaction commits, not inside it, so a failure while unblocking can't roll back a job that already finished or failed. Because only the actor that finalized the claim reaches that step, the concurrency lock is still released exactly once. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com>
…leaks Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The failure: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale ClaimedExecution finalizes the job and releases the same lock a second time, pushing the semaphore value above its limit and admitting ever more concurrent work. Make terminal claim ownership exact-once: finishing, failing, and graceful release now lock the claimed-execution row (FOR UPDATE) before changing job state, so only the actor that still owns that row changes the job's terminal state and deletes the claim. A stale performer whose claim was already finalized finds it gone and does nothing. Unblocking the next job — releasing the concurrency lock and dispatching the next blocked job — happens after that transaction commits, not inside it, so a failure while unblocking can't roll back a job that already finished or failed. Because only the actor that finalized the claim reaches that step, the concurrency lock is still released exactly once. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com> Co-Authored-By: Rosa Gutierrez <rosa@37signals.com>
Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The problem: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale `ClaimedExecution`, which exists only in memory, because the DB record was deleted by the supervisor prune, also goes through the semaphore and blocked execution release, potentially violating the limit and allowing more concurrent jobs. With this change, we take a lock on the claimed execution row so that it's required that it still exists and we're the only ones acting over it, before running the finalization code: deleting the claimed execution row, signaling the semaphore and unblocking blocked jobs. In the scenario described above, we'd see the claimed execution record as gone and would do nothing. Note that the semaphore signal + blocked jobs release is still done after the transaction that marks the job as finished/failed, so that these two actions can't rollback a job that finished/failed (see 873760d). Because this can only happen for the ones who got to clear the claimed execution row, we still guarantee the lock is released just once. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com> Co-Authored-By: Tom Blais <tom.blais@wealthbox.com>
Ported from starburstlabs#11, a production-diagnosed fix for a concurrency-limited job whose semaphore count could be permanently corrupted, letting it run well past its configured limit. The problem: a worker stays alive on a long-running job but its database heartbeat fails transiently. Another supervisor prunes it as dead, fails its claimed execution, releases its concurrency lock, and unblocks a replacement — but the original execution keeps running. When it finally returns, its stale `ClaimedExecution`, which exists only in memory, because the DB record was deleted by the supervisor prune, also goes through the semaphore and blocked execution release, potentially violating the limit and allowing more concurrent jobs. With this change, we take a lock on the claimed execution row so that it's required that it still exists and we're the only ones acting over it, before running the finalization code: deleting the claimed execution row, signaling the semaphore and unblocking blocked jobs. In the scenario described above, we'd see the claimed execution record as gone and would do nothing. Note that the semaphore signal + blocked jobs release is still done after the transaction that marks the job as finished/failed, so that these two actions can't rollback a job that finished/failed (see 873760d). Because this can only happen for the ones who got to clear the claimed execution row, we still guarantee the lock is released just once. Co-Authored-By: Brice Burgess <brice.burgess@wealthbox.com> Co-Authored-By: Tom Blais <tom.blais@wealthbox.com>
What does this PR do? (required)
Prevents a stale Solid Queue heartbeat from turning one concurrency-limited execution into multiple overlapping executions and permanently corrupting the semaphore count.
Incident and failure sequence
The production investigation started with
Sync::ExternalCalendar::SyncJob, configured with a concurrency limit of one, running up to 11 times concurrently for the same calendar configuration.One confirmed execution followed this sequence:
SSL error: unexpected eof while reading.process_alive_threshold, another supervisor pruned the worker record as dead.ClaimedExecutionfinalized the job and returned the same permit again.The immediate database/network cause of the SSL EOF remains unknown. This PR instead makes that transient heartbeat failure safe.
Safeguards
Corroborate worker liveness with its supervisor
A supervised worker with a stale heartbeat is no longer pruned while its owning supervisor has a fresh heartbeat. The supervisor already detects actual fork or thread exits directly and fails those claims with
ProcessExitError, which is stronger evidence than a failed database heartbeat.If both worker and supervisor heartbeats become stale, the existing pruning recovery remains available. This intentionally favors preventing concurrent side effects while the owning supervisor is known to be alive.
Make terminal claim ownership exact-once
Finishing, failing, and graceful release now lock the claimed-execution row before changing job state. Only the actor that still owns that row can delete the claim or return its concurrency permit.
Job state changes, claim deletion, semaphore signaling, and blocked-job promotion occur in one transaction. If pruning already removed the claim, the stale in-memory performer performs no terminal bookkeeping and cannot signal the semaphore a second time.
Together, these changes prevent the observed false prune when the supervisor remains healthy and prevent permanent concurrency amplification even if pruning races with a live completion.
Link to Basecamp to-do, Trello card, New Relic or Honeybadger (required)
N/A — production investigation for external calendar configuration
58070.QA
What platforms should be included in QA?
QA steps
Automated regression coverage verifies:
Verification completed:
Screenshots (if appropriate)
N/A
Docs
The process lifecycle documentation now explains that a stale supervised process is protected while its supervisor heartbeat remains fresh.