Skip to content

fix(queue): restore BackgroundQueue later(), fix after-commit handling#378

Merged
binaryfire merged 14 commits into0.4from
fix/deferred-background-queue-later
May 1, 2026
Merged

fix(queue): restore BackgroundQueue later(), fix after-commit handling#378
binaryfire merged 14 commits into0.4from
fix/deferred-background-queue-later

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

Follow-up to #377 for the renamed 0.4 queue classes. 0.4 renamed CoroutineQueue / DeferQueue to Laravel-compatible BackgroundQueue / DeferredQueue; this finishes the delayed-dispatch work for those names and fixes a few related queue package issues I found while reviewing it.

Main changes

BackgroundQueue::later()

BackgroundQueue now has a real later() method. Before this, it inherited SyncQueue::later(), which just calls push() and ignores the delay.

The new implementation:

  • schedules through Timer::after()
  • handles after-commit before scheduling the timer
  • calls executeJob() directly from the timer callback instead of going back through push()
  • clamps negative delays to 0, so later(-5, $job) runs immediately

DeferredQueue::later()

DeferredQueue::later() now respects after-commit jobs. If the job should run after commit, the timer is scheduled inside the transaction callback, so the delay starts after commit instead of at dispatch time.

Worker shutdown

Timer::after() callbacks can also run when the worker is closing. BackgroundQueue and DeferredQueue now check $isClosing and skip delayed jobs during shutdown.

These queues are still in-memory/best-effort. Use Redis, database, or SQS for durable jobs.

push() cleanup

BackgroundQueue and DeferredQueue no longer override push(). They inherit SyncQueue::push() and only override executeJob():

  • BackgroundQueue uses Coroutine::create()
  • DeferredQueue uses Coroutine::defer()

This keeps after-commit and unique-job rollback handling in one place. It also fixes the case where a job using both ShouldBeUnique and after-commit could leave its unique lock behind on transaction rollback.

Unique rollback helper

Moved the repeated ShouldBeUnique rollback callback code into Queue::addUniqueJobRollbackCallback(), then reused it from:

  • Queue::enqueueUsing()
  • SyncQueue::push()
  • BackgroundQueue::later()
  • DeferredQueue::later()

Smaller fixes

  • Changed ?bool $dispatchAfterCommit to bool across the queue package.
  • Changed NullQueue::creationTimeOfOldestPendingJob() to return null instead of 0.
  • Removed dead constructor assignments from BeanstalkdQueue and SqsQueue.
  • Switched DeferredQueue from Hypervel\Engine\Coroutine to Hypervel\Coroutine\Coroutine.
  • Changed SQS and Horizon Redis connector after_commit defaults from null to false.
  • Updated QueueDelayTest to extend Hypervel\Tests\TestCase.
  • Made QueueSyncQueueTest self-contained by registering the Illuminate\Queue\CallQueuedHandler alias in setUp().

Not changed

DatabaseQueue::bulk() still uses one multi-row insert. It still bypasses per-job after-commit, unique rollback, and queued events.

That matches Laravel and keeps batch dispatch fast. Bus\Batch::add() is the main caller here, and changing this to loop over push() / later() would make large batches much slower.

Tests

New/updated coverage:

  • QueueDeferredQueueTest: after-commit, unique rollback, negative delays, exception handling, worker shutdown, and handler cleanup.
  • QueueBackgroundQueueTest: same coverage as deferred queue, plus the basic later() delay cases.
  • New focused tests for NullQueue, SqsConnector, and Horizon's Redis connector.

binaryfire added 14 commits May 1, 2026 11:12
… dispatchAfterCommit type

Pull the ShouldBeUnique rollback registration out of enqueueUsing()
into a protected addUniqueJobRollbackCallback() helper so SyncQueue,
DeferredQueue, and BackgroundQueue can share it. Tighten the
dispatchAfterCommit property from ?bool to bool and remove the now-
redundant ?? false at the read site.
…::push

