Skip to content

fix(solid_queue): don't perform the job twice when the guard clause raises - #3

Merged
antoinefink merged 2 commits into
feat/sentry-solid-queuefrom
fix/perform-now-double-execution
Aug 11, 2026
Merged

antoinefink merged 2 commits into
feat/sentry-solid-queuefrom
fix/perform-now-double-execution

Conversation

@antoinefink

Copy link
Copy Markdown

The bug

ActiveJobExtensions#perform_now used a method-level rescue, which in Ruby wraps the entire method body — including its own guard clause:

def perform_now
  return super unless Sentry.initialized? && using_solid_queue_adapter?  # <- also covered

  job_executed = false
  # ...instrumented call...
rescue => e
  raise if job_executed        # job_executed is nil here -> does not fire
  Sentry.sdk_logger.error(...) rescue nil
  super                        # <- performs the job a SECOND time
end

On the early-return path job_executed is still nil. Ruby registers a local from the lexical position of its assignment, so the rescue reads it as nil rather than raising NameError — which is why this failed silently. raise if job_executed never fires, the original exception is swallowed, and the fallback super performs the job a second time on the same instance.

Impact

Whenever the guard is false — any non-solid_queue adapter, or Sentry not initialized:

  • executions reaches 2 for a single invocation
  • side effects run twice: duplicate enqueues, doubled API calls, doubled logs
  • the error that propagates is from the second run, masking the real one. An ActiveJob::Continuable job raises InvalidStepError on the replay, because its step state was already recorded

This hits every ActiveJob test in a host app (the test adapter reports queue_adapter_name == "test") and any environment on a different adapter — e.g. :inline on staging. Production on solid_queue was unaffected, since the guard passes there and the normal path runs.

The fix

Scope the rescue to a begin block around the instrumented section only, so the guard's own super is no longer covered by it. The fallback-to-uninstrumented-super behaviour is preserved for genuine instrumentation failures.

Tests

Adds regression coverage for both early-return paths — Sentry uninitialized, and a non-solid_queue adapter — asserting a raising job is performed exactly once and propagates its own error.

Both new examples fail on the old code with expected: 1, got: 2, and pass with the fix. Full gem suite: 62 examples, 0 failures. RuboCop clean.

Note that the raise_error assertions alone pass either way — only the execution counter catches the duplication. That is precisely why this went unnoticed.

🤖 Generated with Claude Code

antoinefink and others added 2 commits August 6, 2026 13:20
…aises

`ActiveJobExtensions#perform_now` used a method-level `rescue`, which also
covered its own guard clause:

    return super unless Sentry.initialized? && using_solid_queue_adapter?

On that early-return path `job_executed` is still nil — Ruby registers the
local from the lexical position of its assignment, so the rescue reads it as
nil rather than raising NameError. `raise if job_executed` therefore did not
fire: the original exception was swallowed and the fallback `super` performed
the job a *second time on the same instance*.

Observable whenever the guard is false (any non-solid_queue adapter, or Sentry
not initialized):

  - `executions` reaches 2 for a single invocation
  - side effects run twice — duplicate enqueues, doubled API calls and logs
  - the error that finally propagates comes from the *second* run, masking the
    real one; an ActiveJob::Continuable job raises InvalidStepError on the
    replay because its step state was already recorded

This hits every ActiveJob test in a host app, since the test adapter reports
`queue_adapter_name == "test"`, and any environment using a different adapter
(e.g. `:inline` on staging). Production on solid_queue was unaffected.

Scope the rescue to a `begin` block around the instrumented section only, so
the guard's own `super` is no longer covered by it.

Adds regression coverage for both early-return paths — Sentry uninitialized,
and a non-solid_queue adapter — asserting that a raising job is performed
exactly once and propagates its own error. Both new examples fail on the old
code with `expected: 1, got: 2`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`perform_now` is a wrapper, so the invariant that matters is how many times it
calls the wrapped job. The suite asserted almost entirely on outcomes — return
value, error class — which is structurally blind to a duplicate run: an
idempotent job produces an identical outcome either way. That is how the
method-level rescue performed every job twice without failing any of the 62
examples.

Adds counting fixtures and converts the blind assertions:

  - the two "safety rescue" fallback examples used HappyJob and checked only
    `result == "happy"`; they now use CountingHappyJob and assert runs == 1, so
    a fallback that re-runs an already-executed job fails
  - "re-raises job errors even when safety rescue is active" checked only the
    error class; it now asserts the job ran once, so mistaking a job error for
    an instrumentation failure fails
  - adds a fully instrumented raising case (guard passes) asserting one run,
    executions == 1, and exactly one Sentry event

These new assertions pass with and without the perform_now fix — the guard-true
paths were never affected by that bug. They are forward-looking guards: a future
change that breaks `job_executed` on the normal path would otherwise go
unnoticed, exactly as the original defect did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@antoinefink
antoinefink merged commit af613a7 into feat/sentry-solid-queue Aug 11, 2026
112 of 141 checks passed
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.

1 participant