Skip to content

Prevent false worker pruning from leaking concurrency permits#11

Merged
tblais1224 merged 4 commits into
mainfrom
fix/prevent-false-prune-concurrency-leak
Jul 17, 2026
Merged

Prevent false worker pruning from leaking concurrency permits#11
tblais1224 merged 4 commits into
mainfrom
fix/prevent-false-prune-concurrency-leak

Conversation

@tblais1224

Copy link
Copy Markdown

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:

  1. The worker remained alive and continued processing a long-running calendar sync.
  2. Its heartbeat repeatedly failed while establishing a PostgreSQL connection with SSL error: unexpected eof while reading.
  3. After the heartbeat passed process_alive_threshold, another supervisor pruned the worker record as dead.
  4. Pruning failed the claimed execution, returned its concurrency permit, and promoted a replacement job, but it did not stop or fence the original Ruby execution.
  5. The original execution continued for approximately eight minutes after pruning. When it returned, its stale ClaimedExecution finalized the job and returned the same permit again.
  6. Each duplicate permit return admitted more work. Graceful worker replacement then re-readied the leaked claimed population without reacquiring permits, preserving the concurrency leak across deploys.

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?

  • Desktop web
  • Mobile web
  • iOS
  • Android
  • API
  • N/A

QA steps

Automated regression coverage verifies:

  1. A stale supervised worker and its claimed execution remain registered while its supervisor heartbeat is healthy.
  2. A stale worker is still pruned when its supervisor is also stale.
  3. A stale performer returning after pruning cannot mark the failed job finished, promote an additional blocked job, or return another semaphore permit.
  4. Existing worker-exit, graceful-release, failure, and semaphore behavior remains intact.

Verification completed:

  • Full SQLite suite: 254 runs, 1,493 assertions, 0 failures, 4 skips.
  • Focused PostgreSQL suite: 30 runs, 173 assertions, 0 failures.
  • Focused MySQL suite: 30 runs, 173 assertions, 0 failures.
  • RuboCop: 169 files inspected, no offenses.

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.

* 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
Comment thread app/models/solid_queue/process/prunable.rb
@tblais1224

Copy link
Copy Markdown
Author

How the ClaimedExecution changes work

The central idea is that the database claim row is now the ownership token for terminal job bookkeeping. Holding an old ClaimedExecution Ruby object is no longer enough to finish or fail a job, return its concurrency permit, or promote blocked work.

The previous race

Before this change, pruning and normal completion could both return the same permit:

job A is running; jobs B and C are blocked
  → A's heartbeat becomes stale
  → the pruner fails A, deletes its claim, returns A's permit, and promotes B
  → A's Ruby thread is still alive and eventually returns
  → `perform`'s unconditional `ensure` returns A's permit again and promotes C

This was possible because the database claim had been deleted, but the original worker still held an in-memory ClaimedExecution object. The old ensure did not verify whether that execution still owned anything.

perform no longer signals unconditionally

app/models/solid_queue/claimed_execution.rb:64

def perform
  result = execute

  if result.success?
    finished
  else
    failed_with(result.error)
    raise result.error
  end
end

Removing unblock_next_job from ensure means completion must go through either finished or failed_with. Both paths now verify claim ownership before performing terminal bookkeeping.

Success and failure share one finalization path

app/models/solid_queue/claimed_execution.rb:88

def failed_with(error)
  finalize_claim do
    job.failed_with(error)
  end
end

app/models/solid_queue/claimed_execution.rb:102

def finished
  finalize_claim do
    job.finished!
  end
end

Both delegate to:

app/models/solid_queue/claimed_execution.rb:108

def finalize_claim
  with_claim do
    yield
    destroy!
    job.unblock_next_blocked_job
  end
end

This puts the complete terminal transition in one transaction:

  1. Verify and lock ownership of the claim.
  2. Mark the job finished or failed.
  3. Delete the claim.
  4. Return the semaphore permit and promote one blocked job.

If any step raises, the transaction rolls back. We no longer commit claim deletion separately from semaphore signaling.