Replace the inline ShouldBeUnique rollback block with the helper
extracted in Queue.php. Drop the now-unused UniqueLock, Cache, and
ShouldBeUnique imports. Tighten dispatchAfterCommit constructor type
from ?bool to bool to match the parent property.
…dling and worker-exit safety

PR #361 added Timer-based later() to the 0.3 ancestor (CoroutineQueue)
but was never forward-ported to 0.4. Restore it on BackgroundQueue
with the corrections discovered during review:

- After-commit branch wraps the timer scheduling itself, so the delay
  clock starts at commit time and ShouldBeUnique rollback is honored.
- Timer callback skips execution when the worker is closing
  ($isClosing = true) instead of running pending delayed jobs against
  shutdown cleanup.
- Drop the push() override; inherit SyncQueue::push() polymorphically
  so after-commit + ShouldBeUnique rollback are handled in one place.
  The executeJob() override (which spawns a coroutine) is what makes
  push() background-execute under the hood.
- Non-nullable Timer property assigned in constructor body from a
  nullable parameter so phpstan doesn't see ambiguity at every call
  site while tests can still inject a mock.
…rop push() override, standardize coroutine wrapper

PR #377 added later() to DeferredQueue but bypassed the after-commit
branch that push() honors, and duplicated the push() body when it
could inherit. This commit:

- Adds shouldDispatchAfterCommit + addUniqueJobRollbackCallback
  handling to later() so delayed jobs respect transaction commits and
  unique locks roll back correctly.
- Schedules the timer inside the commit callback so the delay clock
  starts at commit time, matching enqueueUsing semantics on the real
  distributed queues.
- Clamps the timer delay with max(0.0, ...) so a negative integer
  delay (or a past DateTimeInterface) executes immediately instead of
  triggering Timer::after's "wait until worker exit" branch.
- Skips execution when the timer fires with $isClosing = true.
- Drops the push() override; inherits SyncQueue::push() polymorphically
  so after-commit + ShouldBeUnique rollback handling come for free.
  The executeJob() override now wraps Coroutine::defer around
  parent::executeJob(), absorbing the old deferJob() into the override.
- Switches the Coroutine import from Hypervel\Engine\Coroutine to the
  high-level Hypervel\Coroutine\Coroutine wrapper used by
  BackgroundQueue, for consistency.
- Non-nullable Timer property pattern (same as BackgroundQueue).
…move dead constructor assignments

Five constructor-body assignments were dead code — the four other
parameters are already promoted (PHP auto-assigns), and dispatchAfterCommit
was being re-promoted as ?bool which redeclared the abstract Queue's
property. Un-promote dispatchAfterCommit (drop `protected`), tighten
type to bool, and assign it explicitly to the inherited property.
Queue is abstract with no constructor, so we cannot call parent::__construct().
…ad constructor assignments

Same pattern as BeanstalkdQueue — five constructor-body assignments
were dead code (other parameters auto-assign via promotion), and
dispatchAfterCommit was being re-promoted as ?bool. Un-promote it,
tighten to bool, and assign explicitly to the inherited Queue property.
…nector defaults, fix NullQueue return value

Small cleanups grouped into one commit:

- DatabaseQueue, RedisQueue: tighten dispatchAfterCommit constructor
  parameter from ?bool to bool to match the parent Queue property.
- SqsConnector, Horizon Connectors\RedisConnector: change after_commit
  default from null to false. Both connectors were silently passing
  null into a constructor that never meant to accept it; the type
  tightening on the queue constructors makes this a hard error
  otherwise.
- NullQueue::creationTimeOfOldestPendingJob(): return null instead of
  0. The method's contract is "timestamp of oldest pending job, or
  null if none" — Laravel and our own SyncQueue both return null. The
  0 was a drift; 0 is a real Unix timestamp (1970-01-01) and would
  fool null-checks into thinking a job exists.
…g coverage to QueueDeferredQueueTest

Lock in the DeferredQueue behavior changes:

- testItAddsATransactionCallbackForAfterCommitUniqueJobs and the
  ShouldQueueAfterCommit interface variant — covers the new
  ShouldBeUnique rollback handling on push() inherited from SyncQueue.
