Skip to content

Fix unique-job lock leak in inline testing mode - #173

Closed
lovitt wants to merge 2 commits into
keypup-io:masterfrom
castolonco:fix/inline-unique-job-upstream
Closed

Fix unique-job lock leak in inline testing mode#173
lovitt wants to merge 2 commits into
keypup-io:masterfrom
castolonco:fix/inline-unique-job-upstream

Conversation

@lovitt

@lovitt lovitt commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Problem

In inline mode (Cloudtasker::Testing.inline!), a unique job runs only the first time it's enqueued — later enqueues of the same job are silently rejected.

lock_for_scheduling! (added in 0.15.0) takes a provisional lock, yields to enqueue the job, then takes the final lock. That final lock is meant to be released later, when the job runs — the execute middleware unlocks it.

But in inline mode the job runs during the yield, so it unlocks before the final lock is even taken. The final lock is then set after the job has already finished, leaving it with no future run to release it. It lingers for the full lock_ttl and rejects later enqueues of the same job (on_conflict: :reject). 0.14.0 did a plain lock!; yield, so this is a regression.

Cloudtasker::Testing.inline! do
  TestUniqueJobWorker.perform_async(1, 2)
  TestUniqueJobWorker.perform_async(1, 2)
end
# before: [[1, 2]]            (second rejected)
# after:  [[1, 2], [1, 2]]

Fix

Skip the final-lock step in inline mode, where the job has already run and released its lock. The async/production path is unchanged: inline_mode? is guarded by defined?(Cloudtasker::Testing), which is false in production (same idiom as CloudTask.backend and Backend::MemoryTask.inline_mode?).

Tests

Integration spec (both inline enqueues run) and unit spec (lock not promoted to the final TTL).

The provisional/final lock split added in 0.15.0 assumes the block
passed to `lock_for_scheduling!` only enqueues the job. Under inline
testing mode (`Cloudtasker::Testing.inline!`) the block also executes
the job synchronously, which releases the job's lock via the execute
middleware. Step 3 then re-acquires a "final" lock that nothing will
ever release, so a subsequent enqueue of the same unique job is
wrongly rejected for the full lock TTL.

Skip the final-lock step under inline execution: the job has already
run and released its lock by the time scheduling returns. The async
(production) path is unchanged.

Adds an integration spec (two successive inline enqueues of the same
unique job both run) and a unit spec (the lock is not promoted to the
final TTL in inline mode).
@alachaum

Copy link
Copy Markdown
Member

Hey @lovitt,

Thanks for the PR ❤️ I need to do some testing/investigations on my side. I bet this issue also impacts the new perform_now logic as well.

To avoid future extension issues with inline! or perform_now, we probably need to generalise the approach and make both methods go through both the client and server middlewares - rather than doing tactical approaches for each extension.

@alachaum

Copy link
Copy Markdown
Member

Hi @lovitt

I just opened this PR, which addresses the problem in a more general and permanent manner: #176

It would be great if you could give it a go (I incorporated your specs anyway) and let me know if it looks better ☺️

inline! is meant to run an enqueued job as the real server would. These
specs pin three properties that hold when inline execution happens at
the universal backend point (Backend::MemoryTask.create), through which
every enqueue path funnels:

- Execution coverage: perform_async, perform_at/perform_in, the instance
  #schedule method, and batch children (enqueued via #schedule) all run.
- Error hooks: a raising job triggers the on_error hook, like the server
  path does via WorkerHandler.with_worker_handling.
- Context fidelity: the worker runs with a task_id, as assigned by the
  backend and read by app logging/instrumentation.

Only perform_async had integration coverage; the batch spec drains a
fake! queue manually rather than running inline!. Pinning these guards
against a refactor that relocates inline execution onto a narrower path.
@lovitt

lovitt commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for looking into this @alachaum. I ran the #176 branch against our app's test suite. The unique-job lock leak is fixed, but a batch of tests that pass on 0.15.0 fail.

The failures are related to tests that exercise scheduled jobs, batches, and error hooks and worker context provided by with_worker_handling.

I pushed some regression specs in 6778d9a which pass on #173 but fail on #176.

@alachaum

alachaum commented Jul 13, 2026

Copy link
Copy Markdown
Member

Hey @lovitt, thanks a lot for the tests; this was really useful 🙏

I have incorporated these tests in my PR and added a new commit that addresses these issues. My original work was a bit too focused on perform_async and did not take into account the execution logic of the handler.

I have reworked things to:

  • Rewire Worker#schedule (used by all perform_* methods) to #perform_now in inline! test mode
  • Make #perform_now invoke the execution logic of WorkerHandler to follow the exact execution path that a server-processed job would follow

Your test suite should now be green, but don't hesitate to let me know if you still see red tests.

Note: I'm trying to define a single interface perform_now that works for both manual executions (e.g., Rails console) and inline! tests. If this ends up being too much for a single method, I'll split it up.

@lovitt

lovitt commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@alachaum looks good! Our full test suite passes now on this revision.

@lovitt lovitt closed this Jul 13, 2026
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.

2 participants