with_claim makes ownership exact-once

app/models/solid_queue/claimed_execution.rb:116

def with_claim
  transaction do
    return false unless self.class.unscoped.lock.find_by(id: id)

    yield
  end
end

The lookup issues a SELECT ... FOR UPDATE against the claimed-execution row. If a performer and pruner race, only one can lock and finalize the claim:

winner locks claim → updates job → deletes claim → returns permit → commits
loser resumes       → claim no longer exists       → returns false → does nothing

find_by is deliberate because a missing claim is an expected lost-ownership result, not an application error.

unscoped is also deliberate. fail_all_with loads claims using includes(:job), and PostgreSQL rejects FOR UPDATE when Active Record carries that outer join into the lock query. The unscoped lookup locks only the solid_queue_claimed_executions row whose ownership matters.

Bulk failure no longer signals separately

app/models/solid_queue/claimed_execution.rb:39

executions.each do |execution|
  execution.failed_with(error)
end

failed_with now records the failure, deletes the claim, and returns the permit atomically. The caller no longer performs a second, separate unblock_next_job operation.

Graceful release also checks ownership

app/models/solid_queue/claimed_execution.rb:75

with_claim do
  job.dispatch_bypassing_concurrency_limits
  destroy!
end

The concurrency bypass remains intentional: a gracefully released job already owns a permit, so making it reacquire that same occupied permit could deadlock it. The new protection is that redispatch only happens if the claim row still exists. If pruning or completion already won, the stale release does nothing.

What this fixes—and what it does not

This prevents a stale execution from:

  • changing a pruned job from failed to finished;
  • returning the same concurrency permit twice;
  • promoting another blocked job;
  • turning one false prune into a persistent concurrency leak.

It cannot stop application work already running in the original Ruby thread. That is why this PR also changes process pruning: a stale supervised worker is protected while its owning supervisor remains healthy. The pruning change avoids starting the first replacement in the observed failure mode; the ClaimedExecution change prevents semaphore amplification if pruning and completion still race.

@tblais1224
tblais1224 marked this pull request as ready for review July 10, 2026 20:24
@tblais1224 tblais1224 self-assigned this Jul 10, 2026
@tblais1224
tblais1224 requested a review from jpcamara July 10, 2026 20:24

@jpcamara jpcamara left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I’ve left the requested review feedback inline, with proposed fixes on each relevant line.

Comment thread app/models/solid_queue/claimed_execution.rb Outdated
Comment thread app/models/solid_queue/claimed_execution.rb Outdated
Comment thread app/models/solid_queue/process/prunable.rb
Comment thread test/models/solid_queue/claimed_execution_test.rb
* 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
@briceburg-wb

briceburg-wb commented Jul 16, 2026

Copy link
Copy Markdown

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 (SSL error: unexpected eof while reading) was pruned as dead, its permit returned twice, and a deploy re-readied the leaked claims via dispatch_bypassing_concurrency_limits without reacquiring permits.

Two things before this ships:

  1. This is preventive, not curative. It stops new leaks but doesn't repair the already-corrupted claims/semaphores from the incident — that needs a one-time reconcile after this deploys, or the bad state persists. (Runbook in the gist.)
  2. CI isn't green. Merge state is blocked and the matrix has failures — including forked_processes_lifecycle_test (expects 3 processes, sees 2 after replacement), which exercises exactly this change. Needs to be green before we pin crm-web to it.

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.
@briceburg-wb
briceburg-wb force-pushed the fix/prevent-false-prune-concurrency-leak branch from 768ac14 to 715fc74 Compare July 16, 2026 20:55
@tblais1224
tblais1224 merged commit f347058 into main Jul 17, 2026
112 of 133 checks passed
@rosa

rosa commented Jul 21, 2026

Copy link
Copy Markdown

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.

rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa added a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
…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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 21, 2026
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>
rosa pushed a commit to rails/solid_queue that referenced this pull request Jul 23, 2026
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>
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.

4 participants