- testLaterAddsTransactionCallbackForAfterCommitJobs and three
  variants — covers the after-commit branch added to later(), with
  unique-rollback × afterCommit-property × interface combinations.
- testLaterClampsNegativeIntegerDelay,
  testLaterClampsPastDateTimeInterface — confirm Timer::after is
  called with 0.0 instead of negative values.
- testLaterFailedJobGetsHandledWhenAnExceptionIsThrown — confirms
  exceptions thrown from inside later()'s deferred execution flow
  through to setExceptionCallback.
- testLaterDoesNotExecuteJobWhenWorkerIsClosing — regression test for
  the worker-exit safety: when Timer::after fires the callback with
  $isClosing = true, the job does not execute.

Also collapses the three near-identical handler classes
(DeferredQueueLaterTestHandler, *IntervalTestHandler,
*DateTimeTestHandler) into one, since they only differed in the
$_SERVER key.
…roundQueueTest

After dropping BackgroundQueue::push() in favor of inheriting
SyncQueue::push() polymorphically, and after restoring later() with
after-commit handling and worker-exit safety, the test file needs
parity with QueueDeferredQueueTest.

Adds 13 new tests:

- testItAddsATransactionCallbackForAfterCommitUniqueJobs and
  interface variant — covers the inherited ShouldBeUnique rollback
  handling now reaching BackgroundQueue.
- testLaterSchedulesJobWithDelay, testLaterWithDateInterval,
  testLaterWithDateTime — basic delay-type coverage for later().
- testLaterAddsTransactionCallbackForAfterCommitJobs plus three
  variants — after-commit handling in later() with
  unique-rollback × afterCommit-property × interface combinations.
- testLaterClampsNegativeIntegerDelay,
  testLaterClampsPastDateTimeInterface — clamping regression tests.
- testLaterFailedJobGetsHandledWhenAnExceptionIsThrown — exception
  flow through setExceptionCallback in later()'s coroutine.
- testLaterDoesNotExecuteJobWhenWorkerIsClosing — worker-exit safety
  regression test.
Per porting.md, queue tests must extend Hypervel\Tests\TestCase
(not raw PHPUnit\Framework\TestCase) so AfterEachTestSubscriber's
global state cleanup runs and the coroutine harness is in place.
QueueDelayTest was the only file in tests/Queue still on the wrong
base class.
…ingJob

Locks in the NullQueue::creationTimeOfOldestPendingJob() return-value
fix (0 → null). Two assertions: with default queue and with a custom
queue name. Prevents future regression toward the 0 sentinel.
…ommit config

Smoke-tests SqsConnector::connect() with a config that omits
after_commit, asserting an SqsQueue is returned without TypeError.
Locks in the connector default fix (?? null → ?? false) — without
that fix and with the bool-tightened SqsQueue constructor, this
would throw at construction time.
…mmit config

Mirrors QueueSqsConnectorTest for Horizon's RedisConnector. Mocks the
Redis factory and asserts connect(['queue' => 'default']) returns a
Hypervel\Horizon\RedisQueue without TypeError. Locks in the
Arr::get(..., null) → Arr::get(..., false) default fix.
…ueSyncQueueTest setUp

Hypervel's queue payload references Illuminate\Queue\CallQueuedHandler
for cross-framework interop (see Queue::createObjectPayload). The
alias is registered in QueueServiceProvider::boot, but unit tests
that instantiate SyncQueue directly without booting the framework
miss it, causing testFailedJobHasAccessToJobInstance and
testCreatesPayloadObject to fail with "Target class does not exist"
when run via plain `vendor/bin/phpunit` (paratest's bootstrap masks
this through different load order).

Register the alias in setUp() if it isn't already present, mirroring
the same conditional class_alias pattern from the service provider.
Now both the focused phpunit invocation and composer test:parallel
agree.
@binaryfire binaryfire merged commit 797bb71 into 0.4 May 1, 2026
34 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