From dae2c732557c326f52541d0bfad46013452f15cf Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:00:28 +0000
Subject: [PATCH 01/22] fix(pipeline): reject undefined hub pipelines
Fail named Hub dispatches with Laravel's explicit InvalidArgumentException instead of falling through an undefined array key into an incidental TypeError.
Keep Hypervel's exact null and empty-string default selection ahead of the guard so the valid pipeline name "0" remains addressable. Port the current upstream Hub coverage and merge the zero-name regression into the dedicated test class.
---
src/pipeline/src/Hub.php | 5 ++++
tests/Pipeline/HubTest.php | 58 ++++++++++++++++++++++++++++++++++++++
2 files changed, 63 insertions(+)
create mode 100644 tests/Pipeline/HubTest.php
diff --git a/src/pipeline/src/Hub.php b/src/pipeline/src/Hub.php
index 72b132e9a..e1bfca19e 100644
--- a/src/pipeline/src/Hub.php
+++ b/src/pipeline/src/Hub.php
@@ -7,6 +7,7 @@
use Closure;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Pipeline\Hub as HubContract;
+use InvalidArgumentException;
class Hub implements HubContract
{
@@ -60,6 +61,10 @@ public function pipe(mixed $object, ?string $pipeline = null): mixed
{
$pipeline = $pipeline === null || $pipeline === '' ? 'default' : $pipeline;
+ if (! isset($this->pipelines[$pipeline])) {
+ throw new InvalidArgumentException("Pipeline [{$pipeline}] is not defined.");
+ }
+
return call_user_func(
$this->pipelines[$pipeline],
new Pipeline($this->container),
diff --git a/tests/Pipeline/HubTest.php b/tests/Pipeline/HubTest.php
new file mode 100644
index 000000000..eef192fae
--- /dev/null
+++ b/tests/Pipeline/HubTest.php
@@ -0,0 +1,58 @@
+hub = new Hub(new Container);
+ }
+
+ public function testPipeSendsObjectThroughDefaultPipeline(): void
+ {
+ $this->hub->defaults(function (Pipeline $pipeline, mixed $object): mixed {
+ return $pipeline->send($object)->through([])->thenReturn();
+ });
+
+ $this->assertSame('foo', $this->hub->pipe('foo'));
+ }
+
+ public function testPipeSendsObjectThroughNamedPipeline(): void
+ {
+ $this->hub->pipeline('named', function (Pipeline $pipeline, mixed $object): mixed {
+ return $pipeline->send($object)->through([])->thenReturn();
+ });
+
+ $this->assertSame('foo', $this->hub->pipe('foo', 'named'));
+ }
+
+ public function testPipeThrowsExceptionForUndefinedPipeline(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Pipeline [missing] is not defined.'));
+
+ $this->hub->pipe('foo', 'missing');
+ }
+
+ public function testHubPreservesZeroNamedPipelines(): void
+ {
+ $this->hub->defaults(fn (Pipeline $pipeline, string $value): string => 'default-' . $value);
+ $this->hub->pipeline('0', fn (Pipeline $pipeline, string $value): string => 'zero-' . $value);
+
+ $this->assertSame('default-value', $this->hub->pipe('value'));
+ $this->assertSame('default-value', $this->hub->pipe('value', ''));
+ $this->assertSame('zero-value', $this->hub->pipe('value', '0'));
+ }
+}
From e7fbd73cbfd238d395bb1b769b110457ffdac188 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:00:43 +0000
Subject: [PATCH 02/22] fix(pipeline): isolate concrete builder resolutions
Bind both Pipeline::class and the facade key through one transient factory so the mutable concrete cannot enter the container's worker-lifetime auto-singleton cache.
Preserve the existing facade lifecycle while restoring Laravel-style freshness for direct concrete resolution. Add a bounded, deterministic sibling-coroutine regression that proves passables and finalizers cannot overwrite one another during interleaved execution.
---
src/pipeline/src/PipelineServiceProvider.php | 7 +-
tests/Pipeline/CoroutineIsolationTest.php | 77 ++++++++++++++++++++
2 files changed, 83 insertions(+), 1 deletion(-)
create mode 100644 tests/Pipeline/CoroutineIsolationTest.php
diff --git a/src/pipeline/src/PipelineServiceProvider.php b/src/pipeline/src/PipelineServiceProvider.php
index 6c9e8a3a9..015445815 100644
--- a/src/pipeline/src/PipelineServiceProvider.php
+++ b/src/pipeline/src/PipelineServiceProvider.php
@@ -16,6 +16,11 @@ public function register(): void
{
$this->app->singleton(PipelineHubContract::class, Hub::class);
- $this->app->bind('pipeline', fn ($app) => new Pipeline($app));
+ $pipelineFactory = fn ($app) => new Pipeline($app);
+
+ // Pipeline is a mutable per-operation builder, so the concrete must not
+ // fall through to the container's worker-lifetime auto-singleton cache.
+ $this->app->bind(Pipeline::class, $pipelineFactory);
+ $this->app->bind('pipeline', $pipelineFactory);
}
}
diff --git a/tests/Pipeline/CoroutineIsolationTest.php b/tests/Pipeline/CoroutineIsolationTest.php
new file mode 100644
index 000000000..573e9221f
--- /dev/null
+++ b/tests/Pipeline/CoroutineIsolationTest.php
@@ -0,0 +1,77 @@
+app->make(Pipeline::class);
+ $secondPipeline = $this->app->make(Pipeline::class);
+ $firstEntered = new Channel(1);
+ $releaseFirst = new Channel(1);
+ $finalized = [];
+
+ try {
+ $results = parallel([
+ 'first' => function () use ($firstPipeline, $firstEntered, $releaseFirst, &$finalized): mixed {
+ return $firstPipeline
+ ->send('first')
+ ->through([function (mixed $value, callable $next) use ($firstEntered, $releaseFirst): mixed {
+ if (! $firstEntered->push(true, 1)) {
+ throw new RuntimeException('The second pipeline did not observe the first pipeline.');
+ }
+
+ if ($releaseFirst->pop(1) === false) {
+ throw new RuntimeException('The second pipeline did not release the first pipeline.');
+ }
+
+ return $next($value);
+ }])
+ ->finally(function (string $value) use (&$finalized): void {
+ $finalized[] = 'first:' . $value;
+ })
+ ->thenReturn();
+ },
+ 'second' => function () use ($secondPipeline, $firstEntered, $releaseFirst, &$finalized): mixed {
+ if ($firstEntered->pop(1) === false) {
+ throw new RuntimeException('The first pipeline did not enter its pipe.');
+ }
+
+ try {
+ return $secondPipeline
+ ->send('second')
+ ->through([])
+ ->finally(function (string $value) use (&$finalized): void {
+ $finalized[] = 'second:' . $value;
+ })
+ ->thenReturn();
+ } finally {
+ if (! $releaseFirst->push(true, 1)) {
+ throw new RuntimeException('The first pipeline could not be released.');
+ }
+ }
+ },
+ ]);
+ } finally {
+ $firstEntered->close();
+ $releaseFirst->close();
+ }
+
+ $this->assertSame('first', $results['first']);
+ $this->assertSame('second', $results['second']);
+
+ sort($finalized);
+
+ $this->assertSame(['first:first', 'second:second'], $finalized);
+ }
+}
From a6bf0d2a117d399f3096f487e2020c55d24b58a1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:01:01 +0000
Subject: [PATCH 03/22] test(pipeline): normalize the package test surface
Add the repository-required void return types to the existing Pipeline and facade test methods without widening the change into closure or fixture typing churn.
Remove the duplicate Hub zero-name case now that Hub behavior lives in its current upstream-aligned dedicated test class. Existing Pipeline, macro, facade freshness, and enum transaction coverage remains unchanged.
---
tests/Pipeline/PipelineFacadeTest.php | 4 +-
tests/Pipeline/PipelineTest.php | 60 +++++++++++----------------
2 files changed, 26 insertions(+), 38 deletions(-)
diff --git a/tests/Pipeline/PipelineFacadeTest.php b/tests/Pipeline/PipelineFacadeTest.php
index bf36a91b4..36383e9e1 100644
--- a/tests/Pipeline/PipelineFacadeTest.php
+++ b/tests/Pipeline/PipelineFacadeTest.php
@@ -10,7 +10,7 @@
class PipelineFacadeTest extends TestCase
{
- public function testFacadeReturnsFreshInstanceOnEveryAccess()
+ public function testFacadeReturnsFreshInstanceOnEveryAccess(): void
{
$first = PipelineFacade::getFacadeRoot();
$second = PipelineFacade::getFacadeRoot();
@@ -20,7 +20,7 @@ public function testFacadeReturnsFreshInstanceOnEveryAccess()
$this->assertNotSame($first, $second);
}
- public function testFacadeInstanceIsNotContaminatedByPriorUsage()
+ public function testFacadeInstanceIsNotContaminatedByPriorUsage(): void
{
PipelineFacade::send('foo')->through([
function ($value, $next) {
diff --git a/tests/Pipeline/PipelineTest.php b/tests/Pipeline/PipelineTest.php
index c1ba01242..58b3a9860 100644
--- a/tests/Pipeline/PipelineTest.php
+++ b/tests/Pipeline/PipelineTest.php
@@ -8,7 +8,6 @@
use Hypervel\Container\Container;
use Hypervel\Database\Connection;
use Hypervel\Database\DatabaseManager;
-use Hypervel\Pipeline\Hub;
use Hypervel\Pipeline\Pipeline;
use Hypervel\Tests\Pipeline\Fixtures\FooPipeline;
use Hypervel\Tests\TestCase;
@@ -18,18 +17,7 @@
class PipelineTest extends TestCase
{
- public function testHubPreservesZeroNamedPipelines(): void
- {
- $hub = new Hub(new Container);
- $hub->defaults(fn (Pipeline $pipeline, string $value): string => 'default-' . $value);
- $hub->pipeline('0', fn (Pipeline $pipeline, string $value): string => 'zero-' . $value);
-
- $this->assertSame('default-value', $hub->pipe('value'));
- $this->assertSame('default-value', $hub->pipe('value', ''));
- $this->assertSame('zero-value', $hub->pipe('value', '0'));
- }
-
- public function testPipelineBasicUsage()
+ public function testPipelineBasicUsage(): void
{
$pipeTwo = function ($piped, $next) {
$_SERVER['__test.pipe.two'] = $piped;
@@ -51,7 +39,7 @@ public function testPipelineBasicUsage()
unset($_SERVER['__test.pipe.one'], $_SERVER['__test.pipe.two']);
}
- public function testPipelineUsageWithObjects()
+ public function testPipelineUsageWithObjects(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -66,7 +54,7 @@ public function testPipelineUsageWithObjects()
unset($_SERVER['__test.pipe.one']);
}
- public function testPipelineUsageWithInvokableObjects()
+ public function testPipelineUsageWithInvokableObjects(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -83,7 +71,7 @@ function ($piped) {
unset($_SERVER['__test.pipe.one']);
}
- public function testPipelineUsageWithCallable()
+ public function testPipelineUsageWithCallable(): void
{
$function = function ($piped, $next) {
$_SERVER['__test.pipe.one'] = 'foo';
@@ -116,7 +104,7 @@ function ($piped) {
unset($_SERVER['__test.pipe.one']);
}
- public function testPipelineUsageWithPipe()
+ public function testPipelineUsageWithPipe(): void
{
$object = new stdClass;
@@ -142,7 +130,7 @@ function ($piped) {
$this->assertEquals(2, $object->value);
}
- public function testPipelineThroughMethodOverwritesPreviouslySetAndAppendedPipes()
+ public function testPipelineThroughMethodOverwritesPreviouslySetAndAppendedPipes(): void
{
$object = new stdClass;
@@ -165,7 +153,7 @@ public function testPipelineThroughMethodOverwritesPreviouslySetAndAppendedPipes
$this->assertEquals(1, $object->value);
}
- public function testPipelineUsageWithInvokableClass()
+ public function testPipelineUsageWithInvokableClass(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -182,7 +170,7 @@ function ($piped) {
unset($_SERVER['__test.pipe.one']);
}
- public function testThenMethodIsNotCalledIfThePipeReturns()
+ public function testThenMethodIsNotCalledIfThePipeReturns(): void
{
$_SERVER['__test.pipe.then'] = '(*_*)';
$_SERVER['__test.pipe.second'] = '(*_*)';
@@ -208,7 +196,7 @@ public function testThenMethodIsNotCalledIfThePipeReturns()
unset($_SERVER['__test.pipe.then']);
}
- public function testThenMethodInputValue()
+ public function testThenMethodInputValue(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -231,7 +219,7 @@ public function testThenMethodInputValue()
unset($_SERVER['__test.then.arg'], $_SERVER['__test.pipe.return']);
}
- public function testPipelineUsageWithParameters()
+ public function testPipelineUsageWithParameters(): void
{
$parameters = ['one', 'two'];
@@ -248,7 +236,7 @@ public function testPipelineUsageWithParameters()
unset($_SERVER['__test.pipe.parameters']);
}
- public function testPipelineViaChangesTheMethodBeingCalledOnThePipes()
+ public function testPipelineViaChangesTheMethodBeingCalledOnThePipes(): void
{
$pipelineInstance = new Pipeline(new Container);
$result = $pipelineInstance->send('data')
@@ -260,7 +248,7 @@ public function testPipelineViaChangesTheMethodBeingCalledOnThePipes()
$this->assertSame('data', $result);
}
- public function testPipelineThrowsExceptionOnResolveWithoutContainer()
+ public function testPipelineThrowsExceptionOnResolveWithoutContainer(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('A container instance has not been passed to the Pipeline.');
@@ -272,7 +260,7 @@ public function testPipelineThrowsExceptionOnResolveWithoutContainer()
});
}
- public function testPipelineThrowsExceptionWhenUsingTransactionsWithoutContainer()
+ public function testPipelineThrowsExceptionWhenUsingTransactionsWithoutContainer(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('A container instance has not been passed to the Pipeline.');
@@ -302,7 +290,7 @@ public function testPipelineDelegatesIntegerBackedEnumTransactionConnection(): v
$this->assertSame('data', $result);
}
- public function testPipelineThenReturnMethodRunsPipelineThenReturnsPassable()
+ public function testPipelineThenReturnMethodRunsPipelineThenReturnsPassable(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -315,7 +303,7 @@ public function testPipelineThenReturnMethodRunsPipelineThenReturnsPassable()
unset($_SERVER['__test.pipe.one']);
}
- public function testPipelineConditionable()
+ public function testPipelineConditionable(): void
{
$result = (new Pipeline(new Container))
->send('foo')
@@ -345,7 +333,7 @@ public function testPipelineConditionable()
unset($_SERVER['__test.pipe.one']);
}
- public function testPipelineFinally()
+ public function testPipelineFinally(): void
{
$pipeTwo = function ($piped, $next) {
$_SERVER['__test.pipe.two'] = $piped;
@@ -371,7 +359,7 @@ public function testPipelineFinally()
unset($_SERVER['__test.pipe.one'], $_SERVER['__test.pipe.two'], $_SERVER['__test.pipe.finally']);
}
- public function testPipelineFinallyMethodWhenChainIsStopped()
+ public function testPipelineFinallyMethodWhenChainIsStopped(): void
{
$pipeTwo = function ($piped) {
$_SERVER['__test.pipe.two'] = $piped;
@@ -395,7 +383,7 @@ public function testPipelineFinallyMethodWhenChainIsStopped()
unset($_SERVER['__test.pipe.one'], $_SERVER['__test.pipe.two'], $_SERVER['__test.pipe.finally']);
}
- public function testPipelineFinallyOrder()
+ public function testPipelineFinallyOrder(): void
{
$std = new stdClass;
@@ -426,7 +414,7 @@ function ($std, $next) {
$this->assertSame(4, $result->value);
}
- public function testPipelineFinallyWhenExceptionOccurs()
+ public function testPipelineFinallyWhenExceptionOccurs(): void
{
$std = new stdClass;
@@ -462,7 +450,7 @@ function ($std) {
}
}
- public function testHandleCarry()
+ public function testHandleCarry(): void
{
$result = (new FooPipeline(new Container))
->send($id = rand(0, 99))
@@ -479,7 +467,7 @@ public function testHandleCarry()
$this->assertSame($id + 6, $result);
}
- public function testPipelineMacro()
+ public function testPipelineMacro(): void
{
Pipeline::macro('customMethod', function ($value) {
return 'custom_' . $value;
@@ -490,7 +478,7 @@ public function testPipelineMacro()
$this->assertSame('custom_test', $pipeline->customMethod('test'));
}
- public function testPipelineMacroWithThis()
+ public function testPipelineMacroWithThis(): void
{
Pipeline::macro('getPipes', function () {
return $this->pipes;
@@ -502,7 +490,7 @@ public function testPipelineMacroWithThis()
$this->assertEquals(['pipe1', 'pipe2'], $pipeline->getPipes());
}
- public function testPipelineHasMacro()
+ public function testPipelineHasMacro(): void
{
Pipeline::macro('existingMacro', function () {
return 'exists';
@@ -514,7 +502,7 @@ public function testPipelineHasMacro()
$this->assertFalse($pipeline->hasMacro('nonExistingMacro'));
}
- public function testPipelineMacroOverwrite()
+ public function testPipelineMacroOverwrite(): void
{
Pipeline::macro('testMacro', function () {
return 'first';
From c42c45147db4276f62a2eb1199c217e814eb626d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:01:15 +0000
Subject: [PATCH 04/22] fix(pipeline): declare the database dependency
Require hypervel/database directly because Pipeline's public withinTransaction capability resolves and invokes the database manager.
This makes the package contract truthful instead of relying on Support's transitive dependency. Database is already mandatory in the current Hypervel package graph, so the metadata correction adds no installed or runtime weight.
---
src/pipeline/composer.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/pipeline/composer.json b/src/pipeline/composer.json
index d6bb9f116..11b30fcca 100644
--- a/src/pipeline/composer.json
+++ b/src/pipeline/composer.json
@@ -32,6 +32,7 @@
"php": "^8.4",
"hypervel/conditionable": "^0.4",
"hypervel/contracts": "^0.4",
+ "hypervel/database": "^0.4",
"hypervel/macroable": "^0.4",
"hypervel/support": "^0.4"
},
From e335e72f5e14fa96db4affed336bded3fcfef435 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:01:33 +0000
Subject: [PATCH 05/22] test(pipeline): verify transaction connection routing
Replace the discarded Event::dispatched query with an actual dispatch assertion and compare ConnectionEvent::connectionName with the expected configured name.
The enum, string, and default-connection cases now fail when Pipeline selects the wrong database instead of passing vacuously. Use strict result assertions and complete the package test method return types at the same boundary.
---
.../Pipeline/PipelineTransactionTest.php | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/tests/Integration/Pipeline/PipelineTransactionTest.php b/tests/Integration/Pipeline/PipelineTransactionTest.php
index 8abd42ac6..0c754c709 100644
--- a/tests/Integration/Pipeline/PipelineTransactionTest.php
+++ b/tests/Integration/Pipeline/PipelineTransactionTest.php
@@ -28,7 +28,7 @@ protected function defineEnvironment(ApplicationContract $app): void
]);
}
- public function testPipelineTransaction()
+ public function testPipelineTransaction(): void
{
Event::fake();
@@ -40,13 +40,13 @@ public function testPipelineTransaction()
])
->thenReturn();
- $this->assertEquals('some string', $result);
+ $this->assertSame('some string', $result);
Event::assertDispatchedTimes(TransactionBeginning::class, 1);
Event::assertDispatchedTimes(TransactionCommitted::class, 1);
}
#[DataProvider('transactionConnectionDataProvider')]
- public function testConnection($connection, $connectionName)
+ public function testConnection($connection, $connectionName): void
{
Event::fake();
config(['database.connections.testing2' => config('database.connections.testing')]);
@@ -61,9 +61,9 @@ function ($value, $next) {
])
->thenReturn();
- $this->assertEquals('some string', $result);
- Event::dispatched(TransactionBeginning::class, function (TransactionBeginning $event) use ($connectionName) {
- return $event->connection === $connectionName;
+ $this->assertSame('some string', $result);
+ Event::assertDispatched(TransactionBeginning::class, function (TransactionBeginning $event) use ($connectionName) {
+ return $event->connectionName === $connectionName;
});
}
@@ -76,7 +76,7 @@ public static function transactionConnectionDataProvider(): array
];
}
- public function testExceptionThrownRollsBackTransaction()
+ public function testExceptionThrownRollsBackTransaction(): void
{
Event::fake();
From bf81a6d24b8e227cfababf6165365c06dfb0ae99 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:01:46 +0000
Subject: [PATCH 06/22] docs(pipeline): record the Laravel provenance
Identify the current Laravel 13.x Pipeline source as the package's porting reference, matching the concise format used by sibling Hypervel package READMEs.
Keeping the upstream location at the package boundary makes future incremental parity work discoverable without adding unnecessary package documentation.
---
src/pipeline/README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/pipeline/README.md b/src/pipeline/README.md
index ce6a8accd..dc3fe2d3a 100644
--- a/src/pipeline/README.md
+++ b/src/pipeline/README.md
@@ -2,3 +2,5 @@ Pipeline for Hypervel
===
[](https://deepwiki.com/hypervel/pipeline)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Pipeline
From 8ccb70fa08d7fcf411e43259e35eaf30778648b1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 09:02:04 +0000
Subject: [PATCH 07/22] docs(audit): complete the Pipeline work unit
Record pipeline-01 through pipeline-05, their verified ownership boundaries, accepted implementation, current Laravel discovery sources, regression coverage, validation results, and final performance and overengineering assessment.
Mark Pipeline complete and route the next audit to Bus with the exact queue-12, support-02, and shared-Pipeline invariants that must be revalidated. Keep rejected context, locking, cloning, registry, and contract-binding machinery explicit so future audits do not reopen disproven designs.
---
...-coroutine-state-lifecycle-audit-ledger.md | 21 +++++++++++++++++++
...amework-coroutine-state-lifecycle-audit.md | 8 +++----
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
index da375f9d1..cb75495e2 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -830,3 +830,24 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Validation and review:** Every changed test file and all affected Filesystem, Cache, HTTP, Routing, Foundation, HTTP Server, contract, facade, environment, session, and streaming groups pass. PHP CS Fixer changed none of 5,574 files; both PHPStan configurations pass; the complete components suite passes with 23,282 tests, 66,332 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; dogfood passes with 4 tests and 7 assertions; `git diff --check` and package-checklist parity are clean. Fresh full-diff caller/callee, resource-lifecycle, API, performance, stale-code, and overengineering review is complete, and independent code review signed off on the final MIME, SSE, documentation, test, and ledger corrections.
- **Laravel-facing result:** Current Laravel filesystem APIs, configuration structure, and conventional extension shapes remain compatible. The owner approved the safer native-failure behavior, the Swoole-specific lazy remote-streaming default, the bounded native lock-detection tail, and protocol-correct multiline SSE framing where current Laravel emits invalid subsequent data lines; `stream_reads=false` preserves the documented eager transport option. No public API was removed or renamed, and the added `assertEmpty()` surface restores current Laravel parity.
- **Assessment:** The result fixes verified native-boundary, ownership, streaming, disconnect, and cleanup defects at their lowest owners while bounding remote-read and response-stream memory. Ordinary non-streaming requests and ordinary cloud reads gain no new runtime work; single-line SSE events add one native scan without normalized-data allocation, while multiline events incur only the bounded native string work required for correct framing. The design adds no registry, replacement lock, retry loop, timeout policy, context state, custom cURL bridge, resource state machine, compatibility shim, or speculative Image surface; every accepted mechanism has a demonstrated consumer and the completed work is free of overengineering.
+
+### Make Pipeline builders transient and restore current upstream fidelity
+
+- **Architecture and inspected risk surfaces:** Pipeline is a Laravel-derived mutable per-operation builder plus a worker-lifetime named Hub registry. The audit covered every Pipeline source, unit, integration, contract, facade, provider, and documentation surface; every repository consumer and subclass; current Laravel 13.x source, tests, package metadata, documentation, and originating framework/documentation pull requests; the container's auto-singleton behavior; Macroable cleanup; and the completed `support-02` enum-identifier boundary.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `pipeline-01` | Defect | Major | High | The canonical facade key is transient, but the unbound concrete `Pipeline::class` falls into the worker-lifetime auto-singleton cache, allowing independently resolved mutable builders to overwrite another coroutine's execution-time state | Bind the concrete and facade key transiently through one factory; document why the concrete binding must bypass auto-singletoning and prove finalizer/passable isolation with deterministic sibling coroutines |
+| `pipeline-02` | Defect | Minor | High | Undefined Hub pipelines fall through an undefined-key warning and incidental native exception rather than the current Laravel `InvalidArgumentException` contract | Port Laravel's current guard and dedicated Hub regression after Hypervel's exact null/empty default selection |
+| `pipeline-03` | Package metadata defect | Minor | High | Database is an undeclared direct dependency of the public `withinTransaction()` capability even though Support currently installs it transitively | Add a direct sorted `hypervel/database` requirement; do not copy Laravel's factually inapplicable optional suggestion |
+| `pipeline-04` | Test fidelity defect | Minor | High | The transaction connection regression discards `Event::dispatched()` and compares a `Connection` object with a string, so every connection-selection case passes vacuously | Assert the dispatch and compare `ConnectionEvent::$connectionName`; retain enum, string, null, and integer-backed delegation coverage |
+| `pipeline-05` | Documentation defect | Minor | High | The package README omits its Laravel provenance | Add the current Laravel Pipeline source reference without unnecessary package prose |
+
+- **Ownership and performance boundary:** Reuse one boot-created factory closure under `Pipeline::class` and `pipeline`. Existing facade resolution performs the same factory call and gains no work; direct concrete resolution deliberately changes from one unsafe worker instance to one fresh allocation per resolution, restoring Laravel's builder lifetime. The owner approved that required allocation tradeoff. The Hub guard adds one predictable `isset` check per named dispatch. No per-pipe context lookup, lock, clone, registry, retry, yield, or new retained request state is added.
+- **Cross-package and support revalidation:** `support-02` remains correct without source changes: Hub preserves named pipeline `"0"`, while `withinTransaction()` carries enums unchanged to Database's owning string boundary, including integer-backed zero. Bus's shared Pipeline remains safe because its non-yielding `send()->through()->then()` prefix snapshots the passable and pipes before execution and Bus never mutates the method, container, finalizer, or transaction fields read during yielding execution. This invariant is recorded for later Bus revalidation; no Bus source change or hot-path clone is justified.
+- **Important rejected concerns:** Do not add CoroutineContext state, locks, immutable fluent copies, scoped binding, `SelfBuilding`, a generic transient marker, subclass registry, per-dispatch Bus clone, Pipeline-contract binding, Hub concrete/contract identity machinery, native-finally semantic changes, or closure/call-user-function micro-optimizations. The base concrete binding cannot enumerate userland subclasses; custom subclasses remain governed by the general container lifetime rules rather than package-owned machinery.
+- **Upstream and regression strategy:** Laravel framework pull requests `#60802`, `#56377`, `#56447`, `#56550`, and `#56567` and documentation pull requests `#10677`, `#10689`, and `#10691` supply discovery history; current local Laravel 13.x source, tests, metadata, and documentation supply the porting reference. Merge the zero-name regression into the new current Hub test, reproduce concrete-resolution finalizer contamination with bounded sibling-coroutine handshakes, make the inherited transaction assertion meaningful, and apply the repository-required `: void` only to Pipeline package test methods. Record the current Laravel object-versus-name test defect for owner coordination rather than opening an external change.
+- **Implemented changes:** Pipeline's provider now reuses one stateless factory for transient concrete and facade-key bindings, with the concrete auto-singleton rationale recorded at the binding. Hub now fails undefined names with Laravel's current exception after preserving Hypervel's exact null/empty fallback. The package declares Database directly and records its Laravel provenance. Current upstream Hub coverage, the existing zero-name regression, deterministic concrete-resolution coroutine isolation, meaningful transaction connection assertions, strict result comparisons, and Pipeline test-method return types cover the corrected behavior without adding production test seams.
+- **Laravel-facing result:** Public APIs, configuration, and conventional call shapes remain unchanged. Concrete container resolution becomes fresh like Laravel, undefined Hub names gain Laravel's current exception, and package metadata declares Hypervel's actual direct dependency rather than copying Laravel's different split-package architecture.
+- **Validation and review:** The Hub and concrete-resolution regressions fail against the old source for the intended undefined-name and cross-coroutine finalizer-contamination reasons. All 32 Pipeline tests with 77 assertions and all five transaction tests with 12 assertions pass. Package Composer metadata and `git diff --check` are clean. The final `composer fix` run changed none of 5,576 files; both PHPStan configurations pass; the complete components suite passes with 23,286 tests, 66,342 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. Fresh full-diff caller/callee, lifecycle, API, metadata, performance, stale-code, and overengineering review is complete, and independent code review signed off without findings.
+- **Assessment:** The result fixes each verified defect at its owning boundary and restores current Laravel behavior without a compatibility layer or broader lifecycle mechanism. Existing facade dispatch gains no work; direct concrete resolution pays only the approved fresh-builder allocation required for correctness, and Hub adds one predictable `isset` guard per named dispatch. No per-pipe context lookup, lock, clone, registry, retry, yield, retained request state, or speculative extension surface was added.
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
index 0c5b40e4f..f68acc53d 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
@@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `pipeline`
-- **Ledger entries required for the active work:** `Normalize framework enum identifiers at string boundaries` (`support-02`).
-- **Pending revalidation carried into the active work:** Revalidate `support-02` during the full Pipeline audit.
+- **Active package or work unit:** `bus`
+- **Ledger entries required for the active work:** `Correct event dispatch, queued-consumer isolation, and queue interoperability` (`queue-12`); `Normalize framework enum identifiers at string boundaries` (`support-02`); `Make Pipeline builders transient and restore current upstream fidelity` (`pipeline-01` through `pipeline-05`).
+- **Pending revalidation carried into the active work:** Revalidate `queue-12`, `support-02`, and Pipeline's shared-Bus invariant during the full Bus audit.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1114,7 +1114,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
### Framework dispatch and runtime
-- [ ] `pipeline`
+- [x] `pipeline`
- [ ] `bus`
- [ ] `core`
- [ ] `foundation`
From a52b9210fff758ecf8165ebcc3abc4c538a5f7a5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:41:26 +0000
Subject: [PATCH 08/22] fix(bus): preserve unique job payload ownership
Replace the request-wide context bracket with a lazy WeakMap keyed by the exact unique job object. Record ownership only after the unique lock is acquired, consume it once during payload creation, and let weak keys prevent worker-lifetime retention.
Carry the metadata safely through deferred, after-response, synchronous, background, and after-commit dispatch paths while restoring log context on both success and failure. Make after-response selection reversible at the pending-dispatch boundary.
Declare the direct Foundation and Queue log dependencies, register deterministic test-state cleanup, remove the superseded trait, and cover exact identity, weak retention, delayed payload creation, and failure cleanup.
---
src/bus/src/UniqueJobPayloadContext.php | 83 +++++++++
src/foundation/composer.json | 1 +
src/foundation/src/Bus/PendingDispatch.php | 17 +-
.../src/Queue/InteractsWithUniqueJobs.php | 50 ------
src/queue/composer.json | 1 +
src/queue/src/Queue.php | 15 +-
.../src/PHPUnit/AfterEachTestSubscriber.php | 1 +
tests/Bus/BusPendingDispatchTest.php | 115 ++++++++++--
tests/Bus/UniqueJobPayloadContextTest.php | 163 ++++++++++++++++++
.../Integration/Queue/JobDispatchingTest.php | 94 +++++++++-
tests/Log/ContextQueueTest.php | 111 ++++++++++--
11 files changed, 554 insertions(+), 97 deletions(-)
create mode 100644 src/bus/src/UniqueJobPayloadContext.php
delete mode 100644 src/foundation/src/Queue/InteractsWithUniqueJobs.php
create mode 100644 tests/Bus/UniqueJobPayloadContextTest.php
diff --git a/src/bus/src/UniqueJobPayloadContext.php b/src/bus/src/UniqueJobPayloadContext.php
new file mode 100644
index 000000000..e59d6f2bd
--- /dev/null
+++ b/src/bus/src/UniqueJobPayloadContext.php
@@ -0,0 +1,83 @@
+
+ */
+ protected static ?WeakMap $metadata = null;
+
+ /**
+ * Register unique job metadata for payload creation.
+ */
+ public static function register(ShouldBeUnique $job): void
+ {
+ // @phpstan-ignore assign.propertyType (PHPStan falsely rejects an empty WeakMap for this invariant closed-shape value.)
+ $metadata = static::$metadata ??= new WeakMap;
+
+ // IMPORTANT: Uses Laravel's keys for cross-framework queue interoperability.
+ $metadata[$job] = [
+ 'laravel_unique_job_cache_store' => static::getCacheStore($job),
+ 'laravel_unique_job_key' => UniqueLock::getKey($job),
+ ];
+ }
+
+ /**
+ * Consume unique job metadata for payload creation.
+ *
+ * @return null|array{laravel_unique_job_cache_store: ?string, laravel_unique_job_key: string}
+ */
+ public static function consume(object $job): ?array
+ {
+ if (static::$metadata === null) {
+ return null;
+ }
+
+ $metadata = static::$metadata;
+
+ if (! isset($metadata[$job])) {
+ if (count($metadata) === 0) {
+ static::$metadata = null;
+ }
+
+ return null;
+ }
+
+ $value = $metadata[$job];
+
+ unset($metadata[$job]);
+
+ if (count($metadata) === 0) {
+ static::$metadata = null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Determine the cache store used by the unique job to acquire locks.
+ */
+ protected static function getCacheStore(ShouldBeUnique $job): ?string
+ {
+ return method_exists($job, 'uniqueVia')
+ ? $job->uniqueVia()->getName()
+ : config('cache.default');
+ }
+
+ /**
+ * Flush all static state.
+ */
+ public static function flushState(): void
+ {
+ static::$metadata = null;
+ }
+}
diff --git a/src/foundation/composer.json b/src/foundation/composer.json
index 5c686f5cb..b9ffbc03a 100644
--- a/src/foundation/composer.json
+++ b/src/foundation/composer.json
@@ -54,6 +54,7 @@
"hypervel/core": "^0.4",
"hypervel/http": "^0.4",
"hypervel/http-server": "^0.4",
+ "hypervel/log": "^0.4",
"hypervel/macroable": "^0.4",
"hypervel/prompts": "^0.4",
"hypervel/queue": "^0.4",
diff --git a/src/foundation/src/Bus/PendingDispatch.php b/src/foundation/src/Bus/PendingDispatch.php
index 2db5fa658..117fd87bc 100644
--- a/src/foundation/src/Bus/PendingDispatch.php
+++ b/src/foundation/src/Bus/PendingDispatch.php
@@ -7,13 +7,13 @@
use DateInterval;
use DateTimeInterface;
use Hypervel\Bus\DebounceLock;
+use Hypervel\Bus\UniqueJobPayloadContext;
use Hypervel\Bus\UniqueLock;
use Hypervel\Container\Container;
use Hypervel\Contracts\Bus\Dispatcher;
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Contracts\Queue\PreparesForDispatch;
use Hypervel\Contracts\Queue\ShouldBeUnique;
-use Hypervel\Foundation\Queue\InteractsWithUniqueJobs;
use Hypervel\Queue\Attributes\DebounceFor;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Support\Traits\Conditionable;
@@ -23,7 +23,6 @@
class PendingDispatch
{
use Conditionable;
- use InteractsWithUniqueJobs;
use ReadsQueueAttributes;
/**
@@ -158,9 +157,9 @@ public function chain(array $chain): static
/**
* Indicate that the job should be dispatched after the response is sent to the browser.
*/
- public function afterResponse(): static
+ public function afterResponse(bool $afterResponse = true): static
{
- $this->afterResponse = true;
+ $this->afterResponse = $afterResponse;
return $this;
}
@@ -236,14 +235,14 @@ public function __call(string $method, array $parameters): static
*/
public function __destruct()
{
- $this->addUniqueJobInformationToContext($this->job);
-
if (! $this->shouldDispatch()) {
- $this->removeUniqueJobInformationFromContext($this->job);
-
return;
}
+ if ($this->job instanceof ShouldBeUnique) {
+ UniqueJobPayloadContext::register($this->job);
+ }
+
$this->acquireDebounceLock();
if ($this->afterResponse) {
@@ -255,7 +254,5 @@ public function __destruct()
->make(Dispatcher::class)
->dispatch($this->job);
}
-
- $this->removeUniqueJobInformationFromContext($this->job);
}
}
diff --git a/src/foundation/src/Queue/InteractsWithUniqueJobs.php b/src/foundation/src/Queue/InteractsWithUniqueJobs.php
deleted file mode 100644
index d073d13b8..000000000
--- a/src/foundation/src/Queue/InteractsWithUniqueJobs.php
+++ /dev/null
@@ -1,50 +0,0 @@
- $this->getUniqueJobCacheStore($job),
- 'laravel_unique_job_key' => UniqueLock::getKey($job),
- ]);
- }
- }
-
- /**
- * Remove the unique job information from the context.
- */
- public function removeUniqueJobInformationFromContext(mixed $job): void
- {
- if ($job instanceof ShouldBeUnique) {
- // IMPORTANT: Uses Laravel's keys for cross-framework queue interoperability.
- Context::forgetHidden([
- 'laravel_unique_job_cache_store',
- 'laravel_unique_job_key',
- ]);
- }
- }
-
- /**
- * Determine the cache store used by the unique job to acquire locks.
- */
- protected function getUniqueJobCacheStore(mixed $job): ?string
- {
- return method_exists($job, 'uniqueVia')
- ? $job->uniqueVia()->getName()
- : config('cache.default');
- }
-}
diff --git a/src/queue/composer.json b/src/queue/composer.json
index 5dd0fbdaa..b29ac0ad2 100644
--- a/src/queue/composer.json
+++ b/src/queue/composer.json
@@ -42,6 +42,7 @@
"hypervel/engine": "^0.4",
"hypervel/events": "^0.4",
"hypervel/foundation": "^0.4",
+ "hypervel/log": "^0.4",
"hypervel/object-pool": "^0.4",
"hypervel/pipeline": "^0.4",
"hypervel/redis": "^0.4",
diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php
index c8ab521df..20c531c95 100644
--- a/src/queue/src/Queue.php
+++ b/src/queue/src/Queue.php
@@ -8,6 +8,7 @@
use DateInterval;
use DateTimeInterface;
use Hypervel\Bus\DebounceLock;
+use Hypervel\Bus\UniqueJobPayloadContext;
use Hypervel\Bus\UniqueLock;
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Contracts\Container\Container;
@@ -27,6 +28,7 @@
use Hypervel\Queue\Events\JobQueueing;
use Hypervel\Support\Carbon;
use Hypervel\Support\Collection;
+use Hypervel\Support\Facades\Context;
use Hypervel\Support\InteractsWithTime;
use Hypervel\Support\Str;
use RuntimeException;
@@ -143,7 +145,7 @@ protected function createPayloadArray(array|object|string $job, ?string $queue,
*/
protected function createObjectPayload(object $job, ?string $queue): array
{
- $payload = $this->withCreatePayloadHooks($queue, [
+ $payload = [
'uuid' => (string) Str::uuid(),
'displayName' => $this->getDisplayName($job),
// IMPORTANT: Uses Laravel's handler reference for cross-framework queue interoperability.
@@ -161,7 +163,16 @@ protected function createObjectPayload(object $job, ?string $queue): array
'batchId' => $job->batchId ?? null,
],
'createdAt' => Carbon::now()->getTimestamp(),
- ]);
+ ];
+
+ $uniqueJobMetadata = UniqueJobPayloadContext::consume($job);
+
+ $payload = $uniqueJobMetadata === null
+ ? $this->withCreatePayloadHooks($queue, $payload)
+ : Context::scope(
+ fn (): array => $this->withCreatePayloadHooks($queue, $payload),
+ hidden: $uniqueJobMetadata,
+ );
try {
$command = $this->jobShouldBeEncrypted($job) && $this->container->has(Encrypter::class)
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index 7d438f399..c1561ef56 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -130,6 +130,7 @@ protected function flushFrameworkState(): void
\Hypervel\Auth\TokenGuard::flushState();
\Hypervel\Broadcasting\Broadcasters\Broadcaster::flushChannels();
\Hypervel\Bus\PendingBatch::flushState();
+ \Hypervel\Bus\UniqueJobPayloadContext::flushState();
\Hypervel\Cache\Redis\Console\BenchmarkCommand::flushState();
\Hypervel\Cache\Redis\Console\DoctorCommand::flushState();
\Hypervel\Cache\Repository::flushState();
diff --git a/tests/Bus/BusPendingDispatchTest.php b/tests/Bus/BusPendingDispatchTest.php
index 04e986027..50eec3a72 100644
--- a/tests/Bus/BusPendingDispatchTest.php
+++ b/tests/Bus/BusPendingDispatchTest.php
@@ -5,9 +5,14 @@
namespace Hypervel\Tests\Bus;
use Hypervel\Bus\Queueable;
+use Hypervel\Bus\UniqueJobPayloadContext;
+use Hypervel\Cache\Repository as CacheRepository;
+use Hypervel\Cache\WorkerArrayStore;
use Hypervel\Container\Container;
use Hypervel\Contracts\Bus\Dispatcher;
+use Hypervel\Contracts\Cache\Repository as CacheContract;
use Hypervel\Contracts\Queue\PreparesForDispatch;
+use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Foundation\Bus\PendingDispatch;
use Hypervel\Queue\Attributes\DebounceFor;
use Hypervel\Tests\TestCase;
@@ -40,13 +45,13 @@ protected function setUp(): void
$this->pendingDispatch = new PendingDispatchWithoutDestructor($this->job);
}
- public function testOnConnection()
+ public function testOnConnection(): void
{
$this->job->shouldReceive('onConnection')->once()->with('test-connection');
$this->pendingDispatch->onConnection('test-connection');
}
- public function testOnQueue()
+ public function testOnQueue(): void
{
$this->job->shouldReceive('onQueue')->once()->with('test-queue');
$this->pendingDispatch->onQueue('test-queue');
@@ -59,7 +64,28 @@ public function testConditionableCanConfigurePendingDispatch(): void
$this->pendingDispatch->when(true, fn ($pendingDispatch) => $pendingDispatch->onQueue('conditional-queue'));
}
- public function testOnGroup()
+ public function testWhenMethodOfConditionableTraitWithFalse(): void
+ {
+ $this->job->shouldReceive('delay')->never();
+
+ $this->pendingDispatch->when(false, fn ($pendingDispatch) => $pendingDispatch->delay(300));
+ }
+
+ public function testUnlessMethodOfConditionableTraitWithTrue(): void
+ {
+ $this->job->shouldReceive('delay')->never();
+
+ $this->pendingDispatch->unless(true, fn ($pendingDispatch) => $pendingDispatch->delay(300));
+ }
+
+ public function testUnlessMethodOfConditionableTraitWithFalse(): void
+ {
+ $this->job->shouldReceive('delay')->once()->with(300);
+
+ $this->pendingDispatch->unless(false, fn ($pendingDispatch) => $pendingDispatch->delay(300));
+ }
+
+ public function testOnGroup(): void
{
$this->job->shouldReceive('onGroup')->once()->with('test-group');
$this->pendingDispatch->onGroup('test-group');
@@ -73,7 +99,7 @@ public function testOnGroupForwardsAnArray(): void
$this->pendingDispatch->onGroup($groups);
}
- public function testWithDeduplicator()
+ public function testWithDeduplicator(): void
{
$deduplicator = fn () => 'id';
$this->job->shouldReceive('withDeduplicator')->once()->with($deduplicator);
@@ -93,50 +119,50 @@ public function resolveDeduplicationId(): string
return 'id';
}
- public function testAllOnConnection()
+ public function testAllOnConnection(): void
{
$this->job->shouldReceive('allOnConnection')->once()->with('test-connection');
$this->pendingDispatch->allOnConnection('test-connection');
}
- public function testAllOnQueue()
+ public function testAllOnQueue(): void
{
$this->job->shouldReceive('allOnQueue')->once()->with('test-queue');
$this->pendingDispatch->allOnQueue('test-queue');
}
- public function testDelay()
+ public function testDelay(): void
{
$this->job->shouldReceive('delay')->once()->with(60);
$this->pendingDispatch->delay(60);
}
- public function testWithoutDelay()
+ public function testWithoutDelay(): void
{
$this->job->shouldReceive('withoutDelay')->once();
$this->pendingDispatch->withoutDelay();
}
- public function testAfterCommit()
+ public function testAfterCommit(): void
{
$this->job->shouldReceive('afterCommit')->once();
$this->pendingDispatch->afterCommit();
}
- public function testBeforeCommit()
+ public function testBeforeCommit(): void
{
$this->job->shouldReceive('beforeCommit')->once();
$this->pendingDispatch->beforeCommit();
}
- public function testChain()
+ public function testChain(): void
{
$chain = [new stdClass];
$this->job->shouldReceive('chain')->once()->with($chain);
$this->pendingDispatch->chain($chain);
}
- public function testAfterResponse()
+ public function testAfterResponse(): void
{
$this->pendingDispatch->afterResponse();
$this->assertTrue(
@@ -144,7 +170,16 @@ public function testAfterResponse()
);
}
- public function testGetJob()
+ public function testAfterResponseCanBeDisabled(): void
+ {
+ $this->pendingDispatch->afterResponse()->afterResponse(false);
+
+ $this->assertFalse(
+ (new ReflectionClass($this->pendingDispatch))->getProperty('afterResponse')->getValue($this->pendingDispatch)
+ );
+ }
+
+ public function testGetJob(): void
{
$this->assertSame($this->job, $this->pendingDispatch->getJob());
}
@@ -186,7 +221,41 @@ public function testPrepareForDispatchAllowsDispatch(): void
}
}
- public function testDynamicallyProxyMethods()
+ public function testUniqueMetadataRemainsRegisteredUntilAfterResponsePayloadCreation(): void
+ {
+ Container::setInstance($container = new Container);
+
+ try {
+ $cache = new CacheRepository(new WorkerArrayStore, ['store' => 'unique']);
+ $container->instance(CacheContract::class, $cache);
+
+ $job = new UniquePendingDispatchJob($cache);
+ $deferredJob = null;
+
+ $dispatcher = m::mock(Dispatcher::class);
+ $dispatcher->shouldReceive('dispatch')->never();
+ $dispatcher->shouldReceive('dispatchAfterResponse')
+ ->once()
+ ->with($job)
+ ->andReturnUsing(function (object $job) use (&$deferredJob): void {
+ $deferredJob = $job;
+ });
+ $container->instance(Dispatcher::class, $dispatcher);
+
+ $pendingDispatch = (new PendingDispatch($job))->afterResponse();
+ unset($pendingDispatch);
+
+ $this->assertSame($job, $deferredJob);
+ $this->assertSame([
+ 'laravel_unique_job_cache_store' => 'unique',
+ 'laravel_unique_job_key' => 'laravel_unique_job:' . UniquePendingDispatchJob::class . ':after-response',
+ ], UniqueJobPayloadContext::consume($deferredJob));
+ } finally {
+ Container::setInstance(null);
+ }
+ }
+
+ public function testDynamicallyProxyMethods(): void
{
$newJob = m::mock(stdClass::class);
$this->job->shouldReceive('appendToChain')->once()->with($newJob);
@@ -212,3 +281,21 @@ class PreparingDebouncedPendingDispatchJob extends PreparingPendingDispatchJob
{
use Queueable;
}
+
+class UniquePendingDispatchJob implements ShouldBeUnique
+{
+ public function __construct(
+ protected CacheRepository $cache
+ ) {
+ }
+
+ public function uniqueId(): string
+ {
+ return 'after-response';
+ }
+
+ public function uniqueVia(): CacheRepository
+ {
+ return $this->cache;
+ }
+}
diff --git a/tests/Bus/UniqueJobPayloadContextTest.php b/tests/Bus/UniqueJobPayloadContextTest.php
new file mode 100644
index 000000000..76b000b02
--- /dev/null
+++ b/tests/Bus/UniqueJobPayloadContextTest.php
@@ -0,0 +1,163 @@
+assertNull(UniqueJobPayloadContext::consume($other));
+ $this->assertSame([
+ 'laravel_unique_job_cache_store' => 'unique',
+ 'laravel_unique_job_key' => 'laravel_unique_job:' . UniqueJobPayloadContextJob::class . ':registered',
+ ], UniqueJobPayloadContext::consume($registered));
+ $this->assertNull(UniqueJobPayloadContext::consume($registered));
+ }
+
+ public function testRegistrationDoesNotRetainTheJob(): void
+ {
+ $job = new UniqueJobPayloadContextJob('released');
+ $reference = WeakReference::create($job);
+
+ UniqueJobPayloadContext::register($job);
+
+ unset($job);
+
+ $this->assertNull($reference->get());
+ }
+
+ #[DataProvider('afterCommitPayloadBuilders')]
+ public function testMetadataSurvivesUntilAfterCommitPayloadCreation(string $queueClass, bool $delayed): void
+ {
+ $container = new Container;
+ $container->instance(ContainerContract::class, $container);
+ $container->instance(EventDispatcher::class, new EventsDispatcher($container));
+ Container::setInstance($container);
+
+ $transactionManager = m::mock(DatabaseTransactionsManager::class);
+ $transactionManager->shouldReceive('addCallback')
+ ->once()
+ ->andReturnUsing(fn (callable $callback) => $callback());
+ $transactionManager->shouldReceive('addCallbackForRollback')->once()->andReturnNull();
+ $container->instance('db.transactions', $transactionManager);
+
+ $metadata = null;
+ SyncQueue::createPayloadUsing(function () use (&$metadata): array {
+ $metadata = ContextRepository::getInstance()->allHidden();
+
+ return [];
+ });
+
+ /** @var SyncQueue $queue */
+ $queue = new $queueClass;
+ $queue->setContainer($container);
+ $queue->setConnectionName('test');
+
+ $job = new UniqueJobPayloadContextJob('after-commit');
+ UniqueJobPayloadContext::register($job);
+
+ if ($delayed) {
+ $queue->later(5, $job);
+ } else {
+ $queue->push($job);
+ }
+
+ $this->assertSame([
+ 'laravel_unique_job_cache_store' => 'unique',
+ 'laravel_unique_job_key' => 'laravel_unique_job:' . UniqueJobPayloadContextJob::class . ':after-commit',
+ ], $metadata);
+ $this->assertNull(UniqueJobPayloadContext::consume($job));
+ }
+
+ public static function afterCommitPayloadBuilders(): array
+ {
+ return [
+ 'sync push' => [UniqueJobPayloadSyncQueue::class, false],
+ 'background push' => [UniqueJobPayloadBackgroundQueue::class, false],
+ 'deferred push' => [UniqueJobPayloadDeferredQueue::class, false],
+ 'background later' => [UniqueJobPayloadBackgroundQueue::class, true],
+ 'deferred later' => [UniqueJobPayloadDeferredQueue::class, true],
+ ];
+ }
+}
+
+class UniqueJobPayloadContextJob implements ShouldBeUnique
+{
+ public bool $afterCommit = true;
+
+ public function __construct(
+ public string $id
+ ) {
+ }
+
+ public function uniqueId(): string
+ {
+ return $this->id;
+ }
+
+ public function uniqueVia(): Repository
+ {
+ return new Repository(new WorkerArrayStore, ['store' => 'unique']);
+ }
+}
+
+class UniqueJobPayloadSyncQueue extends SyncQueue
+{
+ protected function executePayload(string $payload, ?string $queue = null): int
+ {
+ return 0;
+ }
+}
+
+class UniqueJobPayloadBackgroundQueue extends BackgroundQueue
+{
+ protected function executePayload(string $payload, ?string $queue = null): int
+ {
+ return 0;
+ }
+
+ protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, string $payload, ?string $queue): int
+ {
+ return 1;
+ }
+}
+
+class UniqueJobPayloadDeferredQueue extends DeferredQueue
+{
+ protected function executePayload(string $payload, ?string $queue = null): int
+ {
+ return 0;
+ }
+
+ protected function scheduleTimer(DateInterval|DateTimeInterface|int $delay, string $payload, ?string $queue): int
+ {
+ return 1;
+ }
+}
diff --git a/tests/Integration/Queue/JobDispatchingTest.php b/tests/Integration/Queue/JobDispatchingTest.php
index d046355c7..32719cfd4 100644
--- a/tests/Integration/Queue/JobDispatchingTest.php
+++ b/tests/Integration/Queue/JobDispatchingTest.php
@@ -11,13 +11,16 @@
use Hypervel\Coroutine\Coroutine;
use Hypervel\Engine\Channel;
use Hypervel\Foundation\Bus\Dispatchable;
+use Hypervel\Log\Context\Repository as ContextRepository;
use Hypervel\Queue\Events\JobQueued;
use Hypervel\Queue\Events\JobQueueing;
use Hypervel\Queue\InteractsWithQueue;
+use Hypervel\Queue\Queue;
use Hypervel\Support\Facades\Bus;
use Hypervel\Support\Facades\Config;
use Hypervel\Testbench\Attributes\WithMigration;
use Hypervel\Tests\Integration\Queue\QueueTestCase;
+use RuntimeException;
#[WithMigration]
#[WithMigration('queue')]
@@ -33,7 +36,7 @@ protected function setUp(): void
parent::setUp();
}
- public function testJobCanUseCustomMethodsAfterDispatch()
+ public function testJobCanUseCustomMethodsAfterDispatch(): void
{
Job::dispatch('test')->replaceValue('new-test');
@@ -43,7 +46,7 @@ public function testJobCanUseCustomMethodsAfterDispatch()
$this->assertSame('new-test', Job::$value);
}
- public function testDispatchesConditionallyWithBoolean()
+ public function testDispatchesConditionallyWithBoolean(): void
{
Job::dispatchIf(false, 'test')->replaceValue('new-test');
@@ -60,7 +63,7 @@ public function testDispatchesConditionallyWithBoolean()
$this->assertSame('new-test', Job::$value);
}
- public function testDispatchesConditionallyWithClosure()
+ public function testDispatchesConditionallyWithClosure(): void
{
Job::dispatchIf(fn ($job) => $job instanceof Job ? 0 : 1, 'test')->replaceValue('new-test');
@@ -75,7 +78,7 @@ public function testDispatchesConditionallyWithClosure()
$this->assertTrue(Job::$ran);
}
- public function testDoesNotDispatchConditionallyWithBoolean()
+ public function testDoesNotDispatchConditionallyWithBoolean(): void
{
Job::dispatchUnless(true, 'test')->replaceValue('new-test');
@@ -92,7 +95,7 @@ public function testDoesNotDispatchConditionallyWithBoolean()
$this->assertSame('new-test', Job::$value);
}
- public function testDoesNotDispatchConditionallyWithClosure()
+ public function testDoesNotDispatchConditionallyWithClosure(): void
{
Job::dispatchUnless(fn ($job) => $job instanceof Job ? 1 : 0, 'test')->replaceValue('new-test');
@@ -107,7 +110,7 @@ public function testDoesNotDispatchConditionallyWithClosure()
$this->assertTrue(Job::$ran);
}
- public function testUniqueJobLockIsReleasedForJobDispatchedAfterResponse()
+ public function testUniqueJobLockIsReleasedForJobDispatchedAfterResponse(): void
{
// Use worker-array because unique locks must be visible across the after-response child coroutine.
Config::set('cache.default', 'worker-array');
@@ -147,7 +150,80 @@ public function testUniqueJobLockIsReleasedForJobDispatchedAfterResponse()
$this->assertFalse(UniqueJob::$ran);
}
- public function testQueueMayBeNullForJobQueueingAndJobQueuedEvent()
+ public function testUniqueJobMetadataIsIncludedWhenPayloadIsCreatedAfterResponse(): void
+ {
+ Config::set('cache.default', 'worker-array');
+
+ $payload = null;
+ Queue::createPayloadUsing(function (string $connection, ?string $queue, array $currentPayload) use (&$payload): array {
+ if (($currentPayload['data']['commandName'] ?? null) instanceof UniqueJob) {
+ $payload = $currentPayload;
+ }
+
+ return [];
+ });
+
+ $this->dispatchAfterResponseInChildCoroutine(function (): void {
+ UniqueJob::dispatchAfterResponse('after-response-metadata');
+ });
+
+ $this->assertSame(
+ 'worker-array',
+ unserialize($payload['illuminate:log:context']['hidden']['laravel_unique_job_cache_store'])
+ );
+ $this->assertSame(
+ 'laravel_unique_job:' . UniqueJob::class . ':after-response-metadata',
+ unserialize($payload['illuminate:log:context']['hidden']['laravel_unique_job_key'])
+ );
+ }
+
+ public function testPayloadHookFailureDoesNotLeakUniqueMetadataIntoTheNextPayload(): void
+ {
+ Config::set('cache.default', 'worker-array');
+ ContextRepository::getInstance()->addHidden('persistent', 'value');
+
+ $throw = true;
+ $ordinaryPayload = null;
+
+ Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use (&$ordinaryPayload, &$throw): array {
+ if (($payload['data']['commandName'] ?? null) instanceof UniqueJob && $throw) {
+ throw new RuntimeException('Payload hook failed.');
+ }
+
+ if (($payload['data']['commandName'] ?? null) instanceof Job) {
+ $ordinaryPayload = $payload;
+ }
+
+ return [];
+ });
+
+ try {
+ UniqueJob::dispatch('payload-failure');
+ $this->fail('The payload hook should have failed.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Payload hook failed.', $exception->getMessage());
+ }
+
+ $this->assertSame(['persistent' => 'value'], ContextRepository::getInstance()->allHidden());
+
+ $throw = false;
+ Job::dispatch('ordinary');
+
+ $this->assertArrayNotHasKey(
+ 'laravel_unique_job_cache_store',
+ $ordinaryPayload['illuminate:log:context']['hidden']
+ );
+ $this->assertArrayNotHasKey(
+ 'laravel_unique_job_key',
+ $ordinaryPayload['illuminate:log:context']['hidden']
+ );
+ $this->assertSame(
+ 'value',
+ unserialize($ordinaryPayload['illuminate:log:context']['hidden']['persistent'])
+ );
+ }
+
+ public function testQueueMayBeNullForJobQueueingAndJobQueuedEvent(): void
{
Config::set('queue.default', 'database');
$events = [];
@@ -173,7 +249,7 @@ public function testQueueMayBeNullForJobQueueingAndJobQueuedEvent()
$this->assertNull($events[3]->queue);
}
- public function testQueuedClosureCanBeNamed()
+ public function testQueuedClosureCanBeNamed(): void
{
Config::set('queue.default', 'database');
$events = [];
@@ -190,7 +266,7 @@ public function testQueuedClosureCanBeNamed()
$this->assertStringContainsString('custom name', $events[0]->job->displayName());
}
- public function testCanDisableDispatchingAfterResponse()
+ public function testCanDisableDispatchingAfterResponse(): void
{
$ranBeforeCoroutineExit = false;
diff --git a/tests/Log/ContextQueueTest.php b/tests/Log/ContextQueueTest.php
index 93c1f5074..9890452c6 100644
--- a/tests/Log/ContextQueueTest.php
+++ b/tests/Log/ContextQueueTest.php
@@ -4,7 +4,11 @@
namespace Hypervel\Tests\Log;
+use Hypervel\Bus\UniqueJobPayloadContext;
+use Hypervel\Cache\Repository as CacheRepository;
+use Hypervel\Cache\WorkerArrayStore;
use Hypervel\Context\CoroutineContext;
+use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Contracts\Queue\ShouldQueue;
use Hypervel\Engine\Channel;
use Hypervel\Foundation\Bus\Dispatchable;
@@ -13,13 +17,15 @@
use Hypervel\Queue\BackgroundQueue;
use Hypervel\Queue\Events\JobProcessing;
use Hypervel\Queue\InteractsWithQueue;
+use Hypervel\Queue\Queue;
use Hypervel\Queue\SyncQueue;
use Hypervel\Testbench\TestCase;
use Mockery as m;
+use RuntimeException;
class ContextQueueTest extends TestCase
{
- public function testContextIsIncludedInJobPayload()
+ public function testContextIsIncludedInJobPayload(): void
{
Repository::getInstance()->add('trace_id', 'abc-123');
@@ -31,7 +37,7 @@ public function testContextIsIncludedInJobPayload()
$this->assertArrayHasKey('trace_id', $payload['illuminate:log:context']['data']);
}
- public function testEmptyContextDoesNotAddToPayload()
+ public function testEmptyContextDoesNotAddToPayload(): void
{
// Access context but don't add anything
Repository::getInstance();
@@ -42,7 +48,7 @@ public function testEmptyContextDoesNotAddToPayload()
$this->assertArrayNotHasKey('illuminate:log:context', $payload);
}
- public function testPayloadHookSkipsWhenNoContextExists()
+ public function testPayloadHookSkipsWhenNoContextExists(): void
{
$queue = $this->createSyncQueue();
$payload = $queue->testCreatePayload('SomeJob', null);
@@ -51,7 +57,7 @@ public function testPayloadHookSkipsWhenNoContextExists()
$this->assertFalse(Repository::hasInstance());
}
- public function testHiddenContextIsIncludedInJobPayload()
+ public function testHiddenContextIsIncludedInJobPayload(): void
{
Repository::getInstance()->addHidden('api_key', 'secret-token');
@@ -63,7 +69,70 @@ public function testHiddenContextIsIncludedInJobPayload()
$this->assertArrayHasKey('api_key', $payload['illuminate:log:context']['hidden']);
}
- public function testContextIsHydratedWhenJobProcesses()
+ public function testUniqueJobMetadataIsScopedToItsPayloadWithoutReplacingExistingContext(): void
+ {
+ $context = Repository::getInstance()
+ ->add('trace_id', 'abc-123')
+ ->addHidden('persistent', 'value');
+
+ $job = new ContextQueueUniqueJob('unique-id');
+ UniqueJobPayloadContext::register($job);
+
+ $queue = $this->createSyncQueue();
+ $payload = $queue->testCreatePayload($job, null);
+
+ $this->assertSame('unique', unserialize($payload['illuminate:log:context']['hidden']['laravel_unique_job_cache_store']));
+ $this->assertSame(
+ 'laravel_unique_job:' . ContextQueueUniqueJob::class . ':unique-id',
+ unserialize($payload['illuminate:log:context']['hidden']['laravel_unique_job_key'])
+ );
+ $this->assertSame('value', unserialize($payload['illuminate:log:context']['hidden']['persistent']));
+ $this->assertSame('abc-123', $context->get('trace_id'));
+ $this->assertSame(['persistent' => 'value'], $context->allHidden());
+ $this->assertNull(UniqueJobPayloadContext::consume($job));
+ }
+
+ public function testUniqueJobMetadataScopeIsRestoredWhenAPayloadHookThrows(): void
+ {
+ $context = Repository::getInstance()->addHidden('persistent', 'value');
+ $job = new ContextQueueUniqueJob('failing-payload');
+ UniqueJobPayloadContext::register($job);
+
+ $throw = true;
+ Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use (&$throw): array {
+ if (($payload['data']['commandName'] ?? null) instanceof ContextQueueUniqueJob && $throw) {
+ throw new RuntimeException('Payload hook failed.');
+ }
+
+ return [];
+ });
+
+ $queue = $this->createSyncQueue();
+
+ try {
+ $queue->testCreatePayload($job, null);
+ $this->fail('The payload hook should have failed.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('Payload hook failed.', $exception->getMessage());
+ }
+
+ $this->assertSame(['persistent' => 'value'], $context->allHidden());
+ $this->assertNull(UniqueJobPayloadContext::consume($job));
+
+ $throw = false;
+ $payload = $queue->testCreatePayload(new ContextQueueTestJob, null);
+
+ $this->assertArrayNotHasKey(
+ 'laravel_unique_job_cache_store',
+ $payload['illuminate:log:context']['hidden']
+ );
+ $this->assertArrayNotHasKey(
+ 'laravel_unique_job_key',
+ $payload['illuminate:log:context']['hidden']
+ );
+ }
+
+ public function testContextIsHydratedWhenJobProcesses(): void
{
// Build a payload with context
Repository::getInstance()->add('trace_id', 'abc-123');
@@ -88,7 +157,7 @@ public function testContextIsHydratedWhenJobProcesses()
$this->assertSame('token', Repository::getInstance()->getHidden('secret'));
}
- public function testHydrateSkipsWhenPayloadHasNoContext()
+ public function testHydrateSkipsWhenPayloadHasNoContext(): void
{
$job = m::mock(\Hypervel\Contracts\Queue\Job::class);
$job->shouldReceive('payload')->andReturn(['job' => 'SomeJob']);
@@ -116,7 +185,7 @@ public function testPayloadWithoutContextFlushesAnExistingRepository(): void
$this->assertSame([], $repository->allHidden());
}
- public function testDehydratingHookFiresBeforeJobDispatch()
+ public function testDehydratingHookFiresBeforeJobDispatch(): void
{
$called = false;
@@ -135,7 +204,7 @@ public function testDehydratingHookFiresBeforeJobDispatch()
$this->assertArrayHasKey('dehydrated_at', $payload['illuminate:log:context']['data']);
}
- public function testHydratedHookFiresWhenJobProcesses()
+ public function testHydratedHookFiresWhenJobProcesses(): void
{
$called = false;
@@ -159,7 +228,7 @@ public function testHydratedHookFiresWhenJobProcesses()
$this->assertTrue($called);
}
- public function testDehydratingCallbackCanModifyWithoutAffectingOriginal()
+ public function testDehydratingCallbackCanModifyWithoutAffectingOriginal(): void
{
Repository::getInstance()->add('trace_id', 'abc-123');
Repository::getInstance()->dehydrating(function (Repository $context) {
@@ -179,7 +248,7 @@ public function testDehydratingCallbackCanModifyWithoutAffectingOriginal()
$this->assertNull(Repository::getInstance()->get('extra'));
}
- public function testRoundTripPreservesVariousDataTypes()
+ public function testRoundTripPreservesVariousDataTypes(): void
{
Repository::getInstance()->add('string', 'hello');
Repository::getInstance()->add('integer', 42);
@@ -213,7 +282,7 @@ public function testRoundTripPreservesVariousDataTypes()
$this->assertSame('hidden-value', Repository::getInstance()->getHidden('secret'));
}
- public function testEndToEndSyncJobReceivesContext()
+ public function testEndToEndSyncJobReceivesContext(): void
{
ContextQueueTestJob::$receivedTraceId = null;
ContextQueueTestJob::$receivedSecret = null;
@@ -263,7 +332,7 @@ protected function createSyncQueue(): TestableSyncQueue
*/
class TestableSyncQueue extends SyncQueue
{
- public function testCreatePayload(string $job, ?string $queue): array
+ public function testCreatePayload(object|string $job, ?string $queue): array
{
return $this->createPayloadArray($job, $queue);
}
@@ -302,3 +371,21 @@ public function handle(): void
static::$completed?->push(true);
}
}
+
+class ContextQueueUniqueJob implements ShouldBeUnique
+{
+ public function __construct(
+ public string $id
+ ) {
+ }
+
+ public function uniqueId(): string
+ {
+ return $this->id;
+ }
+
+ public function uniqueVia(): CacheRepository
+ {
+ return new CacheRepository(new WorkerArrayStore, ['store' => 'unique']);
+ }
+}
From 5d34ed0fb0ce354e157aa9d61dacd61213e398eb Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:41:47 +0000
Subject: [PATCH 09/22] feat(bus): restore dispatcher and fake parity
Add grouped bulk dispatch to the dispatcher and queueing contract, and bind the QueueingDispatcher alias through the same fakeable base dispatcher used by the public Bus surface.
Bring the preparation contract, facade annotations, Bus fake, and pending-batch fake in line with the current upstream behavior, including boolean-or-void preparation results, bulk routing, array batch assertions, sync and after-response assertions, nullable fake returns, and correctly serialized batched jobs at the record boundary.
Keep provider wiring on typed batching configuration and add focused unit, integration, and Testbench coverage for the public contracts, fake behavior, dispatch preparation, and corrected upstream regression cases.
---
src/bus/src/BusServiceProvider.php | 6 +-
src/bus/src/Dispatcher.php | 36 +
src/contracts/src/Bus/QueueingDispatcher.php | 5 +
.../src/Queue/PreparesForDispatch.php | 4 +-
src/support/src/Facades/Bus.php | 3 +-
src/support/src/Testing/Fakes/BusFake.php | 48 +-
.../src/Testing/Fakes/PendingBatchFake.php | 1 +
tests/Bus/BusDispatcherTest.php | 57 +
.../Queue/PreparesForDispatchTest.php | 68 ++
tests/Support/PendingBatchFakeTest.php | 10 +
tests/Support/SupportTestingBusFakeTest.php | 1088 ++++++++++++++++-
.../Integrations/DispatchJobTest.php | 13 +-
12 files changed, 1309 insertions(+), 30 deletions(-)
create mode 100644 tests/Integration/Queue/PreparesForDispatchTest.php
diff --git a/src/bus/src/BusServiceProvider.php b/src/bus/src/BusServiceProvider.php
index c770b89bb..c1b2199e7 100644
--- a/src/bus/src/BusServiceProvider.php
+++ b/src/bus/src/BusServiceProvider.php
@@ -31,7 +31,7 @@ public function register(): void
);
$this->app->alias(
- Dispatcher::class,
+ DispatcherContract::class,
QueueingDispatcherContract::class,
);
}
@@ -45,11 +45,13 @@ protected function registerBatchServices(): void
return $app->make(DatabaseBatchRepository::class);
});
+ // DynamoDB batch storage is intentionally unsupported because Hypervel does not support DynamoDB databases.
+
$this->app->singleton(DatabaseBatchRepository::class, function ($app) {
return new DatabaseBatchRepository(
$app->make(BatchFactory::class),
$app->make('db'),
- $app->make('config')->string('queue.batching.table', 'job_batches'),
+ $app->make('config')->string('queue.batching.table'),
$app->make('config')->get('queue.batching.database'),
);
});
diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php
index 9a772256f..0c70aee5e 100644
--- a/src/bus/src/Dispatcher.php
+++ b/src/bus/src/Dispatcher.php
@@ -115,6 +115,42 @@ public function dispatchNow(mixed $command, mixed $handler = null): mixed
->then($callback);
}
+ /**
+ * Dispatch multiple commands in bulk to their appropriate handlers on the queue.
+ */
+ public function bulk(iterable $jobs): void
+ {
+ $groups = [];
+
+ foreach ($jobs as $job) {
+ if (! $this->queueResolver || ! $this->commandShouldBeQueued($job)) {
+ $this->dispatchNow($job);
+
+ continue;
+ }
+
+ $connection = $this->getAttributeValue($job, Connection::class, 'connection')
+ ?? $this->resolveConnectionFromQueueRoute($job)
+ ?? null;
+
+ $queue = $this->getAttributeValue($job, QueueAttribute::class, 'queue')
+ ?? $this->resolveQueueFromQueueRoute($job)
+ ?? null;
+
+ $groups[$connection . ':' . $queue]['connection'] = $connection;
+ $groups[$connection . ':' . $queue]['queue'] = $queue;
+ $groups[$connection . ':' . $queue]['jobs'][] = $job;
+ }
+
+ foreach ($groups as $group) {
+ ($this->queueResolver)($group['connection'])->bulk(
+ $group['jobs'],
+ '',
+ $group['queue']
+ );
+ }
+ }
+
/**
* Attempt to find the batch with the given ID.
*/
diff --git a/src/contracts/src/Bus/QueueingDispatcher.php b/src/contracts/src/Bus/QueueingDispatcher.php
index f607cb675..6eb15433d 100644
--- a/src/contracts/src/Bus/QueueingDispatcher.php
+++ b/src/contracts/src/Bus/QueueingDispatcher.php
@@ -19,6 +19,11 @@ public function findBatch(string $batchId): ?Batch;
*/
public function batch(mixed $jobs): PendingBatch;
+ /**
+ * Dispatch an iterable of jobs in bulk.
+ */
+ public function bulk(iterable $jobs): void;
+
/**
* Dispatch a command to its appropriate handler behind a queue.
*/
diff --git a/src/contracts/src/Queue/PreparesForDispatch.php b/src/contracts/src/Queue/PreparesForDispatch.php
index 85a229bcd..d6734fd8e 100644
--- a/src/contracts/src/Queue/PreparesForDispatch.php
+++ b/src/contracts/src/Queue/PreparesForDispatch.php
@@ -8,6 +8,8 @@ interface PreparesForDispatch
{
/**
* Run preparation logic before dispatch. Return false to abort.
+ *
+ * @return bool|void
*/
- public function prepareForDispatch(): bool;
+ public function prepareForDispatch();
}
diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php
index a71ec7714..81dec5aeb 100644
--- a/src/support/src/Facades/Bus.php
+++ b/src/support/src/Facades/Bus.php
@@ -13,6 +13,7 @@
* @method static mixed dispatch(mixed $command)
* @method static mixed dispatchSync(mixed $command, mixed $handler = null)
* @method static mixed dispatchNow(mixed $command, mixed $handler = null)
+ * @method static void bulk(iterable $jobs)
* @method static \Hypervel\Bus\Batch|null findBatch(string $batchId)
* @method static \Hypervel\Bus\PendingBatch batch(mixed $jobs)
* @method static \Hypervel\Foundation\Bus\PendingChain chain(mixed $jobs = null)
@@ -42,7 +43,7 @@
* @method static void assertNothingChained()
* @method static void assertDispatchedWithoutChain(\Closure|string $command, callable|null $callback = null)
* @method static \Hypervel\Support\Testing\Fakes\ChainedBatchTruthTest chainedBatch(\Closure $callback)
- * @method static void assertBatched(callable $callback)
+ * @method static void assertBatched(array|callable $callback)
* @method static void assertBatchCount(int $count)
* @method static void assertNothingBatched()
* @method static void assertNothingPlaced()
diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php
index 3620edcb9..b4dbe08f3 100644
--- a/src/support/src/Testing/Fakes/BusFake.php
+++ b/src/support/src/Testing/Fakes/BusFake.php
@@ -164,9 +164,11 @@ public function assertNotDispatched(Closure|string $command, ?callable $callback
*/
public function assertNothingDispatched(): void
{
- $commandNames = implode("\n- ", array_keys($this->commands));
+ $dispatchedCommands = $this->commands + $this->commandsSync + $this->commandsAfterResponse;
- PHPUnit::assertEmpty($this->commands, "The following jobs were dispatched unexpectedly:\n\n- {$commandNames}\n");
+ $commandNames = implode("\n- ", array_keys($dispatchedCommands));
+
+ PHPUnit::assertEmpty($dispatchedCommands, "The following jobs were dispatched unexpectedly:\n\n- {$commandNames}\n");
}
/**
@@ -400,10 +402,10 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex
return false;
}
} elseif (is_string($chain[$index])) {
- if ($chain[$index] != get_class(unserialize($serializedChainedJob))) {
+ if ($chain[$index] !== get_class(unserialize($serializedChainedJob))) {
return false;
}
- } elseif (serialize($chain[$index]) != $serializedChainedJob) {
+ } elseif (serialize($chain[$index]) !== $serializedChainedJob) {
return false;
}
}
@@ -425,8 +427,10 @@ public function chainedBatch(Closure $callback): ChainedBatchTruthTest
/**
* Assert if a batch was dispatched based on a truth-test callback.
*/
- public function assertBatched(callable $callback): void
+ public function assertBatched(callable|array $callback): void
{
+ $callback = is_array($callback) ? fn (PendingBatchFake $batch) => $batch->hasJobs($callback) : $callback;
+
PHPUnit::assertTrue(
$this->batched($callback)->count() > 0,
'The expected batch was not dispatched.'
@@ -550,7 +554,9 @@ public function hasDispatchedAfterResponse(string $command): bool
public function dispatch(mixed $command): mixed
{
if ($this->shouldFakeJob($command)) {
- return $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+ $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+
+ return null;
}
return $this->dispatcher->dispatch($command);
}
@@ -563,7 +569,9 @@ public function dispatch(mixed $command): mixed
public function dispatchSync(mixed $command, mixed $handler = null): mixed
{
if ($this->shouldFakeJob($command)) {
- return $this->commandsSync[get_class($command)][] = $this->getCommandRepresentation($command);
+ $this->commandsSync[get_class($command)][] = $this->getCommandRepresentation($command);
+
+ return null;
}
return $this->dispatcher->dispatchSync($command, $handler);
}
@@ -574,7 +582,9 @@ public function dispatchSync(mixed $command, mixed $handler = null): mixed
public function dispatchNow(mixed $command, mixed $handler = null): mixed
{
if ($this->shouldFakeJob($command)) {
- return $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+ $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+
+ return null;
}
return $this->dispatcher->dispatchNow($command, $handler);
}
@@ -585,7 +595,9 @@ public function dispatchNow(mixed $command, mixed $handler = null): mixed
public function dispatchToQueue(mixed $command): mixed
{
if ($this->shouldFakeJob($command)) {
- return $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+ $this->commands[get_class($command)][] = $this->getCommandRepresentation($command);
+
+ return null;
}
return $this->dispatcher->dispatchToQueue($command);
}
@@ -602,6 +614,16 @@ public function dispatchAfterResponse(mixed $command, mixed $handler = null): vo
$this->dispatcher->dispatchAfterResponse($command, $handler);
}
+ /**
+ * Dispatch multiple commands in bulk to their appropriate handlers on the queue.
+ */
+ public function bulk(iterable $jobs): void
+ {
+ foreach ($jobs as $job) {
+ $this->dispatch($job);
+ }
+ }
+
/**
* Create a new chain of queueable jobs.
*/
@@ -642,6 +664,12 @@ public function dispatchFakeBatch(string $name = ''): Batch
*/
public function recordPendingBatch(PendingBatch $pendingBatch): Batch
{
+ if ($this->serializeAndRestore) {
+ $pendingBatch->jobs = $pendingBatch->jobs->map(
+ fn (mixed $job): mixed => $this->getCommandRepresentation($job)
+ );
+ }
+
$this->batches[] = $pendingBatch;
return $this->batchRepository->store($pendingBatch);
@@ -682,7 +710,7 @@ protected function shouldDispatchCommand(mixed $command): bool
}
/**
- * Specify if commands should be serialized and restored when being batched.
+ * Specify if commands should be serialized and restored when dispatched or batched.
*/
public function serializeAndRestore(bool $serializeAndRestore = true): static
{
diff --git a/src/support/src/Testing/Fakes/PendingBatchFake.php b/src/support/src/Testing/Fakes/PendingBatchFake.php
index a0a35e86d..7d7c507bc 100644
--- a/src/support/src/Testing/Fakes/PendingBatchFake.php
+++ b/src/support/src/Testing/Fakes/PendingBatchFake.php
@@ -23,6 +23,7 @@ public function __construct(
protected BusFake $bus,
public Collection $jobs
) {
+ $this->jobs = $jobs->filter()->values();
}
/**
diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php
index 3c45ff0b9..52837eed2 100644
--- a/tests/Bus/BusDispatcherTest.php
+++ b/tests/Bus/BusDispatcherTest.php
@@ -192,6 +192,41 @@ public function testOnConnectionOnJobWhenDispatching()
Container::setInstance(null);
}
+
+ public function testDispatchBulk(): void
+ {
+ $container = new Container;
+ $container->instance('queue.routes', $queueRoutes = m::mock(QueueRoutes::class));
+ $queueRoutes->shouldReceive('getQueue')->andReturn(null);
+ $queueRoutes->shouldReceive('getConnection')->andReturn(null);
+ Container::setInstance($container);
+
+ $defaultQueue = m::mock(Queue::class);
+ $defaultQueue->shouldReceive('bulk')->once()->with(m::on(fn ($jobs) => count($jobs) === 2), '', null);
+ $defaultQueue->shouldReceive('bulk')->once()->with(m::on(fn ($jobs) => count($jobs) === 1), '', 'high');
+
+ $priorityQueue = m::mock(Queue::class);
+ $priorityQueue->shouldReceive('bulk')->once()->with(m::on(fn ($jobs) => count($jobs) === 1), '', 'high');
+
+ $dispatcher = new Dispatcher(
+ $container,
+ fn (?string $connection) => $connection === 'priority' ? $priorityQueue : $defaultQueue
+ );
+
+ $immediate = new BusDispatcherImmediateCommand;
+
+ $dispatcher->bulk([
+ new BusDispatcherQueueable,
+ new BusDispatcherQueueable,
+ new BusDispatcherTestSpecificQueueCommand,
+ new BusDispatcherTestSpecificConnectionAndQueueCommand,
+ $immediate,
+ ]);
+
+ $this->assertTrue($immediate->handled);
+
+ Container::setInstance(null);
+ }
}
class BusInjectionStub
@@ -227,6 +262,28 @@ class BusDispatcherTestSpecificQueueAndDelayCommand implements ShouldQueue
public $delay = 10;
}
+class BusDispatcherTestSpecificQueueCommand implements ShouldQueue
+{
+ public string $queue = 'high';
+}
+
+class BusDispatcherTestSpecificConnectionAndQueueCommand implements ShouldQueue
+{
+ public string $connection = 'priority';
+
+ public string $queue = 'high';
+}
+
+class BusDispatcherImmediateCommand
+{
+ public bool $handled = false;
+
+ public function handle(): void
+ {
+ $this->handled = true;
+ }
+}
+
#[QueueAttribute('foo')]
#[Delay(10)]
class BusDispatcherTestSpecificQueueAndDelayAttributesCommand implements ShouldQueue
diff --git a/tests/Integration/Queue/PreparesForDispatchTest.php b/tests/Integration/Queue/PreparesForDispatchTest.php
new file mode 100644
index 000000000..61e751d8e
--- /dev/null
+++ b/tests/Integration/Queue/PreparesForDispatchTest.php
@@ -0,0 +1,68 @@
+assertTrue(PreparesForDispatchVoidJob::$ran);
+ Queue::assertPushed(PreparesForDispatchVoidJob::class);
+ }
+}
+
+class PreparesForDispatchFalseJob implements PreparesForDispatch, ShouldQueue
+{
+ use Dispatchable;
+ use Queueable;
+
+ public function prepareForDispatch(): bool
+ {
+ return false;
+ }
+
+ public function handle(): void
+ {
+ }
+}
+
+class PreparesForDispatchVoidJob implements PreparesForDispatch, ShouldQueue
+{
+ use Dispatchable;
+ use Queueable;
+
+ public static bool $ran = false;
+
+ public function prepareForDispatch(): void
+ {
+ static::$ran = true;
+ }
+
+ public function handle(): void
+ {
+ }
+}
diff --git a/tests/Support/PendingBatchFakeTest.php b/tests/Support/PendingBatchFakeTest.php
index 1dd2b067e..44a8f83e2 100644
--- a/tests/Support/PendingBatchFakeTest.php
+++ b/tests/Support/PendingBatchFakeTest.php
@@ -37,6 +37,16 @@ public function testHasJobsMatchesObjectsClassesAndTypedClosuresInOrder(): void
]));
}
+ public function testJobsAreFilteredAndReindexed(): void
+ {
+ $first = new PendingBatchFakeJob('first');
+ $second = new PendingBatchFakeJob('second');
+
+ $batch = $this->batch([$first, null, false, $second]);
+
+ $this->assertSame([$first, $second], $batch->jobs->all());
+ }
+
private function batch(array $jobs): PendingBatchFake
{
$bus = new BusFake(m::mock(QueueingDispatcher::class));
diff --git a/tests/Support/SupportTestingBusFakeTest.php b/tests/Support/SupportTestingBusFakeTest.php
index 4cd951130..27c57acb1 100644
--- a/tests/Support/SupportTestingBusFakeTest.php
+++ b/tests/Support/SupportTestingBusFakeTest.php
@@ -4,8 +4,15 @@
namespace Hypervel\Tests\Support;
+use Hypervel\Bus\Batch;
+use Hypervel\Bus\Batchable;
+use Hypervel\Bus\Queueable;
+use Hypervel\Container\Container;
+use Hypervel\Contracts\Bus\Dispatcher;
use Hypervel\Contracts\Bus\QueueingDispatcher;
+use Hypervel\Support\Testing\Fakes\BatchRepositoryFake;
use Hypervel\Support\Testing\Fakes\BusFake;
+use Hypervel\Support\Testing\Fakes\PendingBatchFake;
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -13,24 +20,379 @@
class SupportTestingBusFakeTest extends TestCase
{
+ protected BusFake $fake;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->fake = new BusFake(m::mock(QueueingDispatcher::class));
+ }
+
+ public function testItUsesCustomBusRepository(): void
+ {
+ $busRepository = new BatchRepositoryFake;
+
+ $fake = new BusFake(m::mock(QueueingDispatcher::class), [], $busRepository);
+
+ $this->assertNull($fake->findBatch('non-existent-batch'));
+
+ $batch = $fake->batch([])->dispatch();
+
+ $this->assertSame($batch, $fake->findBatch($batch->id));
+ $this->assertSame($batch, $busRepository->find($batch->id));
+ }
+
+ public function testAssertDispatched(): void
+ {
+ try {
+ $this->fake->assertDispatched(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched.', $e->getMessage());
+ }
+
+ $this->fake->dispatch(new BusJobStub);
+
+ $this->fake->assertDispatched(BusJobStub::class);
+ }
+
+ public function testAssertDispatchedWithClosure(): void
+ {
+ $this->fake->dispatch(new BusJobStub);
+
+ $this->fake->assertDispatched(function (BusJobStub $job) {
+ return true;
+ });
+ }
+
+ public function testAssertDispatchedAfterResponse(): void
+ {
+ try {
+ $this->fake->assertDispatchedAfterResponse(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched after sending the response.', $e->getMessage());
+ }
+
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ $this->fake->assertDispatchedAfterResponse(BusJobStub::class);
+ }
+
+ public function testAssertDispatchedAfterResponseClosure(): void
+ {
+ try {
+ $this->fake->assertDispatchedAfterResponse(function (BusJobStub $job) {
+ return true;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched after sending the response.', $e->getMessage());
+ }
+ }
+
+ public function testAssertDispatchedSync(): void
+ {
+ try {
+ $this->fake->assertDispatchedSync(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched synchronously.', $e->getMessage());
+ }
+
+ $this->fake->dispatch(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatchedSync(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched synchronously.', $e->getMessage());
+ }
+
+ $this->fake->dispatchSync(new BusJobStub);
+
+ $this->fake->assertDispatchedSync(BusJobStub::class);
+ }
+
+ public function testAssertDispatchedSyncClosure(): void
+ {
+ try {
+ $this->fake->assertDispatchedSync(function (BusJobStub $job) {
+ return true;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was not dispatched synchronously.', $e->getMessage());
+ }
+ }
+
+ public function testAssertDispatchedNow(): void
+ {
+ $this->fake->dispatchNow(new BusJobStub);
+
+ $this->fake->assertDispatched(BusJobStub::class);
+ }
+
+ #[DataProvider('fakeReturnMethods')]
+ public function testFakeDispatchMethodsReturnNull(string $dispatchMethod): void
+ {
+ $this->assertNull($this->fake->{$dispatchMethod}(new BusJobStub));
+ }
+
+ public static function fakeReturnMethods(): array
+ {
+ return [
+ ['dispatch'],
+ ['dispatchSync'],
+ ['dispatchNow'],
+ ['dispatchToQueue'],
+ ];
+ }
+
+ public function testAssertDispatchedWithCallbackInt(): void
+ {
+ $this->fake->dispatch(new BusJobStub);
+ $this->fake->dispatchNow(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatched(BusJobStub::class, 1);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatched(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedAfterResponseWithCallbackInt(): void
+ {
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatchedAfterResponse(BusJobStub::class, 1);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedAfterResponse(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedSyncWithCallbackInt(): void
+ {
+ $this->fake->dispatchSync(new BusJobStub);
+ $this->fake->dispatchSync(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatchedSync(BusJobStub::class, 1);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was synchronously pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedSync(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedWithCallbackFunction(): void
+ {
+ $this->fake->dispatch(new OtherBusJobStub);
+ $this->fake->dispatchNow(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatched(OtherBusJobStub::class, function ($job) {
+ return $job->id === 0;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was not dispatched.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatched(OtherBusJobStub::class, function ($job) {
+ return $job->id === null;
+ });
+
+ $this->fake->assertDispatched(OtherBusJobStub::class, function ($job) {
+ return $job->id === 1;
+ });
+ }
+
+ public function testAssertDispatchedAfterResponseWithCallbackFunction(): void
+ {
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub);
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatchedAfterResponse(OtherBusJobStub::class, function ($job) {
+ return $job->id === 0;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was not dispatched after sending the response.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedAfterResponse(OtherBusJobStub::class, function ($job) {
+ return $job->id === null;
+ });
+
+ $this->fake->assertDispatchedAfterResponse(OtherBusJobStub::class, function ($job) {
+ return $job->id === 1;
+ });
+ }
+
+ public function testAssertDispatchedAfterResponseTimesWithCallbackFunction(): void
+ {
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub(0));
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub(1));
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatchedAfterResponseTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ }, 2);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was pushed 1 time instead of 2 times.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedAfterResponseTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ });
+
+ $this->fake->assertDispatchedAfterResponseTimes(function (OtherBusJobStub $job) {
+ return $job->id === 1;
+ }, 2);
+ }
+
+ public function testAssertDispatchedSyncWithCallbackFunction(): void
+ {
+ $this->fake->dispatchSync(new OtherBusJobStub);
+ $this->fake->dispatchSync(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) {
+ return $job->id === 0;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was not dispatched synchronously.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) {
+ return $job->id === null;
+ });
+
+ $this->fake->assertDispatchedSync(OtherBusJobStub::class, function ($job) {
+ return $job->id === 1;
+ });
+ }
+
public function testAssertDispatchedOnce(): void
{
- $fake = new BusFake(m::mock(QueueingDispatcher::class));
+ $this->fake->dispatch(new BusJobStub);
+ $this->fake->dispatchNow(new BusJobStub);
- $fake->dispatch(new BusFakeJobStub);
- $fake->assertDispatchedOnce(BusFakeJobStub::class);
+ try {
+ $this->fake->assertDispatchedOnce(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedTimes(BusJobStub::class, 2);
+ }
- $fake->dispatchNow(new BusFakeJobStub);
+ public function testAssertDispatchedTimes(): void
+ {
+ $this->fake->dispatch(new BusJobStub);
+ $this->fake->dispatchNow(new BusJobStub);
try {
- $fake->assertDispatchedOnce(BusFakeJobStub::class);
+ $this->fake->assertDispatchedTimes(BusJobStub::class, 1);
$this->fail();
- } catch (ExpectationFailedException $exception) {
- $this->assertStringContainsString(
- 'The expected [' . BusFakeJobStub::class . '] job was pushed 2 times instead of 1 time.',
- $exception->getMessage()
- );
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedTimes(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedTimesWithCallbackFunction(): void
+ {
+ $this->fake->dispatch(new OtherBusJobStub(0));
+ $this->fake->dispatchNow(new OtherBusJobStub(1));
+ $this->fake->dispatchAfterResponse(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatchedTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ }, 2);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was pushed 1 time instead of 2 times.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ });
+
+ $this->fake->assertDispatchedTimes(function (OtherBusJobStub $job) {
+ return $job->id === 1;
+ }, 2);
+ }
+
+ public function testAssertDispatchedAfterResponseTimes(): void
+ {
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatchedAfterResponseTimes(BusJobStub::class, 1);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedAfterResponseTimes(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedSyncTimes(): void
+ {
+ $this->fake->dispatchSync(new BusJobStub);
+ $this->fake->dispatchSync(new BusJobStub);
+
+ try {
+ $this->fake->assertDispatchedSyncTimes(BusJobStub::class, 1);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\BusJobStub] job was synchronously pushed 2 times instead of 1 time.', $e->getMessage());
+ }
+
+ $this->fake->assertDispatchedSyncTimes(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedSyncTimesWithCallbackFunction(): void
+ {
+ $this->fake->dispatchSync(new OtherBusJobStub(0));
+ $this->fake->dispatchSync(new OtherBusJobStub(1));
+ $this->fake->dispatchSync(new OtherBusJobStub(1));
+
+ try {
+ $this->fake->assertDispatchedSyncTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ }, 2);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected [Hypervel\Tests\Support\OtherBusJobStub] job was synchronously pushed 1 time instead of 2 times.', $e->getMessage());
}
+
+ $this->fake->assertDispatchedSyncTimes(function (OtherBusJobStub $job) {
+ return $job->id === 0;
+ });
+
+ $this->fake->assertDispatchedSyncTimes(function (OtherBusJobStub $job) {
+ return $job->id === 1;
+ }, 2);
}
#[DataProvider('countAssertionMethods')]
@@ -39,15 +401,14 @@ public function testCountAssertionsPluralizeFailureMessages(
string $assertionMethod,
string $action
): void {
- $fake = new BusFake(m::mock(QueueingDispatcher::class));
- $fake->{$dispatchMethod}(new BusFakeJobStub);
+ $this->fake->{$dispatchMethod}(new BusJobStub);
try {
- $fake->{$assertionMethod}(BusFakeJobStub::class, 2);
+ $this->fake->{$assertionMethod}(BusJobStub::class, 2);
$this->fail();
} catch (ExpectationFailedException $exception) {
$this->assertStringContainsString(
- 'The expected [' . BusFakeJobStub::class . "] {$action} 1 time instead of 2 times.",
+ 'The expected [' . BusJobStub::class . "] {$action} 1 time instead of 2 times.",
$exception->getMessage()
);
}
@@ -61,8 +422,705 @@ public static function countAssertionMethods(): array
['dispatchAfterResponse', 'assertDispatchedAfterResponseTimes', 'job was pushed'],
];
}
+
+ public function testAssertNotDispatched(): void
+ {
+ $this->fake->assertNotDispatched(BusJobStub::class);
+
+ $this->fake->dispatch(new BusJobStub);
+ $this->fake->dispatchNow(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatched(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNotDispatchedWithClosure(): void
+ {
+ $this->fake->dispatch(new BusJobStub);
+ $this->fake->dispatchNow(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatched(function (BusJobStub $job) {
+ return true;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNotDispatchedAfterResponse(): void
+ {
+ $this->fake->assertNotDispatchedAfterResponse(BusJobStub::class);
+
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatchedAfterResponse(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched after sending the response.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNotDispatchedAfterResponseClosure(): void
+ {
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatchedAfterResponse(function (BusJobStub $job) {
+ return true;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched after sending the response.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNotDispatchedSync(): void
+ {
+ $this->fake->assertNotDispatchedSync(BusJobStub::class);
+
+ $this->fake->dispatchSync(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatchedSync(BusJobStub::class);
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched synchronously.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNotDispatchedSyncClosure(): void
+ {
+ $this->fake->dispatchSync(new BusJobStub);
+
+ try {
+ $this->fake->assertNotDispatchedSync(function (BusJobStub $job) {
+ return true;
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The unexpected [Hypervel\Tests\Support\BusJobStub] job was dispatched synchronously.', $e->getMessage());
+ }
+ }
+
+ public function testAssertNothingDispatched(): void
+ {
+ $this->fake->assertNothingDispatched();
+
+ $this->fake->dispatch(new BusJobStub);
+
+ try {
+ $this->fake->assertNothingDispatched();
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The following jobs were dispatched unexpectedly:', $e->getMessage());
+ $this->assertStringContainsString(BusJobStub::class, $e->getMessage());
+ }
+ }
+
+ public function testAssertNothingDispatchedWithSyncDispatch(): void
+ {
+ $this->fake->assertNothingDispatched();
+
+ $this->fake->dispatchSync(new BusJobStub);
+
+ try {
+ $this->fake->assertNothingDispatched();
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The following jobs were dispatched unexpectedly:', $e->getMessage());
+ $this->assertStringContainsString(BusJobStub::class, $e->getMessage());
+ }
+ }
+
+ public function testAssertNothingDispatchedWithAfterResponseDispatch(): void
+ {
+ $this->fake->assertNothingDispatched();
+
+ $this->fake->dispatchAfterResponse(new BusJobStub);
+
+ try {
+ $this->fake->assertNothingDispatched();
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The following jobs were dispatched unexpectedly:', $e->getMessage());
+ $this->assertStringContainsString(BusJobStub::class, $e->getMessage());
+ }
+ }
+
+ public function testAssertChained(): void
+ {
+ Container::setInstance($container = new Container);
+
+ $container->instance(Dispatcher::class, $this->fake);
+
+ $this->fake->chain([
+ new ChainedJobStub,
+ ])->dispatch();
+
+ $this->fake->assertChained([
+ ChainedJobStub::class,
+ ]);
+
+ $this->fake->chain([
+ new ChainedJobStub,
+ new OtherBusJobStub,
+ ])->dispatch();
+
+ $this->fake->assertChained([
+ ChainedJobStub::class,
+ OtherBusJobStub::class,
+ ]);
+
+ $this->fake->chain([
+ new ChainedJobStub,
+ $this->fake->batch([
+ new OtherBusJobStub,
+ new OtherBusJobStub,
+ ]),
+ new ChainedJobStub,
+ ])->dispatch();
+
+ $this->fake->assertChained([
+ ChainedJobStub::class,
+ $this->fake->chainedBatch(function ($pendingBatch) {
+ return $pendingBatch->jobs->count() === 2;
+ }),
+ ChainedJobStub::class,
+ ]);
+
+ $this->fake->assertChained([
+ new ChainedJobStub,
+ $this->fake->chainedBatch(function ($pendingBatch) {
+ return $pendingBatch->jobs->count() === 2;
+ }),
+ new ChainedJobStub,
+ ]);
+
+ $this->fake->chain([
+ $this->fake->batch([
+ new OtherBusJobStub,
+ new OtherBusJobStub,
+ ]),
+ new ChainedJobStub,
+ new ChainedJobStub,
+ ])->dispatch();
+
+ $this->fake->assertChained([
+ $this->fake->chainedBatch(function ($pendingBatch) {
+ return $pendingBatch->jobs->count() === 2;
+ }),
+ ChainedJobStub::class,
+ ChainedJobStub::class,
+ ]);
+
+ $this->fake->chain([
+ new ChainedJobStub(123),
+ new ChainedJobStub(456),
+ ])->dispatch();
+
+ $this->fake->assertChained([
+ fn (ChainedJobStub $job) => $job->id === 123,
+ fn (ChainedJobStub $job) => $job->id === 456,
+ ]);
+
+ Container::setInstance(null);
+ }
+
+ public function testAssertNothingChained(): void
+ {
+ $this->fake->assertNothingChained();
+ }
+
+ public function testAssertNothingChainedFails(): void
+ {
+ $this->fake->chain([new ChainedJobStub])->dispatch();
+
+ try {
+ $this->fake->assertNothingChained();
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The following jobs were dispatched unexpectedly:', $e->getMessage());
+ $this->assertStringContainsString(ChainedJobStub::class, $e->getMessage());
+ }
+ }
+
+ public function testAssertDispatchedWithIgnoreClass(): void
+ {
+ $dispatcher = m::mock(QueueingDispatcher::class);
+
+ $job = new BusJobStub;
+ $dispatcher->shouldReceive('dispatch')->once()->with($job);
+ $dispatcher->shouldReceive('dispatchNow')->once()->with($job, null);
+
+ $otherJob = new OtherBusJobStub;
+ $dispatcher->shouldReceive('dispatch')->never()->with($otherJob);
+ $dispatcher->shouldReceive('dispatchNow')->never()->with($otherJob, null);
+
+ $fake = new BusFake($dispatcher, OtherBusJobStub::class);
+
+ $fake->dispatch($job);
+ $fake->dispatchNow($job);
+
+ $fake->dispatch($otherJob);
+ $fake->dispatchNow($otherJob);
+
+ $fake->assertNotDispatched(BusJobStub::class);
+ $fake->assertDispatchedTimes(OtherBusJobStub::class, 2);
+ }
+
+ public function testDispatchedFakingOnlyGivenJobs(): void
+ {
+ $dispatcher = m::mock(QueueingDispatcher::class);
+
+ $job = new BusJobStub;
+ $dispatcher->shouldReceive('dispatch')->never()->with($job);
+ $dispatcher->shouldReceive('dispatchNow')->never()->with($job, null);
+
+ $otherJob = new OtherBusJobStub;
+ $dispatcher->shouldReceive('dispatch')->once()->with($otherJob);
+ $dispatcher->shouldReceive('dispatchNow')->once()->with($otherJob, null);
+
+ $thirdJob = new ThirdJob;
+ $dispatcher->shouldReceive('dispatch')->never()->with($thirdJob);
+ $dispatcher->shouldReceive('dispatchNow')->never()->with($thirdJob, null);
+
+ $fake = (new BusFake($dispatcher))->except(OtherBusJobStub::class);
+
+ $fake->dispatch($job);
+ $fake->dispatchNow($job);
+
+ $fake->dispatch($otherJob);
+ $fake->dispatchNow($otherJob);
+
+ $fake->dispatch($thirdJob);
+ $fake->dispatchNow($thirdJob);
+
+ $fake->assertNotDispatched(OtherBusJobStub::class);
+ $fake->assertDispatchedTimes(BusJobStub::class, 2);
+ $fake->assertDispatchedTimes(ThirdJob::class, 2);
+ }
+
+ public function testBulkRecordsEachJob(): void
+ {
+ $this->fake->bulk([new BusJobStub, new BusJobStub]);
+
+ $this->fake->assertDispatchedTimes(BusJobStub::class, 2);
+ }
+
+ public function testAssertDispatchedWithIgnoreCallback(): void
+ {
+ $dispatcher = m::mock(QueueingDispatcher::class);
+
+ $job = new BusJobStub;
+ $dispatcher->shouldReceive('dispatch')->once()->with($job);
+ $dispatcher->shouldReceive('dispatchNow')->once()->with($job, null);
+
+ $otherJob = new OtherBusJobStub;
+ $dispatcher->shouldReceive('dispatch')->once()->with($otherJob);
+ $dispatcher->shouldReceive('dispatchNow')->once()->with($otherJob, null);
+
+ $anotherJob = new OtherBusJobStub(1);
+ $dispatcher->shouldReceive('dispatch')->never()->with($anotherJob);
+ $dispatcher->shouldReceive('dispatchNow')->never()->with($anotherJob, null);
+
+ $fake = new BusFake($dispatcher, [
+ function ($command) {
+ return $command instanceof OtherBusJobStub && $command->id === 1;
+ },
+ ]);
+
+ $fake->dispatch($job);
+ $fake->dispatchNow($job);
+
+ $fake->dispatch($otherJob);
+ $fake->dispatchNow($otherJob);
+
+ $fake->dispatch($anotherJob);
+ $fake->dispatchNow($anotherJob);
+
+ $fake->assertNotDispatched(BusJobStub::class);
+ $fake->assertDispatchedTimes(OtherBusJobStub::class, 2);
+ $fake->assertNotDispatched(OtherBusJobStub::class, function ($job) {
+ return $job->id === null;
+ });
+ $fake->assertDispatched(OtherBusJobStub::class, function ($job) {
+ return $job->id === 1;
+ });
+ }
+
+ public function testAssertNothingBatched(): void
+ {
+ $this->fake->assertNothingBatched();
+
+ $job = new BusJobStub;
+
+ $this->fake->batch([$job])->dispatch();
+
+ try {
+ $this->fake->assertNothingBatched();
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString("The following batched jobs were dispatched unexpectedly:\n\n- " . get_class($job), $e->getMessage());
+ }
+ }
+
+ public function testAssertNothingPlacedPasses(): void
+ {
+ $this->fake->assertNothingPlaced();
+ }
+
+ public function testAssertNothingPlacedWhenJobBatched(): void
+ {
+ $this->fake->batch([new BusJobStub])->dispatch();
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $this->fake->assertNothingPlaced();
+ }
+
+ public function testAssertNothingPlacedWhenJobDispatched(): void
+ {
+ $this->fake->dispatch(new BusJobStub);
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $this->fake->assertNothingPlaced();
+ }
+
+ public function testAssertNothingPlacedWhenJobChained(): void
+ {
+ $this->fake->chain([new ChainedJobStub])->dispatch();
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $this->fake->assertNothingPlaced();
+ }
+
+ public function testAssertNothingPlacedWhenJobDispatchedNow(): void
+ {
+ $this->fake->dispatchNow(new BusJobStub);
+
+ $this->expectException(ExpectationFailedException::class);
+
+ $this->fake->assertNothingPlaced();
+ }
+
+ public function testFindBatch(): void
+ {
+ $this->assertNull($this->fake->findBatch('non-existent-batch'));
+
+ $batch = $this->fake->batch([])->dispatch();
+
+ $this->assertSame($batch, $this->fake->findBatch($batch->id));
+ }
+
+ public function testBatchesCanBeCancelled(): void
+ {
+ $batch = $this->fake->batch([])->dispatch();
+
+ $this->assertFalse($batch->cancelled());
+
+ $batch->cancel();
+
+ $this->assertTrue($batch->cancelled());
+ }
+
+ public function testDispatchFakeBatch(): void
+ {
+ $this->fake->assertNothingBatched();
+
+ $batch = $this->fake->dispatchFakeBatch('my fake job batch');
+
+ $this->fake->assertBatchCount(1);
+ $this->assertInstanceOf(Batch::class, $batch);
+ $this->assertSame('my fake job batch', $batch->name);
+ $this->assertSame(0, $batch->totalJobs);
+
+ $batch = $this->fake->dispatchFakeBatch();
+
+ $this->fake->assertBatchCount(2);
+ $this->assertInstanceOf(Batch::class, $batch);
+ $this->assertSame('', $batch->name);
+ $this->assertSame(0, $batch->totalJobs);
+ }
+
+ public function testIncrementFailedJobsInFakeBatch(): void
+ {
+ $this->fake->assertNothingBatched();
+ $batch = $this->fake->dispatchFakeBatch('my fake job batch');
+
+ $this->fake->assertBatchCount(1);
+ $this->assertInstanceOf(Batch::class, $batch);
+ $this->assertSame('my fake job batch', $batch->name);
+ $this->assertSame(0, $batch->totalJobs);
+
+ $batch->incrementFailedJobs($batch->id);
+
+ $this->assertSame(0, $batch->failedJobs);
+ $this->assertSame(0, $batch->pendingJobs);
+ }
+
+ public function testDecrementPendingJobsInFakeBatch(): void
+ {
+ $this->fake->assertNothingBatched();
+ $batch = $this->fake->dispatchFakeBatch('my fake job batch');
+
+ $this->fake->assertBatchCount(1);
+ $this->assertInstanceOf(Batch::class, $batch);
+ $this->assertSame('my fake job batch', $batch->name);
+ $this->assertSame(0, $batch->totalJobs);
+
+ $batch->decrementPendingJobs($batch->id);
+
+ $this->assertSame(0, $batch->failedJobs);
+ $this->assertSame(0, $batch->pendingJobs);
+ }
+
+ #[DataProvider('serializeAndRestoreCommandMethodsDataProvider')]
+ public function testCanSerializeAndRestoreCommands(string $commandFunctionName, string $assertionFunctionName): void
+ {
+ $serializingBusFake = (clone $this->fake)->serializeAndRestore();
+
+ // without setting the serialization, the job should return the value passed in
+ $this->fake->{$commandFunctionName}(new BusFakeJobWithSerialization('hello'));
+ $this->fake->{$assertionFunctionName}(BusFakeJobWithSerialization::class, fn ($command) => $command->value === 'hello');
+
+ // when enabling the serializeAndRestore property, job has value modified
+ $serializingBusFake->{$commandFunctionName}(new BusFakeJobWithSerialization('hello'));
+ $serializingBusFake->{$assertionFunctionName}(
+ BusFakeJobWithSerialization::class,
+ fn ($command) => $command->value === 'hello-serialized-unserialized'
+ );
+ }
+
+ public static function serializeAndRestoreCommandMethodsDataProvider(): array
+ {
+ return [
+ 'dispatch' => ['dispatch', 'assertDispatched'],
+ 'dispatchSync' => ['dispatchSync', 'assertDispatchedSync'],
+ 'dispatchNow' => ['dispatchNow', 'assertDispatched'],
+ 'dispatchAfterResponse' => ['dispatchAfterResponse', 'assertDispatchedAfterResponse'],
+ ];
+ }
+
+ public function testCanSerializeAndRestoreCommandsInBatch(): void
+ {
+ $serializingBusFake = (clone $this->fake)->serializeAndRestore();
+
+ // without setting the serialization, the batch should return the value passed in
+ $this->fake->batch([
+ new BusFakeJobWithSerialization('hello'),
+ ])->dispatch();
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection): bool {
+ return $batchedCollection->jobs->count() === 1 && $batchedCollection->jobs->first()->value === 'hello';
+ });
+
+ // when enabling the serializeAndRestore property, each job in the batch will be serialized/restored
+ $pendingBatch = $serializingBusFake->batch([
+ new BusFakeJobWithSerialization('hello'),
+ ]);
+ $pendingBatch->add(new BusFakeJobWithSerialization('added'));
+ $pendingBatch->dispatch();
+
+ $serializingBusFake->assertBatched(function (PendingBatchFake $batchedCollection): bool {
+ return $batchedCollection->jobs->count() === 2
+ && $batchedCollection->jobs[0]->value === 'hello-serialized-unserialized'
+ && $batchedCollection->jobs[1]->value === 'added-serialized-unserialized';
+ });
+ }
+
+ public function testDispatchAfterResponseWithHandler(): void
+ {
+ $job = new BusJobStub;
+ $handler = function () {
+ return 'handled';
+ };
+
+ $this->fake->dispatchAfterResponse($job, $handler);
+
+ $this->fake->assertDispatchedAfterResponse(BusJobStub::class);
+ }
+
+ public function testCanAssertJobsOnPendingBatchFake(): void
+ {
+ $this->fake->batch([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ new BusFakeJobWithSerialization('baz'),
+ ])->dispatch();
+
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ new BusFakeJobWithSerialization('baz'),
+ ]);
+ });
+
+ $this->fake->assertBatched([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ new BusFakeJobWithSerialization('baz'),
+ ]);
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ new BusFakeJobWithSerialization('baz'),
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('baaar'),
+ new BusFakeJobWithSerialization('baz'),
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('baz'),
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ new BusFakeJobWithSerialization('baz'),
+ new BusFakeJobWithSerialization('qux'),
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+ }
+
+ public function testCanAssertJobsOnPendingBatchFakeWithClosures(): void
+ {
+ $this->fake->batch([
+ new BusFakeJobWithSerialization('foo'),
+ new BusFakeJobWithSerialization('bar'),
+ new BusFakeJobWithSerialization('baz'),
+ ])->dispatch();
+
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'foo',
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'bar',
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'baz',
+ ]);
+ });
+
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'foo',
+ BusFakeJobWithSerialization::class,
+ new BusFakeJobWithSerialization('baz'),
+ ]);
+ });
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'foo',
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'wrong',
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'baz',
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+
+ try {
+ $this->fake->assertBatched(function (PendingBatchFake $batchedCollection) {
+ return $batchedCollection->hasJobs([
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'foo',
+ fn (BusJobStub $job) => true,
+ fn (BusFakeJobWithSerialization $job) => $job->value === 'baz',
+ ]);
+ });
+ $this->fail();
+ } catch (ExpectationFailedException $e) {
+ $this->assertStringContainsString('The expected batch was not dispatched.', $e->getMessage());
+ }
+ }
}
-class BusFakeJobStub
+class BusJobStub
{
}
+
+class ChainedJobStub
+{
+ use Queueable;
+
+ public ?int $id;
+
+ public function __construct(?int $id = null)
+ {
+ $this->id = $id;
+ }
+}
+
+class OtherBusJobStub
+{
+ public ?int $id;
+
+ public function __construct(?int $id = null)
+ {
+ $this->id = $id;
+ }
+}
+
+class ThirdJob
+{
+}
+
+class BusFakeJobWithSerialization
+{
+ use Batchable;
+ use Queueable;
+
+ public function __construct(public string $value)
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return ['value' => $this->value . '-serialized'];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ $this->value = $data['value'] . '-unserialized';
+ }
+}
diff --git a/tests/Testbench/Integrations/DispatchJobTest.php b/tests/Testbench/Integrations/DispatchJobTest.php
index 8e468a068..ea0212722 100644
--- a/tests/Testbench/Integrations/DispatchJobTest.php
+++ b/tests/Testbench/Integrations/DispatchJobTest.php
@@ -4,7 +4,9 @@
namespace Hypervel\Tests\Testbench\Integrations;
+use Hypervel\Contracts\Bus\QueueingDispatcher;
use Hypervel\Support\Facades\Bus;
+use Hypervel\Support\Testing\Fakes\BusFake;
use Hypervel\Tests\Testbench\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Jobs\RegisterUser;
@@ -12,7 +14,7 @@
class DispatchJobTest extends TestCase
{
#[Test]
- public function itCanTriggersExpectedJobs()
+ public function itCanTriggersExpectedJobs(): void
{
Bus::fake();
@@ -20,4 +22,13 @@ public function itCanTriggersExpectedJobs()
Bus::assertDispatched(RegisterUser::class);
}
+
+ #[Test]
+ public function itResolvesTheQueueingDispatcherToTheBusFake(): void
+ {
+ $fake = Bus::fake();
+
+ $this->assertInstanceOf(BusFake::class, $fake);
+ $this->assertSame($fake, $this->app->make(QueueingDispatcher::class));
+ }
}
From 1304de6dfe6cd284c124408a829246925de1c64c Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:42:14 +0000
Subject: [PATCH 10/22] fix(bus): harden batch lifecycle and repositories
Complete the batch lifecycle with guarded started, finished, and canceled events, cancellation-aware job handling, finished-batch support, preserved routing metadata, and correct zero-valued identifiers and cursors.
Make database count updates and callback refreshes tolerate batches deleted or pruned during execution. Resolve pooled connections per operation instead of mutating a worker-shared repository, expose the read-only connection name, and tighten repository and configuration types.
Keep fake repositories, Horizon responses, and console output consistent with the real lifecycle, and add regression coverage for event gates, cancellation, missing rows, connection ownership, callback state, pagination boundaries, and exhaustive worker-global test cleanup.
---
src/bus/src/Batch.php | 80 +++++++-
src/bus/src/BatchRepository.php | 9 +-
src/bus/src/Batchable.php | 6 +-
src/bus/src/DatabaseBatchRepository.php | 63 +++---
src/bus/src/Events/BatchCanceled.php | 2 +
src/bus/src/Events/BatchStarted.php | 18 ++
src/bus/src/PendingBatch.php | 18 +-
.../Http/Controllers/BatchesController.php | 9 +-
src/queue/src/Console/BatchesTableCommand.php | 2 +-
src/support/src/Testing/Fakes/BatchFake.php | 2 +-
.../src/Testing/Fakes/BatchRepositoryFake.php | 13 +-
tests/Bus/BusBatchTest.php | 189 +++++++++++++++++-
tests/Bus/BusBatchableTest.php | 38 ++++
tests/Bus/BusPendingBatchTest.php | 59 +++++-
.../Controller/BatchesControllerTest.php | 12 ++
15 files changed, 446 insertions(+), 74 deletions(-)
create mode 100644 src/bus/src/Events/BatchStarted.php
diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php
index 9f0bf638d..b2f669de5 100644
--- a/src/bus/src/Batch.php
+++ b/src/bus/src/Batch.php
@@ -8,6 +8,7 @@
use Closure;
use Hypervel\Bus\Events\BatchCanceled;
use Hypervel\Bus\Events\BatchFinished;
+use Hypervel\Bus\Events\BatchStarted;
use Hypervel\Container\Container;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Queue\Factory as QueueFactory;
@@ -65,10 +66,17 @@ public function add(array|object $jobs): ?Batch
$chain = $this->prepareBatchedChain($job);
- return $chain->first()
- ->allOnQueue($this->options['queue'] ?? null)
- ->allOnConnection($this->options['connection'] ?? null)
- ->chain($chain->slice(1)->values()->all());
+ $first = $chain->first();
+
+ if (isset($this->options['queue'])) {
+ $first->allOnQueue($this->options['queue']);
+ }
+
+ if (isset($this->options['connection'])) {
+ $first->allOnConnection($this->options['connection']);
+ }
+
+ return $first->chain($chain->slice(1)->values()->all());
}
$job->withBatchId($this->id);
@@ -127,6 +135,22 @@ public function recordSuccessfulJob(string $jobId): void
{
$counts = $this->decrementPendingJobs($jobId);
+ if ($counts === null) {
+ return;
+ }
+
+ if ($this->isFirstJobProcessed($counts)) {
+ $container = Container::getInstance();
+
+ if ($container->bound(Dispatcher::class)) {
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchStarted::class)) {
+ $events->dispatch(new BatchStarted($this));
+ }
+ }
+ }
+
if ($this->hasProgressCallbacks()) {
$this->invokeCallbacks('progress');
}
@@ -137,7 +161,11 @@ public function recordSuccessfulJob(string $jobId): void
$container = Container::getInstance();
if ($container->bound(Dispatcher::class)) {
- $container->make(Dispatcher::class)->dispatch(new BatchFinished($this));
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchFinished::class)) {
+ $events->dispatch(new BatchFinished($this));
+ }
}
}
@@ -153,7 +181,7 @@ public function recordSuccessfulJob(string $jobId): void
/**
* Decrement the pending jobs for the batch.
*/
- public function decrementPendingJobs(string $jobId): UpdatedBatchJobCounts
+ public function decrementPendingJobs(string $jobId): ?UpdatedBatchJobCounts
{
return $this->repository->decrementPendingJobs($this->id, $jobId);
}
@@ -165,6 +193,10 @@ protected function invokeCallbacks(string $type, ?Throwable $e = null): void
{
$batch = $this->fresh();
+ if ($batch === null) {
+ return;
+ }
+
foreach ($this->options[$type] ?? [] as $handler) {
$this->invokeHandlerCallback($handler, $batch, $e);
}
@@ -217,8 +249,24 @@ public function recordFailedJob(string $jobId, ?Throwable $e): void
{
$counts = $this->incrementFailedJobs($jobId);
+ if ($counts === null) {
+ return;
+ }
+
+ if ($this->isFirstJobProcessed($counts)) {
+ $container = Container::getInstance();
+
+ if ($container->bound(Dispatcher::class)) {
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchStarted::class)) {
+ $events->dispatch(new BatchStarted($this));
+ }
+ }
+ }
+
if ($counts->failedJobs === 1 && ! $this->allowsFailures()) {
- $this->cancel();
+ $this->cancel($e);
}
if ($this->allowsFailures()) {
@@ -243,11 +291,19 @@ public function recordFailedJob(string $jobId, ?Throwable $e): void
/**
* Increment the failed jobs for the batch.
*/
- public function incrementFailedJobs(string $jobId): UpdatedBatchJobCounts
+ public function incrementFailedJobs(string $jobId): ?UpdatedBatchJobCounts
{
return $this->repository->incrementFailedJobs($this->id, $jobId);
}
+ /**
+ * Determine if this is the first job processed in the batch.
+ */
+ protected function isFirstJobProcessed(UpdatedBatchJobCounts $counts): bool
+ {
+ return $this->totalJobs - $counts->pendingJobs + $counts->failedJobs === 1;
+ }
+
/**
* Determine if the batch has "catch" callbacks.
*/
@@ -275,14 +331,18 @@ public function hasFinallyCallbacks(): bool
/**
* Cancel the batch.
*/
- public function cancel(): void
+ public function cancel(?Throwable $exception = null): void
{
$this->repository->cancel($this->id);
$container = Container::getInstance();
if ($container->bound(Dispatcher::class)) {
- $container->make(Dispatcher::class)->dispatch(new BatchCanceled($this));
+ $events = $container->make(Dispatcher::class);
+
+ if ($events->hasListeners(BatchCanceled::class)) {
+ $events->dispatch(new BatchCanceled($this, $exception));
+ }
}
}
diff --git a/src/bus/src/BatchRepository.php b/src/bus/src/BatchRepository.php
index b351fb341..0d7103040 100644
--- a/src/bus/src/BatchRepository.php
+++ b/src/bus/src/BatchRepository.php
@@ -33,12 +33,12 @@ public function incrementTotalJobs(int|string $batchId, int $amount): void;
/**
* Decrement the total number of pending jobs for the batch.
*/
- public function decrementPendingJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts;
+ public function decrementPendingJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts;
/**
* Increment the total number of failed jobs for the batch.
*/
- public function incrementFailedJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts;
+ public function incrementFailedJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts;
/**
* Mark the batch that has the given ID as finished.
@@ -57,6 +57,11 @@ public function delete(int|string $batchId): void;
/**
* Execute the given Closure within a storage specific transaction.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function transaction(Closure $callback): mixed;
diff --git a/src/bus/src/Batchable.php b/src/bus/src/Batchable.php
index 377743407..359a84e98 100644
--- a/src/bus/src/Batchable.php
+++ b/src/bus/src/Batchable.php
@@ -30,7 +30,7 @@ public function batch(): ?Batch
return $this->fakeBatch;
}
- if ($this->batchId) {
+ if ($this->batchId !== null && $this->batchId !== '') {
return Container::getInstance()->make(BatchRepository::class)->find($this->batchId);
}
@@ -44,7 +44,7 @@ public function batching(): bool
{
$batch = $this->batch();
- return $batch && ! $batch->cancelled();
+ return $batch && ! $batch->finished() && ! $batch->cancelled();
}
/**
@@ -75,7 +75,7 @@ public function withFakeBatch(
?CarbonImmutable $finishedAt = null,
): array {
$this->fakeBatch = new BatchFake(
- empty($id) ? (string) Str::uuid() : $id,
+ $id === '' ? (string) Str::uuid() : $id,
$name,
$totalJobs,
$pendingJobs,
diff --git a/src/bus/src/DatabaseBatchRepository.php b/src/bus/src/DatabaseBatchRepository.php
index 87c8f8f4a..26767ffc4 100644
--- a/src/bus/src/DatabaseBatchRepository.php
+++ b/src/bus/src/DatabaseBatchRepository.php
@@ -35,10 +35,10 @@ public function __construct(
*/
public function get(int $limit = 50, mixed $before = null): array
{
- return $this->connection()->table($this->table)
+ return $this->getConnection()->table($this->table)
->orderByDesc('id')
->limit($limit)
- ->when($before, fn ($q) => $q->where('id', '<', $before))
+ ->when($before !== null && $before !== '', fn ($q) => $q->where('id', '<', $before))
->get()
->map(function ($batch) {
return $this->toBatch($batch);
@@ -51,7 +51,7 @@ public function get(int $limit = 50, mixed $before = null): array
*/
public function find(int|string $batchId): ?Batch
{
- $batch = $this->connection()->table($this->table)
+ $batch = $this->getConnection()->table($this->table)
->useWritePdo()
->where('id', $batchId)
->first();
@@ -66,7 +66,7 @@ public function store(PendingBatch $batch): Batch
{
$id = (string) Str::orderedUuid();
- $this->connection()->table($this->table)->insert([
+ $this->getConnection()->table($this->table)->insert([
'id' => $id,
'name' => $batch->name,
'total_jobs' => 0,
@@ -93,7 +93,7 @@ public function store(PendingBatch $batch): Batch
*/
public function incrementTotalJobs(int|string $batchId, int $amount): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'total_jobs' => new Expression('total_jobs + ' . $amount),
'pending_jobs' => new Expression('pending_jobs + ' . $amount),
'finished_at' => null,
@@ -103,7 +103,7 @@ public function incrementTotalJobs(int|string $batchId, int $amount): void
/**
* Decrement the total number of pending jobs for the batch.
*/
- public function decrementPendingJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function decrementPendingJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
@@ -113,7 +113,7 @@ public function decrementPendingJobs(int|string $batchId, string $jobId): Update
];
});
- return new UpdatedBatchJobCounts(
+ return $values === null ? null : new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
@@ -122,7 +122,7 @@ public function decrementPendingJobs(int|string $batchId, string $jobId): Update
/**
* Increment the total number of failed jobs for the batch.
*/
- public function incrementFailedJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function incrementFailedJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
$values = $this->updateAtomicValues($batchId, function ($batch) use ($jobId) {
return [
@@ -132,7 +132,7 @@ public function incrementFailedJobs(int|string $batchId, string $jobId): Updated
];
});
- return new UpdatedBatchJobCounts(
+ return $values === null ? null : new UpdatedBatchJobCounts(
$values['pending_jobs'],
$values['failed_jobs']
);
@@ -143,13 +143,13 @@ public function incrementFailedJobs(int|string $batchId, string $jobId): Updated
*/
protected function updateAtomicValues(int|string $batchId, Closure $callback): ?array
{
- return $this->connection()->transaction(function () use ($batchId, $callback) {
- $batch = $this->connection()->table($this->table)->where('id', $batchId)
+ return $this->getConnection()->transaction(function () use ($batchId, $callback) {
+ $batch = $this->getConnection()->table($this->table)->where('id', $batchId)
->lockForUpdate()
->first();
- return is_null($batch) ? [] : tap($callback($batch), function ($values) use ($batchId) {
- $this->connection()->table($this->table)->where('id', $batchId)->update($values);
+ return is_null($batch) ? null : tap($callback($batch), function ($values) use ($batchId) {
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update($values);
});
});
}
@@ -159,7 +159,7 @@ protected function updateAtomicValues(int|string $batchId, Closure $callback): ?
*/
public function markAsFinished(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'finished_at' => time(),
]);
}
@@ -169,7 +169,7 @@ public function markAsFinished(int|string $batchId): void
*/
public function cancel(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->update([
+ $this->getConnection()->table($this->table)->where('id', $batchId)->update([
'cancelled_at' => time(),
'finished_at' => time(),
]);
@@ -180,7 +180,7 @@ public function cancel(int|string $batchId): void
*/
public function delete(int|string $batchId): void
{
- $this->connection()->table($this->table)->where('id', $batchId)->delete();
+ $this->getConnection()->table($this->table)->where('id', $batchId)->delete();
}
/**
@@ -188,7 +188,7 @@ public function delete(int|string $batchId): void
*/
public function prune(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNotNull('finished_at')
->where('finished_at', '<', $before->getTimestamp());
@@ -208,7 +208,7 @@ public function prune(DateTimeInterface $before): int
*/
public function pruneUnfinished(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNull('finished_at')
->where('created_at', '<', $before->getTimestamp());
@@ -228,7 +228,7 @@ public function pruneUnfinished(DateTimeInterface $before): int
*/
public function pruneCancelled(DateTimeInterface $before): int
{
- $query = $this->connection()->table($this->table)
+ $query = $this->getConnection()->table($this->table)
->whereNotNull('cancelled_at')
->where('created_at', '<', $before->getTimestamp());
@@ -245,10 +245,15 @@ public function pruneCancelled(DateTimeInterface $before): int
/**
* Execute the given Closure within a storage specific transaction.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function transaction(Closure $callback): mixed
{
- return $this->connection()->transaction(fn () => $callback());
+ return $this->getConnection()->transaction(fn () => $callback());
}
/**
@@ -256,7 +261,7 @@ public function transaction(Closure $callback): mixed
*/
public function rollBack(): void
{
- $this->connection()->rollBack();
+ $this->getConnection()->rollBack();
}
/**
@@ -266,7 +271,7 @@ protected function serialize(mixed $value): string
{
$serialized = serialize($value);
- return $this->connection() instanceof PostgresConnection
+ return $this->getConnection() instanceof PostgresConnection
? base64_encode($serialized)
: $serialized;
}
@@ -276,7 +281,7 @@ protected function serialize(mixed $value): string
*/
protected function unserialize(string $serialized): mixed
{
- if ($this->connection() instanceof PostgresConnection
+ if ($this->getConnection() instanceof PostgresConnection
&& ! Str::contains($serialized, [':', ';'])
) {
$serialized = base64_decode($serialized);
@@ -312,18 +317,10 @@ protected function toBatch(object $batch): Batch
/**
* Get the underlying database connection.
*/
- public function connection(): ConnectionInterface
+ public function getConnection(): ConnectionInterface
{
return $this->resolver->connection($this->connection);
}
- /**
- * Set the connection name to be used.
- */
- public function setConnection(string $connection): static
- {
- $this->connection = $connection;
-
- return $this;
- }
+ // REMOVED: A mutable connection override would race across coroutines on this worker singleton.
}
diff --git a/src/bus/src/Events/BatchCanceled.php b/src/bus/src/Events/BatchCanceled.php
index 1d7ac46ac..afe566f53 100644
--- a/src/bus/src/Events/BatchCanceled.php
+++ b/src/bus/src/Events/BatchCanceled.php
@@ -5,6 +5,7 @@
namespace Hypervel\Bus\Events;
use Hypervel\Bus\Batch;
+use Throwable;
class BatchCanceled
{
@@ -13,6 +14,7 @@ class BatchCanceled
*/
public function __construct(
public Batch $batch,
+ public ?Throwable $exception = null,
) {
}
}
diff --git a/src/bus/src/Events/BatchStarted.php b/src/bus/src/Events/BatchStarted.php
new file mode 100644
index 000000000..26e7e1b36
--- /dev/null
+++ b/src/bus/src/Events/BatchStarted.php
@@ -0,0 +1,18 @@
+container->make(EventDispatcher::class)->dispatch(
- new BatchDispatched($batch)
- );
+ $events = $this->container->make(EventDispatcher::class);
+
+ if ($events->hasListeners(BatchDispatched::class)) {
+ $events->dispatch(new BatchDispatched($batch));
+ }
return $batch;
}
@@ -350,9 +352,11 @@ protected function dispatchExistingBatch(Batch $batch): void
throw $e;
}
- $this->container->make(EventDispatcher::class)->dispatch(
- new BatchDispatched($batch)
- );
+ $events = $this->container->make(EventDispatcher::class);
+
+ if ($events->hasListeners(BatchDispatched::class)) {
+ $events->dispatch(new BatchDispatched($batch));
+ }
}
/**
diff --git a/src/horizon/src/Http/Controllers/BatchesController.php b/src/horizon/src/Http/Controllers/BatchesController.php
index a845e1679..172f114dd 100644
--- a/src/horizon/src/Http/Controllers/BatchesController.php
+++ b/src/horizon/src/Http/Controllers/BatchesController.php
@@ -9,6 +9,7 @@
use Hypervel\Horizon\Contracts\JobRepository;
use Hypervel\Horizon\Jobs\RetryFailedJob;
use Hypervel\Http\Request;
+use Hypervel\Support\Facades\Config;
use Hypervel\Support\Facades\DB;
class BatchesController extends Controller
@@ -49,15 +50,17 @@ private function searchBatches(Request $request): array
{
$pattern = '%' . addcslashes($request->query('query'), '\%_') . '%';
- return DB::connection(config('queue.batching.database'))
- ->table(config('queue.batching.table', 'job_batches'))
+ $beforeId = $request->query('before_id');
+
+ return DB::connection(Config::get('queue.batching.database'))
+ ->table(Config::string('queue.batching.table'))
->where(function ($q) use ($pattern) {
$q->whereRaw("lower(name) like lower(?) escape '\\'", [$pattern])
->orWhereRaw("lower(id) like lower(?) escape '\\'", [$pattern]);
})
->orderByDesc('id')
->limit(50)
- ->when($request->query('before_id'), fn ($q, $beforeId) => $q->where('id', '<', $beforeId))
+ ->when($beforeId !== null && $beforeId !== '', fn ($q) => $q->where('id', '<', $beforeId))
->pluck('id')
->map(fn ($id) => $this->batches->find($id))
->filter()
diff --git a/src/queue/src/Console/BatchesTableCommand.php b/src/queue/src/Console/BatchesTableCommand.php
index e34a93f4e..8e63163c0 100644
--- a/src/queue/src/Console/BatchesTableCommand.php
+++ b/src/queue/src/Console/BatchesTableCommand.php
@@ -34,7 +34,7 @@ class BatchesTableCommand extends MigrationGeneratorCommand
*/
protected function migrationTableName(): string
{
- return $this->hypervel->make('config')->string('queue.batching.table', 'job_batches');
+ return $this->hypervel->make('config')->string('queue.batching.table');
}
/**
diff --git a/src/support/src/Testing/Fakes/BatchFake.php b/src/support/src/Testing/Fakes/BatchFake.php
index 7f825bd5a..8a6e5128d 100644
--- a/src/support/src/Testing/Fakes/BatchFake.php
+++ b/src/support/src/Testing/Fakes/BatchFake.php
@@ -99,7 +99,7 @@ public function incrementFailedJobs(string $jobId): UpdatedBatchJobCounts
/**
* Cancel the batch.
*/
- public function cancel(): void
+ public function cancel(?Throwable $exception = null): void
{
$this->cancelledAt = CarbonImmutable::now();
}
diff --git a/src/support/src/Testing/Fakes/BatchRepositoryFake.php b/src/support/src/Testing/Fakes/BatchRepositoryFake.php
index 487812446..32dc2d5ac 100644
--- a/src/support/src/Testing/Fakes/BatchRepositoryFake.php
+++ b/src/support/src/Testing/Fakes/BatchRepositoryFake.php
@@ -72,17 +72,17 @@ public function incrementTotalJobs(int|string $batchId, int $amount): void
/**
* Decrement the total number of pending jobs for the batch.
*/
- public function decrementPendingJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function decrementPendingJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
- return new UpdatedBatchJobCounts;
+ return isset($this->batches[$batchId]) ? new UpdatedBatchJobCounts : null;
}
/**
* Increment the total number of failed jobs for the batch.
*/
- public function incrementFailedJobs(int|string $batchId, string $jobId): UpdatedBatchJobCounts
+ public function incrementFailedJobs(int|string $batchId, string $jobId): ?UpdatedBatchJobCounts
{
- return new UpdatedBatchJobCounts;
+ return isset($this->batches[$batchId]) ? new UpdatedBatchJobCounts : null;
}
/**
@@ -115,6 +115,11 @@ public function delete(int|string $batchId): void
/**
* Execute the given Closure within a storage specific transaction.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
*/
public function transaction(Closure $callback): mixed
{
diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php
index 5490f2fec..57afbf357 100644
--- a/tests/Bus/BusBatchTest.php
+++ b/tests/Bus/BusBatchTest.php
@@ -11,6 +11,7 @@
use Hypervel\Bus\DatabaseBatchRepository;
use Hypervel\Bus\Events\BatchCanceled;
use Hypervel\Bus\Events\BatchFinished;
+use Hypervel\Bus\Events\BatchStarted;
use Hypervel\Bus\PendingBatch;
use Hypervel\Bus\Queueable;
use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
@@ -59,9 +60,28 @@ protected function setUp(): void
protected function tearDown(): void
{
- parent::tearDown();
+ unset(
+ $_SERVER['__finally.count'],
+ $_SERVER['__progress.count'],
+ $_SERVER['__then.count'],
+ $_SERVER['__catch.count'],
+ $_SERVER['__finally.batch'],
+ $_SERVER['__progress.batch'],
+ $_SERVER['__then.batch'],
+ $_SERVER['__catch.batch'],
+ $_SERVER['__catch.exception'],
+ $_SERVER['__failure1.invoked'],
+ $_SERVER['__failure2.invoked'],
+ $_SERVER['__failure3.batch'],
+ $_SERVER['__failure3.exception'],
+ $_SERVER['__failure3.batch_id'],
+ $_SERVER['__failure3.batch_class'],
+ $_SERVER['__failure3.exception_class'],
+ $_SERVER['__failure3.exception_message'],
+ $_SERVER['__failure3.param_count'],
+ );
- unset($_SERVER['__finally.batch'], $_SERVER['__progress.batch'], $_SERVER['__then.batch'], $_SERVER['__catch.batch'], $_SERVER['__catch.exception']);
+ parent::tearDown();
}
public function testBatchRepositoryUsesDefaultDatabaseConnectionWhenBatchingDatabaseIsNull(): void
@@ -71,7 +91,22 @@ public function testBatchRepositoryUsesDefaultDatabaseConnectionWhenBatchingData
$repository = $this->app->make(DatabaseBatchRepository::class);
- $this->assertSame($this->app->make('db')->connection(), $repository->connection());
+ $this->assertSame($this->app->make('db')->connection(), $repository->getConnection());
+ }
+
+ public function testBatchRepositoryAppliesAZeroBeforeCursor(): void
+ {
+ $queue = m::mock(Factory::class);
+ $batch = $this->createTestBatch($queue);
+
+ $repository = new DatabaseBatchRepository(
+ new BatchFactory($queue),
+ $this->app->make('db'),
+ 'job_batches'
+ );
+
+ $this->assertSame($batch->id, $repository->get(50, null)[0]->id);
+ $this->assertSame([], $repository->get(50, '0'));
}
public function testJobsCanBeAddedToTheBatch()
@@ -219,6 +254,14 @@ public function testBatchFinishedEventIsDispatched()
$batch = $batch->add([$job]);
+ $events->shouldReceive('hasListeners')->once()->with(BatchStarted::class)->andReturnTrue();
+
+ $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch) {
+ return $event instanceof BatchStarted && $event->batch === $batch;
+ }));
+
+ $events->shouldReceive('hasListeners')->once()->with(BatchFinished::class)->andReturnTrue();
+
$events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch) {
return $event instanceof BatchFinished && $event->batch === $batch;
}));
@@ -226,6 +269,72 @@ public function testBatchFinishedEventIsDispatched()
$batch->recordSuccessfulJob('test-id');
}
+ public function testBatchStartedEventIsDispatchedOnceWhenTheFirstJobSucceeds(): void
+ {
+ $this->app->instance(EventDispatcher::class, $events = m::mock(EventDispatcher::class));
+
+ $queue = m::mock(Factory::class);
+ $batch = $this->createTestBatch($queue);
+
+ $firstJob = new class {
+ use Batchable;
+ };
+
+ $secondJob = new class {
+ use Batchable;
+ };
+
+ $queue->shouldReceive('connection')->once()
+ ->with('test-connection')
+ ->andReturn($connection = m::mock(QueueContract::class));
+
+ $connection->shouldReceive('bulk')->once();
+
+ $batch = $batch->add([$firstJob, $secondJob]);
+
+ $events->shouldReceive('hasListeners')->once()->with(BatchStarted::class)->andReturnTrue();
+ $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch) {
+ return $event instanceof BatchStarted && $event->batch === $batch;
+ }));
+ $events->shouldReceive('hasListeners')->once()->with(BatchFinished::class)->andReturnTrue();
+ $events->shouldReceive('dispatch')->once()->with(m::type(BatchFinished::class));
+
+ $batch->recordSuccessfulJob('test-id-1');
+ $batch->recordSuccessfulJob('test-id-2');
+ }
+
+ public function testBatchStartedEventIsDispatchedOnceWhenTheFirstJobFails(): void
+ {
+ $this->app->instance(EventDispatcher::class, $events = m::mock(EventDispatcher::class));
+
+ $queue = m::mock(Factory::class);
+ $batch = $this->createTestBatch($queue, $allowFailures = true);
+
+ $firstJob = new class {
+ use Batchable;
+ };
+
+ $secondJob = new class {
+ use Batchable;
+ };
+
+ $queue->shouldReceive('connection')->once()
+ ->with('test-connection')
+ ->andReturn($connection = m::mock(QueueContract::class));
+
+ $connection->shouldReceive('bulk')->once();
+
+ $batch = $batch->add([$firstJob, $secondJob]);
+
+ $events->shouldReceive('hasListeners')->once()->with(BatchStarted::class)->andReturnTrue();
+ $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch) {
+ return $event instanceof BatchStarted && $event->batch === $batch;
+ }));
+
+ $batch->recordFailedJob('test-id-1', new RuntimeException('Something went wrong.'));
+ $batch->recordFailedJob('test-id-2', new RuntimeException('Something else went wrong.'));
+ }
+
public function testFailedJobsCanBeRecordedWhileNotAllowingFailures()
{
$queue = m::mock(Factory::class);
@@ -404,11 +513,16 @@ public function testBatchCancelledEventIsDispatched()
$queue = m::mock(Factory::class);
$batch = $this->createTestBatch($queue);
- $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch) {
- return $event instanceof BatchCanceled && $event->batch->id === $batch->id;
+ $exception = new RuntimeException('Something went wrong.');
+
+ $events->shouldReceive('hasListeners')->once()->with(BatchCanceled::class)->andReturnTrue();
+ $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch, $exception) {
+ return $event instanceof BatchCanceled
+ && $event->batch->id === $batch->id
+ && $event->exception === $exception;
}));
- $batch->cancel();
+ $batch->cancel($exception);
}
public function testBatchCanBeDeleted()
@@ -424,6 +538,34 @@ public function testBatchCanBeDeleted()
$this->assertNull($batch);
}
+ public function testDeletedBatchIgnoresLateJobResultsAndCallbacks(): void
+ {
+ $queue = m::mock(Factory::class);
+ $batch = $this->createTestBatch($queue, $allowFailures = true);
+
+ $job = new class {
+ use Batchable;
+ };
+
+ $queue->shouldReceive('connection')->once()
+ ->with('test-connection')
+ ->andReturn($connection = m::mock(QueueContract::class));
+
+ $connection->shouldReceive('bulk')->once();
+
+ $batch = $batch->add([$job]);
+ $batch->delete();
+
+ $batch->recordSuccessfulJob('successful-job');
+ $batch->recordFailedJob('failed-job', new RuntimeException('Something went wrong.'));
+
+ $this->assertNull($batch->fresh());
+ $this->assertSame(0, $_SERVER['__finally.count']);
+ $this->assertSame(0, $_SERVER['__progress.count']);
+ $this->assertSame(0, $_SERVER['__then.count']);
+ $this->assertSame(0, $_SERVER['__catch.count']);
+ }
+
public function testBatchStateCanBeInspected()
{
$queue = m::mock(Factory::class);
@@ -500,6 +642,41 @@ public function testChainCanBeAddedToBatch()
$this->assertInstanceOf(CarbonImmutable::class, $batch->createdAt);
}
+ public function testChainedJobsPreserveTheirRoutesWhenTheBatchHasNone(): void
+ {
+ $queue = m::mock(Factory::class);
+
+ $repository = new DatabaseBatchRepository(
+ new BatchFactory($queue),
+ $this->app->make('db'),
+ 'job_batches'
+ );
+
+ $batch = $repository->store(new PendingBatch($this->app, collect()));
+
+ $firstJob = (new ChainHeadJob)
+ ->onConnection('custom-connection')
+ ->onQueue('custom-queue');
+ $secondJob = (new SecondTestJob)
+ ->onConnection('custom-connection')
+ ->onQueue('custom-queue');
+
+ $queue->shouldReceive('connection')->once()
+ ->with(null)
+ ->andReturn($connection = m::mock(QueueContract::class));
+
+ $connection->shouldReceive('bulk')->once()->with(m::type('array'), '', null);
+
+ $batch->add([
+ [$firstJob, $secondJob],
+ ]);
+
+ $this->assertSame('custom-connection', $firstJob->connection);
+ $this->assertSame('custom-connection', $secondJob->connection);
+ $this->assertSame('custom-queue', $firstJob->queue);
+ $this->assertSame('custom-queue', $secondJob->queue);
+ }
+
public function testChainedClosureAfterMultipleBatchesIsProperlyDispatched()
{
Queue::fake();
diff --git a/tests/Bus/BusBatchableTest.php b/tests/Bus/BusBatchableTest.php
index 8516a7724..b0880d365 100644
--- a/tests/Bus/BusBatchableTest.php
+++ b/tests/Bus/BusBatchableTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Bus;
+use Carbon\CarbonImmutable;
use Hypervel\Bus\Batch;
use Hypervel\Bus\Batchable;
use Hypervel\Bus\BatchRepository;
@@ -51,6 +52,32 @@ public function testWithFakeBatchSetsAndReturnsFake()
$this->assertSame(3, $job->batch()->totalJobs);
}
+ public function testZeroBatchIdMayBeRetrievedAndFaked(): void
+ {
+ $job = new class {
+ use Batchable;
+ };
+
+ Container::setInstance($container = new Container);
+
+ $repository = m::mock(BatchRepository::class);
+ $batch = m::mock(Batch::class);
+ $repository->shouldReceive('find')->once()->with('0')->andReturn($batch);
+ $container->instance(BatchRepository::class, $repository);
+
+ $job->withBatchId('0');
+
+ $this->assertSame($batch, $job->batch());
+
+ $fakeJob = new class {
+ use Batchable;
+ };
+
+ [, $fakeBatch] = $fakeJob->withFakeBatch('0');
+
+ $this->assertSame('0', $fakeBatch->id);
+ }
+
public function testBatchingReflectsCancelledState()
{
$job = new class {
@@ -66,4 +93,15 @@ public function testBatchingReflectsCancelledState()
$job->batch()->cancel();
$this->assertFalse($job->batching());
}
+
+ public function testBatchingReturnsFalseWhenBatchIsFinished(): void
+ {
+ $job = new class {
+ use Batchable;
+ };
+
+ $job->withFakeBatch('test-batch-id', 'test-batch-name', finishedAt: CarbonImmutable::now());
+
+ $this->assertFalse($job->batching());
+ }
}
diff --git a/tests/Bus/BusPendingBatchTest.php b/tests/Bus/BusPendingBatchTest.php
index ab92be018..621d9ee91 100644
--- a/tests/Bus/BusPendingBatchTest.php
+++ b/tests/Bus/BusPendingBatchTest.php
@@ -8,6 +8,7 @@
use Hypervel\Bus\Batchable;
use Hypervel\Bus\BatchRepository;
use Hypervel\Bus\ChainedBatch;
+use Hypervel\Bus\Events\BatchDispatched;
use Hypervel\Bus\PendingBatch;
use Hypervel\Bus\Queueable;
use Hypervel\Container\Container;
@@ -156,7 +157,8 @@ public function testPendingBatchMayBeConfiguredAndDispatched()
$container = new Container;
$eventDispatcher = m::mock(Dispatcher::class);
- $eventDispatcher->shouldReceive('dispatch')->once();
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnTrue();
+ $eventDispatcher->shouldReceive('dispatch')->once()->with(m::type(BatchDispatched::class));
$container->instance(Dispatcher::class, $eventDispatcher);
@@ -190,6 +192,52 @@ public function testPendingBatchMayBeConfiguredAndDispatched()
$pendingBatch->dispatch();
}
+ public function testBatchDispatchedEventIsSkippedWithoutListeners(): void
+ {
+ $container = new Container;
+
+ $eventDispatcher = m::mock(Dispatcher::class);
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnFalse();
+ $eventDispatcher->shouldNotReceive('dispatch');
+ $container->instance(Dispatcher::class, $eventDispatcher);
+
+ $job = new class {
+ use Batchable;
+ };
+
+ $pendingBatch = new PendingBatch($container, new Collection([$job]));
+
+ $repository = m::mock(BatchRepository::class);
+ $repository->shouldReceive('store')->once()->with($pendingBatch)->andReturn($batch = m::mock(Batch::class));
+ $batch->shouldReceive('add')->once()->with(m::type(Collection::class))->andReturnSelf();
+ $container->instance(BatchRepository::class, $repository);
+
+ $this->assertSame($batch, $pendingBatch->dispatch());
+ }
+
+ public function testBatchDispatchedEventIsDispatchedAfterResponse(): void
+ {
+ $container = new Container;
+
+ $eventDispatcher = m::mock(Dispatcher::class);
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnTrue();
+ $eventDispatcher->shouldReceive('dispatch')->once()->with(m::type(BatchDispatched::class));
+ $container->instance(Dispatcher::class, $eventDispatcher);
+
+ $job = new class {
+ use Batchable;
+ };
+
+ $pendingBatch = new PendingBatch($container, new Collection([$job]));
+
+ $repository = m::mock(BatchRepository::class);
+ $repository->shouldReceive('store')->once()->with($pendingBatch)->andReturn($batch = m::mock(Batch::class));
+ $batch->shouldReceive('add')->once()->with(m::type(Collection::class))->andReturnSelf();
+ $container->instance(BatchRepository::class, $repository);
+
+ $this->assertSame($batch, $pendingBatch->dispatchAfterResponse());
+ }
+
public function testBatchIsDeletedFromStorageIfExceptionThrownDuringBatching()
{
$this->expectException(RuntimeException::class);
@@ -224,7 +272,8 @@ public function testBatchIsDispatchedWhenDispatchifIsTrue()
$container = new Container;
$eventDispatcher = m::mock(Dispatcher::class);
- $eventDispatcher->shouldReceive('dispatch')->once();
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnTrue();
+ $eventDispatcher->shouldReceive('dispatch')->once()->with(m::type(BatchDispatched::class));
$container->instance(Dispatcher::class, $eventDispatcher);
$job = new class {
@@ -271,7 +320,8 @@ public function testBatchIsDispatchedWhenDispatchunlessIsFalse()
$container = new Container;
$eventDispatcher = m::mock(Dispatcher::class);
- $eventDispatcher->shouldReceive('dispatch')->once();
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnTrue();
+ $eventDispatcher->shouldReceive('dispatch')->once()->with(m::type(BatchDispatched::class));
$container->instance(Dispatcher::class, $eventDispatcher);
$job = new class {
@@ -318,7 +368,8 @@ public function testBatchBeforeEventIsCalled()
$container = new Container;
$eventDispatcher = m::mock(Dispatcher::class);
- $eventDispatcher->shouldReceive('dispatch')->once();
+ $eventDispatcher->shouldReceive('hasListeners')->once()->with(BatchDispatched::class)->andReturnTrue();
+ $eventDispatcher->shouldReceive('dispatch')->once()->with(m::type(BatchDispatched::class));
$container->instance(Dispatcher::class, $eventDispatcher);
diff --git a/tests/Integration/Horizon/Controller/BatchesControllerTest.php b/tests/Integration/Horizon/Controller/BatchesControllerTest.php
index ae3473380..4a023d565 100644
--- a/tests/Integration/Horizon/Controller/BatchesControllerTest.php
+++ b/tests/Integration/Horizon/Controller/BatchesControllerTest.php
@@ -91,6 +91,18 @@ public function testSearchSupportsCursorPagination()
$this->assertSame('batch-1', $batches[1]->id);
}
+ public function testSearchAppliesAZeroCursor(): void
+ {
+ $this->setupBatchTable();
+ $this->seedBatches();
+
+ $response = $this->actingAs(new Fakes\User)
+ ->get('/horizon/api/batches?query=Import&before_id=0');
+
+ $response->assertOk();
+ $this->assertSame([], $response->original['batches']);
+ }
+
private function setupBatchTable(): void
{
$this->app['config']->set('queue.batching.database', 'testing');
From 047500a878d9f44d76306d1acf48bf6e209b5c0b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:42:35 +0000
Subject: [PATCH 11/22] fix(bus): make debounce ownership atomic
Use one owner cache read, fail open when the lock has already expired, and establish the maximum-wait anchor with an atomic cache add so concurrent first attempts cannot overwrite one another.
Guard the optional JobDebounced event with hasListeners while preserving EventFake assertions, avoiding event construction and dispatch on the normal no-listener queue hot path.
Add deterministic concurrent-anchor, cache-read-count, missing-lock, and event-gating coverage across the debounce lock and queued handler paths.
---
src/bus/src/DebounceLock.php | 22 ++--
src/queue/src/CallQueuedHandler.php | 16 ++-
tests/Bus/BusDebounceLockTest.php | 102 ++++++++++++++++++
.../Queue/CallQueuedHandlerTest.php | 72 +++++++++++++
tests/Integration/Queue/DebouncedJobTest.php | 2 +-
tests/Queue/CallQueuedHandlerTest.php | 26 ++---
6 files changed, 208 insertions(+), 32 deletions(-)
create mode 100644 tests/Bus/BusDebounceLockTest.php
diff --git a/src/bus/src/DebounceLock.php b/src/bus/src/DebounceLock.php
index 5e21393d5..1f10413a0 100644
--- a/src/bus/src/DebounceLock.php
+++ b/src/bus/src/DebounceLock.php
@@ -58,14 +58,15 @@ protected function maxWaitExceeded(Cache $cache, string $key, int $ttl, ?int $ma
}
$timestampKey = $key . ':first_dispatched_at';
+ $firstDispatchedAt = $cache->get($timestampKey);
- if (! $cache->has($timestampKey)) {
- $cache->put($timestampKey, CarbonImmutable::now()->getTimestamp(), $ttl);
+ if ($firstDispatchedAt === null) {
+ $cache->add($timestampKey, CarbonImmutable::now()->getTimestamp(), $ttl);
return false;
}
- $elapsed = CarbonImmutable::now()->getTimestamp() - $cache->get($timestampKey);
+ $elapsed = CarbonImmutable::now()->getTimestamp() - $firstDispatchedAt;
if ($elapsed >= $maxWait) {
$cache->forget($timestampKey);
@@ -77,19 +78,14 @@ protected function maxWaitExceeded(Cache $cache, string $key, int $ttl, ?int $ma
}
/**
- * Determine if the given owner is the current owner for this debounce key.
+ * Get the current owner for the given job.
*/
- public function isCurrentOwner(mixed $job, string $owner): bool
+ public function getCurrentOwner(mixed $job): ?string
{
- return $this->resolveCache($job)->get(static::getKey($job)) === $owner;
- }
+ /** @var null|string $owner */
+ $owner = $this->resolveCache($job)->get(static::getKey($job));
- /**
- * Determine if a debounce token exists for the given job.
- */
- public function lockExists(mixed $job): bool
- {
- return ! is_null($this->resolveCache($job)->get(static::getKey($job)));
+ return $owner;
}
/**
diff --git a/src/queue/src/CallQueuedHandler.php b/src/queue/src/CallQueuedHandler.php
index 803dfcadb..307095869 100644
--- a/src/queue/src/CallQueuedHandler.php
+++ b/src/queue/src/CallQueuedHandler.php
@@ -15,6 +15,7 @@
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Encryption\Encrypter;
+use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
use Hypervel\Contracts\Queue\Job;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing;
@@ -217,12 +218,14 @@ protected function commandShouldBeDebounced(mixed $command): bool
}
$lock = new DebounceLock($this->container->make(Cache::class));
+ $currentOwner = $lock->getCurrentOwner($command);
- if (! $lock->lockExists($command)) {
+ // Fail open if the lock was evicted or expired before execution.
+ if ($currentOwner === null) {
return false;
}
- return ! $lock->isCurrentOwner($command, $owner);
+ return $currentOwner !== $owner;
}
/**
@@ -231,9 +234,12 @@ protected function commandShouldBeDebounced(mixed $command): bool
protected function deleteDebouncedJob(Job $job, mixed $command): void
{
if ($this->container->bound('events')) {
- $this->container->make('events')->dispatch(
- new JobDebounced($job->getConnectionName(), $job, $command)
- );
+ /** @var EventDispatcher $events */
+ $events = $this->container->make('events');
+
+ if ($events->hasListeners(JobDebounced::class)) {
+ $events->dispatch(new JobDebounced($job->getConnectionName(), $job, $command));
+ }
}
$job->delete();
diff --git a/tests/Bus/BusDebounceLockTest.php b/tests/Bus/BusDebounceLockTest.php
new file mode 100644
index 000000000..158754b6a
--- /dev/null
+++ b/tests/Bus/BusDebounceLockTest.php
@@ -0,0 +1,102 @@
+put(DebounceLock::getKey($job), 'owner-token', 300);
+
+ $this->assertSame('owner-token', $lock->getCurrentOwner($job));
+ }
+
+ public function testConcurrentFirstDispatchesDoNotOverwriteTheMaxWaitAnchor(): void
+ {
+ $store = new ConcurrentDebounceStore;
+ $lock = new DebounceLock(new CacheRepository($store));
+
+ $results = parallel([
+ fn (): array => $lock->acquire(new BusDebounceLockJob('entity-1')),
+ fn (): array => $lock->acquire(new BusDebounceLockJob('entity-1')),
+ ]);
+
+ $this->assertFalse($results[0]['maxWaitExceeded']);
+ $this->assertFalse($results[1]['maxWaitExceeded']);
+ $this->assertSame(1, $store->successfulTimestampAdds);
+ }
+}
+
+#[DebounceFor(30, maxWait: 60)]
+class BusDebounceLockJob
+{
+ public function __construct(public string $entityId)
+ {
+ }
+
+ public function debounceId(): string
+ {
+ return $this->entityId;
+ }
+}
+
+class ConcurrentDebounceStore extends WorkerArrayStore
+{
+ public int $successfulTimestampAdds = 0;
+
+ private int $timestampReads = 0;
+
+ private Channel $releaseFirstRead;
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->releaseFirstRead = new Channel(1);
+ }
+
+ public function get(string $key): mixed
+ {
+ $value = parent::get($key);
+
+ if ($value === null && str_ends_with($key, ':first_dispatched_at')) {
+ if (++$this->timestampReads === 1) {
+ $this->releaseFirstRead->pop();
+ } else {
+ $this->releaseFirstRead->push(true);
+ }
+ }
+
+ return $value;
+ }
+
+ public function add(string $key, mixed $value, int $seconds): bool
+ {
+ if (parent::get($key) !== null) {
+ return false;
+ }
+
+ parent::put($key, $value, $seconds);
+
+ if (str_ends_with($key, ':first_dispatched_at')) {
+ ++$this->successfulTimestampAdds;
+ }
+
+ return true;
+ }
+}
diff --git a/tests/Integration/Queue/CallQueuedHandlerTest.php b/tests/Integration/Queue/CallQueuedHandlerTest.php
index a8cd4f625..aea0ee80f 100644
--- a/tests/Integration/Queue/CallQueuedHandlerTest.php
+++ b/tests/Integration/Queue/CallQueuedHandlerTest.php
@@ -7,17 +7,22 @@
use Hypervel\Bus\Batch;
use Hypervel\Bus\Batchable;
use Hypervel\Bus\BatchRepository;
+use Hypervel\Bus\DebounceLock;
use Hypervel\Bus\Dispatcher;
use Hypervel\Bus\Queueable;
+use Hypervel\Contracts\Cache\Repository as Cache;
+use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
use Hypervel\Contracts\Queue\Job;
use Hypervel\Database\Eloquent\ModelNotFoundException;
use Hypervel\Queue\Attributes\DeleteWhenMissingModels;
use Hypervel\Queue\CallQueuedHandler;
+use Hypervel\Queue\Events\JobDebounced;
use Hypervel\Queue\Events\JobFailed;
use Hypervel\Queue\InteractsWithQueue;
use Hypervel\Support\Facades\Event;
use Hypervel\Testbench\TestCase;
use Mockery as m;
+use stdClass;
class CallQueuedHandlerTest extends TestCase
{
@@ -211,6 +216,73 @@ public function testUniqueJobLockIsReleasedViaContextOnModelNotFound()
'command' => serialize(new CallQueuedHandlerExceptionThrowerWithoutDelete),
]);
}
+
+ public function testDebouncedJobEventIsSkippedWithoutListeners(): void
+ {
+ $events = m::mock(EventDispatcher::class);
+ $events->shouldReceive('hasListeners')->once()->with(JobDebounced::class)->andReturnFalse();
+ $events->shouldNotReceive('dispatch');
+ $this->app->instance('events', $events);
+
+ $job = m::mock(Job::class);
+ $job->shouldNotReceive('getConnectionName');
+ $job->shouldReceive('delete')->once();
+
+ $handler = new TestableCallQueuedHandler(new Dispatcher($this->app), $this->app);
+ $handler->deleteDebounced($job, new stdClass);
+ }
+
+ public function testDebouncedJobOwnerIsCheckedWithOneCacheRead(): void
+ {
+ $command = new class {
+ public string $debounceOwner = 'old-owner';
+
+ public function debounceId(): string
+ {
+ return 'entity-1';
+ }
+ };
+
+ $cache = m::mock(Cache::class);
+ $cache->shouldReceive('get')->once()->with(DebounceLock::getKey($command))->andReturn('new-owner');
+ $this->app->instance(Cache::class, $cache);
+
+ $handler = new TestableCallQueuedHandler(new Dispatcher($this->app), $this->app);
+
+ $this->assertTrue($handler->shouldDebounce($command));
+ }
+
+ public function testDebouncedJobEventRemainsVisibleToEventFake(): void
+ {
+ Event::fake([JobDebounced::class]);
+
+ $job = m::mock(Job::class);
+ $job->shouldReceive('getConnectionName')->once()->andReturn('database');
+ $job->shouldReceive('delete')->once();
+
+ $command = new stdClass;
+ $handler = new TestableCallQueuedHandler(new Dispatcher($this->app), $this->app);
+ $handler->deleteDebounced($job, $command);
+
+ Event::assertDispatched(JobDebounced::class, function (JobDebounced $event) use ($job, $command): bool {
+ return $event->connectionName === 'database'
+ && $event->job === $job
+ && $event->command === $command;
+ });
+ }
+}
+
+class TestableCallQueuedHandler extends CallQueuedHandler
+{
+ public function shouldDebounce(mixed $command): bool
+ {
+ return $this->commandShouldBeDebounced($command);
+ }
+
+ public function deleteDebounced(Job $job, mixed $command): void
+ {
+ $this->deleteDebouncedJob($job, $command);
+ }
}
class CallQueuedHandlerTestJob
diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php
index 93211c4c1..917491458 100644
--- a/tests/Integration/Queue/DebouncedJobTest.php
+++ b/tests/Integration/Queue/DebouncedJobTest.php
@@ -201,7 +201,7 @@ public function testOwnerAwareReleaseDoesNotWipeNewerLock(): void
$lock->release($jobA, $ownerA);
- $this->assertTrue($lock->isCurrentOwner($jobB, $ownerB));
+ $this->assertSame($ownerB, $lock->getCurrentOwner($jobB));
}
public function testReleaseClearsMaxWaitTimestamp(): void
diff --git a/tests/Queue/CallQueuedHandlerTest.php b/tests/Queue/CallQueuedHandlerTest.php
index be79464c6..a1433d1f7 100644
--- a/tests/Queue/CallQueuedHandlerTest.php
+++ b/tests/Queue/CallQueuedHandlerTest.php
@@ -31,7 +31,7 @@
class CallQueuedHandlerTest extends TestCase
{
- public function testCommandShouldBeUniqueReturnsTrueForShouldBeUniqueInterface()
+ public function testCommandShouldBeUniqueReturnsTrueForShouldBeUniqueInterface(): void
{
$handler = $this->createHandler();
@@ -40,7 +40,7 @@ public function testCommandShouldBeUniqueReturnsTrueForShouldBeUniqueInterface()
$this->assertTrue($this->invokeMethod($handler, 'commandShouldBeUnique', [$command]));
}
- public function testCommandShouldBeUniqueReturnsTrueForCallQueuedListenerWithShouldBeUnique()
+ public function testCommandShouldBeUniqueReturnsTrueForCallQueuedListenerWithShouldBeUnique(): void
{
$handler = $this->createHandler();
@@ -50,7 +50,7 @@ public function testCommandShouldBeUniqueReturnsTrueForCallQueuedListenerWithSho
$this->assertTrue($this->invokeMethod($handler, 'commandShouldBeUnique', [$listener]));
}
- public function testCommandShouldBeUniqueReturnsFalseForCallQueuedListenerWithoutShouldBeUnique()
+ public function testCommandShouldBeUniqueReturnsFalseForCallQueuedListenerWithoutShouldBeUnique(): void
{
$handler = $this->createHandler();
@@ -60,7 +60,7 @@ public function testCommandShouldBeUniqueReturnsFalseForCallQueuedListenerWithou
$this->assertFalse($this->invokeMethod($handler, 'commandShouldBeUnique', [$listener]));
}
- public function testCommandShouldBeUniqueReturnsFalseForRegularCommand()
+ public function testCommandShouldBeUniqueReturnsFalseForRegularCommand(): void
{
$handler = $this->createHandler();
@@ -69,7 +69,7 @@ public function testCommandShouldBeUniqueReturnsFalseForRegularCommand()
$this->assertFalse($this->invokeMethod($handler, 'commandShouldBeUnique', [$command]));
}
- public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForInterface()
+ public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForInterface(): void
{
$handler = $this->createHandler();
@@ -78,7 +78,7 @@ public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForInterface(
$this->assertTrue($this->invokeMethod($handler, 'commandShouldBeUniqueUntilProcessing', [$command]));
}
- public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForCallQueuedListener()
+ public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForCallQueuedListener(): void
{
$handler = $this->createHandler();
@@ -88,7 +88,7 @@ public function testCommandShouldBeUniqueUntilProcessingReturnsTrueForCallQueued
$this->assertTrue($this->invokeMethod($handler, 'commandShouldBeUniqueUntilProcessing', [$listener]));
}
- public function testCommandShouldBeUniqueUntilProcessingReturnsFalseForCallQueuedListenerWithout()
+ public function testCommandShouldBeUniqueUntilProcessingReturnsFalseForCallQueuedListenerWithout(): void
{
$handler = $this->createHandler();
@@ -98,7 +98,7 @@ public function testCommandShouldBeUniqueUntilProcessingReturnsFalseForCallQueue
$this->assertFalse($this->invokeMethod($handler, 'commandShouldBeUniqueUntilProcessing', [$listener]));
}
- public function testUniqueJobLockIsReleasedAfterProcessing()
+ public function testUniqueJobLockIsReleasedAfterProcessing(): void
{
$lock = m::mock(Lock::class);
$lock->shouldReceive('forceRelease')->once();
@@ -150,7 +150,7 @@ public function testUniqueUntilProcessingRetryDoesNotReleaseLockAgain(): void
$handler->call($job, ['command' => $serialized]);
}
- public function testHandleModelNotFoundFailsJobWhenDeleteWhenMissingModelsIsFalse()
+ public function testHandleModelNotFoundFailsJobWhenDeleteWhenMissingModelsIsFalse(): void
{
$container = m::mock(ContainerContract::class);
@@ -162,7 +162,7 @@ public function testHandleModelNotFoundFailsJobWhenDeleteWhenMissingModelsIsFals
$this->invokeMethod($handler, 'handleModelNotFound', [$job, new \Hypervel\Database\Eloquent\ModelNotFoundException]);
}
- public function testHandleModelNotFoundDeletesJobWhenDeleteWhenMissingModelsIsTrue()
+ public function testHandleModelNotFoundDeletesJobWhenDeleteWhenMissingModelsIsTrue(): void
{
$container = m::mock(ContainerContract::class);
$container->shouldReceive('bound')->with(BatchRepository::class)->andReturn(false);
@@ -177,7 +177,7 @@ public function testHandleModelNotFoundDeletesJobWhenDeleteWhenMissingModelsIsTr
$this->invokeMethod($handler, 'handleModelNotFound', [$job, new \Hypervel\Database\Eloquent\ModelNotFoundException]);
}
- public function testEnsureUniqueJobLockIsReleasedViaContextDoesNothingWithoutContext()
+ public function testEnsureUniqueJobLockIsReleasedViaContextDoesNothingWithoutContext(): void
{
$container = m::mock(ContainerContract::class);
$container->shouldReceive('bound')->never();
@@ -188,7 +188,7 @@ public function testEnsureUniqueJobLockIsReleasedViaContextDoesNothingWithoutCon
$this->invokeMethod($handler, 'ensureUniqueJobLockIsReleasedViaContext', []);
}
- public function testFailedMethodSetsJobInstanceWhenProvided()
+ public function testFailedMethodSetsJobInstanceWhenProvided(): void
{
$container = m::mock(ContainerContract::class);
$container->shouldReceive('make')->with(Cache::class)->andReturn(m::mock(Cache::class));
@@ -267,7 +267,7 @@ public function testRunningCommandIsResetWhenCommandThrows(): void
public function testRunningCommandStaysNullForDebouncedJobs(): void
{
$cache = m::mock(Cache::class);
- $cache->shouldReceive('get')->twice()->andReturn('new-owner');
+ $cache->shouldReceive('get')->once()->andReturn('new-owner');
$container = m::mock(ContainerContract::class);
$container->shouldReceive('make')->with(Cache::class)->andReturn($cache);
From cecd10d52ccdf2f99b1887849fe1d9fda40d3cae Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:42:55 +0000
Subject: [PATCH 12/22] docs(bus): document dispatch and batch lifecycles
Document bulk dispatch, dispatch preparation, reversible after-response selection, batch lifecycle events, finished batches, and the matching Bus fake assertions in the task-first queue guide.
Call out that bulk dispatch goes directly to the selected queue driver and therefore does not run preparation, unique-job, or debounce lifecycles; jobs using those features must be dispatched individually.
Record the package provenance and the intentional DynamoDB and mutable batch-connection differences so future upstream updates preserve Hypervel pooled-connection safety.
---
src/boost/docs/queues.md | 104 +++++++++++++++++++++++++++++++++++++++
src/bus/README.md | 10 +++-
2 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md
index 61d3735a4..3a067e141 100644
--- a/src/boost/docs/queues.md
+++ b/src/boost/docs/queues.md
@@ -19,6 +19,9 @@
- [Dispatching Jobs](#dispatching-jobs)
- [Delayed Dispatching](#delayed-dispatching)
- [Synchronous Dispatching](#synchronous-dispatching)
+ - [Deferred Dispatching](#deferred-dispatching)
+ - [Bulk Dispatching](#bulk-dispatching)
+ - [Preparing Jobs Before Dispatch](#preparing-jobs-before-dispatch)
- [Jobs & Database Transactions](#jobs-and-database-transactions)
- [Job Chaining](#job-chaining)
- [Customizing The Queue and Connection](#customizing-the-queue-and-connection)
@@ -32,6 +35,7 @@
- [Chains and Batches](#chains-and-batches)
- [Adding Jobs to Batches](#adding-jobs-to-batches)
- [Inspecting Batches](#inspecting-batches)
+ - [Batch Events](#batch-events)
- [Cancelling Batches](#cancelling-batches)
- [Batch Failures](#batch-failures)
- [Pruning Batches](#pruning-batches)
@@ -56,6 +60,7 @@
- [Clearing Jobs From Queues](#clearing-jobs-from-queues)
- [Monitoring Your Queues](#monitoring-your-queues)
- [Testing](#testing)
+ - [Testing Bus Dispatches](#testing-bus-dispatches)
- [Faking a Subset of Jobs](#faking-a-subset-of-jobs)
- [Testing Job Chains](#testing-job-chains)
- [Testing Job Batches](#testing-job-batches)
@@ -1171,6 +1176,75 @@ RecordDelivery::dispatch($order)->onConnection('background');
The `background` and `deferred` drivers do not persist jobs to an external queue backend. Delayed jobs on these connections are scheduled with an in-memory timer and will be lost if the worker exits before the timer fires. Use a persistent queue connection such as `database`, `redis`, `sqs`, or `beanstalkd` for durable delayed work.
+You may also chain `afterResponse` onto a dispatch to run the job synchronously when the current coroutine ends:
+
+```php
+ProcessPodcast::dispatch($podcast)->afterResponse();
+```
+
+The method accepts a boolean, which is useful when the choice is conditional. Passing `false` uses the job's normal dispatch path:
+
+```php
+ProcessPodcast::dispatch($podcast)->afterResponse($shouldDefer);
+```
+
+After-response dispatches use the synchronous connection and are not durable.
+
+
+### Bulk Dispatching
+
+If you need to dispatch many independent jobs at once and do not need [batch](#job-batching) tracking or callbacks, you may use the `bulk` method of the `Bus` facade. Hypervel will group the jobs by their configured queue connection and queue name and push each group to the appropriate queue in bulk:
+
+```php
+use App\Jobs\ProcessUser;
+use Hypervel\Support\Facades\Bus;
+
+Bus::bulk(
+ $users->map(fn ($user) => new ProcessUser($user))
+);
+```
+
+Bulk dispatch sends jobs directly to the selected queue driver and does not run the `PreparesForDispatch`, unique job, or debounce dispatch lifecycle. Dispatch jobs that use these features individually.
+
+
+### Preparing Jobs Before Dispatch
+
+If a job needs to prepare or inspect its state before it is pushed onto the queue, the job may implement the `Hypervel\Contracts\Queue\PreparesForDispatch` interface. Hypervel will invoke the job's `prepareForDispatch` method before dispatching the job. If this method returns `false`, the job will not be dispatched; returning `true` or no value allows dispatch to continue:
+
+```php
+podcastIds)
+ ->reject(fn (int $id) => Cache::has("podcast-syncing:{$id}"))
+ ->isNotEmpty();
+ }
+}
+```
+
### Jobs & Database Transactions
@@ -2217,6 +2291,11 @@ Route::get('/batch/{batchId}', function (string $batchId) {
});
```
+
+### Batch Events
+
+Hypervel dispatches events as a batch moves through its lifecycle. You may listen for `BatchDispatched` after a batch is dispatched, `BatchStarted` when its first job is processed, `BatchFinished` when it is marked as finished, and `BatchCanceled` when it is canceled. Each event exposes the batch through its `$batch` property; `BatchCanceled` also exposes the exception that caused cancellation, when available.
+
### Cancelling Batches
@@ -3170,6 +3249,21 @@ Queue::assertClosurePushed(function (CallQueuedClosure $job) {
});
```
+
+### Testing Bus Dispatches
+
+You may use the `Bus` facade to fake command and job dispatches. The `assertNothingDispatched` method checks normal, synchronous, and after-response dispatches:
+
+```php
+use Hypervel\Support\Facades\Bus;
+
+Bus::fake();
+
+// Perform the action under test...
+
+Bus::assertNothingDispatched();
+```
+
### Faking a Subset of Jobs
@@ -3328,6 +3422,16 @@ Bus::assertBatched(function (PendingBatch $batch) {
});
```
+If you only need to assert the batch's jobs, you may pass the expected jobs directly:
+
+```php
+Bus::assertBatched([
+ new ProcessCsvRow(row: 1),
+ new ProcessCsvRow(row: 2),
+ new ProcessCsvRow(row: 3),
+]);
+```
+
The `hasJobs` method may be used on the pending batch to verify that the batch contains the expected jobs. The method accepts an array of job instances, class names, or closures:
```php
diff --git a/src/bus/README.md b/src/bus/README.md
index bab2dc3f0..264db0c72 100644
--- a/src/bus/README.md
+++ b/src/bus/README.md
@@ -1,4 +1,12 @@
Bus for Hypervel
===
-[](https://deepwiki.com/hypervel/bus)
\ No newline at end of file
+[](https://deepwiki.com/hypervel/bus)
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Bus
+
+## Differences From Laravel
+
+Hypervel does not include Laravel's DynamoDB batch repository because DynamoDB is not a supported database backend.
+
+`DatabaseBatchRepository::setConnection()` is intentionally omitted. The repository is shared for the worker lifetime, so mutating its connection would race across coroutines. Configure `queue.batching.database` instead; each repository operation resolves that connection when it runs.
From 49fd2582db4ad8335fbf106ad9cb716a8d26104a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 13:43:10 +0000
Subject: [PATCH 13/22] docs(audit): complete the bus lifecycle audit
Record the final bus-01 through bus-19 findings, accepted fixes, important rejected concerns, public API decisions, hot-path assessment, regression coverage, full validation results, and completed review state.
Mark Bus complete, add the shared Bus dependency and revalidation routes, advance the active package to Core, and preserve the verified 71-package checklist consistency.
---
...-coroutine-state-lifecycle-audit-ledger.md | 38 +++++++++++++++++++
...amework-coroutine-state-lifecycle-audit.md | 16 +++++---
2 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
index cb75495e2..6f6ed5fa0 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -851,3 +851,41 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Laravel-facing result:** Public APIs, configuration, and conventional call shapes remain unchanged. Concrete container resolution becomes fresh like Laravel, undefined Hub names gain Laravel's current exception, and package metadata declares Hypervel's actual direct dependency rather than copying Laravel's different split-package architecture.
- **Validation and review:** The Hub and concrete-resolution regressions fail against the old source for the intended undefined-name and cross-coroutine finalizer-contamination reasons. All 32 Pipeline tests with 77 assertions and all five transaction tests with 12 assertions pass. Package Composer metadata and `git diff --check` are clean. The final `composer fix` run changed none of 5,576 files; both PHPStan configurations pass; the complete components suite passes with 23,286 tests, 66,342 assertions, and 1,600 expected skips; Testbench passes with 346 tests, 1,029 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. Fresh full-diff caller/callee, lifecycle, API, metadata, performance, stale-code, and overengineering review is complete, and independent code review signed off without findings.
- **Assessment:** The result fixes each verified defect at its owning boundary and restores current Laravel behavior without a compatibility layer or broader lifecycle mechanism. Existing facade dispatch gains no work; direct concrete resolution pays only the approved fresh-builder allocation required for correctness, and Hub adds one predictable `isset` guard per named dispatch. No per-pipe context lookup, lock, clone, registry, retry, yield, retained request state, or speculative extension surface was added.
+
+### Make Bus dispatch, batches, and unique payloads lifecycle-safe
+
+- **Architecture and inspected risk surfaces:** Bus is a Laravel-derived worker-singleton dispatcher over mutable pending-dispatch builders, queue connections, database-backed batch metadata, fakes, and optional lifecycle events. The audit covered every Bus source and test file; Foundation pending dispatch and application wiring; Queue payload creation, transaction deferral, missing-model restoration, and debounce handling; Support fakes; Horizon batch reads; current Laravel 13.x source, tests, documentation, and originating pull requests; and the completed `queue-12`, `support-02`, and Pipeline invariants.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `bus-01` | Defect | Major | High | The direct `QueueingDispatcher` alias bypasses a `Bus::fake()` swap and resolves the real dispatcher | Chain the queueing contract alias through the base dispatcher contract and prove both resolve the fake |
+| `bus-02` | Defect | Minor | High | Bus lacks current Laravel bulk dispatch, fake support, facade metadata, tests, and documentation | Port current bulk grouping and immediate-dispatch behavior; require `bulk()` on Hypervel's `QueueingDispatcher` because every conforming queue dispatcher must provide the facade capability |
+| `bus-03` | Defect | Minor | High | `PreparesForDispatch` rejects valid void implementations, and pending dispatch cannot disable a previously selected after-response mode | Restore Laravel's `bool|void` contract and `afterResponse(bool)` toggle with current integration and Conditionable coverage |
+| `bus-04` | Defect | Minor | High | Batch started/canceled events, cancellation exceptions, first-job detection, explicit chain routing, and finished-state batching differ from current Laravel | Port the complete current lifecycle and guard optional observational events with `hasListeners()` |
+| `bus-05` | Defect | Minor | High | Truthiness drops public/custom batch ID `"0"` in `Batchable`, its fake, and paginated repository reads | Use the exact null/empty sentinels at each existing boundary without a normalizer |
+| `bus-06` | Defect | Major | High | Concurrent batch deletion or unfinished pruning makes atomic count updates read absent fields, while a later callback refresh can pass null to a typed Batch callback | Return nullable updated counts from the locked repository boundary and stop completion/failure/callback processing when the batch no longer exists |
+| `bus-07` | Defect | Major | High | The worker-singleton database batch repository exposes an unused connection-name mutator that can redirect sibling coroutines | Keep per-call pooled resolution, rename the accessor to `getConnection()`, remove `setConnection()`, and record the intentional Laravel difference |
+| `bus-08` | Defect | Minor | High | Batching table call-site defaults duplicate framework config and Horizon reads the table through an untyped helper result | Remove dead defaults, use typed getters, and retain the nullable batching database key |
+| `bus-09` | Defect | Minor | High | Debounce ownership performs redundant cache reads and concurrent first writers can overwrite the original max-wait anchor | Port current single-read/owner behavior and use atomic `Cache::add()` for first-anchor creation |
+| `bus-10` | Defect | Minor | High | Superseded jobs construct and dispatch `JobDebounced` even with no listener | Guard the optional event at the narrowed dispatcher boundary and retain EventFake visibility |
+| `bus-11` | Defect | Minor | High | Pending batch trait detection diverges from current Laravel's keyed class-trait lookup | Restore the current `isset(class_uses_recursive(...)[Batchable::class])` source shape |
+| `bus-12` | Defect | Minor | High | BusFake misses sync/after-response work in its empty assertion, rejects array batch assertions, returns values the real fake contract does not, ignores batch serialization, and its batch fakes drift from current behavior | Port current fake behavior and comprehensive upstream coverage while retaining stricter Hypervel regressions; serialize batch jobs at the recording boundary; and correct the upstream double-dispatch, serialization-expectation, and wrong-assertion test defects |
+| `bus-13` | Typing defect | Minor | High | Batch repository transaction callbacks lack the generic return contract carried by current Laravel | Port the current generic PHPDocs across the contract, database implementation, and fake |
+| `bus-14` | Documentation defect | Minor | High | Bus provenance and the intentional unsupported DynamoDB repository omission are not recorded | Add the current Laravel source reference plus the required README and natural provider omission comment without an empty test artifact |
+| `bus-15` | Documentation defect | Minor | High | Queue documentation lacks current bulk and prepare-for-dispatch guidance, omits one existing TOC entry, and leaves accepted public batch/dispatch testing surfaces undiscoverable | Port the current task-first sections, complete the local TOC, and document conditional after-response dispatch, direct batch assertions, empty-dispatch assertions, and observable batch events concisely |
+| `bus-16` | Defect | Major | High | PendingDispatch's request-wide unique metadata bracket can remain stranded when preparation, lock acquisition, serialization, or dispatch throws | Remove the bracket rather than patch each exceptional exit |
+| `bus-17` | Defect | Major | High | After-response and Sync/Background/Deferred after-commit paths can serialize after unique metadata is removed, so missing-model restoration cannot release the acquired lock; coroutine-global metadata also lacks job identity | Register acquired-lock metadata against the exact arbitrary job in a lazy weak sidecar and consume it only at object-payload creation |
+| `bus-18` | Package metadata defect | Minor | High | Foundation registers Log providers and Queue imports Log context classes without either package declaring `hypervel/log` | Add the sorted direct dependency to both package manifests |
+| `bus-19` | Test lifecycle defect | Minor | High | `BusBatchTest` stores callback state in worker-global `$_SERVER`, clears only five keys after throwable parent teardown, and can contaminate later tests with counts, batches, exceptions, and failure metadata | Unset the complete 18-key set owned by the test before parent teardown |
+
+- **Unique payload ownership:** `Bus\UniqueJobPayloadContext` owns a lazily initialized worker-local `WeakMap` keyed by the exact job object. `PendingDispatch` registers only after successful unique-lock acquisition. `Queue::createObjectPayload()` consumes the exact entry before payload hooks and scopes the existing Laravel interoperability keys only around Context dehydration. Weak keys cover dispatch failure before serialization; deferred callbacks retain the job until serialization; a pure `flushState()` participates in authoritative test cleanup. This replaces `InteractsWithUniqueJobs` and fixes exception-stranded context, after-response loss, Sync after-commit loss, and delayed Background/Deferred after-commit loss at their shared boundary.
+- **Approved API and performance boundary:** The owner approved the additive `QueueingDispatcher::bulk()` requirement, nullable batch-count contract, removal of Laravel's unsafe `setConnection()` surface, atomic first-anchor `Cache::add()` divergence, and the exact-object sidecar. Ordinary object payloads gain one static-null branch; a WeakMap lookup occurs only while unique registrations are outstanding, and Context/closure work only for the registered unique payload. Batch completion gains one null check beside an existing locked database transaction. Debounce owner lookup removes cache I/O; the atomic first write changes only the cold anchor path.
+- **Important rejected concerns:** Do not add bulk key hashing, queue-resolver guards, batch retry/tombstone/state-machine machinery, generalized event swallowing, a mutable connection override in CoroutineContext, a debounce lock/retry abstraction, a per-dispatch Pipeline clone, eager unique payload serialization, job dynamic properties, wrapper jobs, public deferred callback parameters, defer-order choreography, or distributed rebracketing at every delayed owner. A custom payload hook that synchronously dispatches a distinct direct unique job inside the outer Context-dehydration scope can still inherit the outer hidden keys; closing that extremely narrow conjunction would require a public payload-hook change or direct coupling to Log's serialized payload structure and is rejected as overengineering.
+- **Cross-package implications and revalidation:** Bus owns dispatcher, batch, debounce, fake, and exact unique metadata registration. Contracts owns truthful dispatcher, preparation, and batch-repository surfaces; Foundation owns pending dispatch, service aliases, and its Log dependency; Queue owns exact payload consumption, missing-model release, debounce handling, and its direct Log-context dependency; Support owns fakes and facade metadata; Testing owns static reset; and Horizon consumes typed batch configuration. The completed `queue-12` and `support-02` boundaries and Pipeline's shared-dispatch invariant were re-traced and remain unchanged. Focused and full-suite coverage revalidates every changed consumer.
+- **Upstream and documentation:** Laravel framework pull requests `#58659`, `#59118`, `#59163`, `#59233`, `#59378`, `#59457`, `#59458`, `#59879`, `#60047`, `#60297`, `#60500`, `#60511`, `#60513`, `#60559`, `#60575`, and `#60745`, plus the bulk and preparation documentation commits, supplied discovery history; current local Laravel 13.x source, tests, metadata, and docs supplied the implementation reference. The Queue guide now documents bulk and preparation, conditional after-response dispatch, observable batch events, and the accepted fake assertions. It also warns that bulk dispatch bypasses preparation, unique-job, and debounce dispatch lifecycles. Bus records its Laravel provenance and the deliberate DynamoDB and mutable-connection omissions without exposing internal sidecar or repository mechanics as application guidance.
+- **Implementation:** The queueing contract now resolves through the fakeable dispatcher contract and supports current bulk grouping; preparation accepts bool or void, and after-response dispatch is reversible. Batch lifecycle events, cancellation exceptions, route preservation, finished-state checks, zero identifiers, nullable concurrent-deletion updates, per-operation pooled connections, generic transaction typing, and typed configuration now agree across contracts, implementations, fakes, Horizon, and docs. Debounce processing uses one owner read, an atomic first anchor, and listener-aware optional events. A lazy exact-object WeakMap sidecar replaces the request-wide unique-context bracket and scopes interoperability metadata only around the payload that consumes it. Bus fakes match current behavior, serialize all recorded batch jobs including later additions, and retain stricter Hypervel assertions. The three verified upstream test defects are corrected, and Bus batch callbacks clear all 18 worker-global keys before throwable parent teardown.
+- **Regression tests:** Focused coverage proves facade replacement, bulk routing, bool/void preparation, after-response toggling, every batch terminal and deletion-race path, zero IDs and cursors, explicit chain routing, debounce atomicity and one-read ownership, optional-event behavior with EventFake, current fake semantics and recorded batch serialization, exact-object consume-once metadata, weak-key release, after-response and every after-commit payload builder, exceptional context restoration, persistent payload interoperability, exhaustive callback-state teardown, and the corrected public assertion methods.
+- **Performance and complexity:** Ordinary dispatch and batch database I/O add only the approved exact checks at their existing boundaries. Every object payload performs one static-null branch; a WeakMap lookup occurs only while unique registrations exist, and Context/closure work occurs only for the registered unique payload. Batch completion adds one null check beside its existing locked transaction. Debounce execution removes one cache read; the atomic add affects only first-anchor creation. Optional event guards avoid construction and dispatch without listeners. No lock, retry, tombstone, state machine, eager serialization, wrapper job, dynamic property, per-dispatch Pipeline clone, context registry, or compatibility layer was added.
+- **Laravel-facing result:** Public call shapes and configuration remain compatible while current bulk, preparation, after-response, batch lifecycle, fake, and documentation surfaces are restored. The additive `QueueingDispatcher::bulk()` and nullable batch-count contracts make implemented behavior truthful. The owner-approved omissions are Laravel's DynamoDB repository and mutable `DatabaseBatchRepository::setConnection()`; both are documented with their concrete Hypervel reasons. Hypervel additionally fixes current upstream batch-fake serialization and test defects rather than preserving them.
+- **Validation and review:** The focused cross-package suite passes with 286 tests, 706 assertions, and 37 expected skips. PHP CS Fixer changed none of 5,580 files; both PHPStan configurations pass; the complete components suite passes with 23,381 tests, 66,658 assertions, and 1,603 expected skips; Testbench passes with 347 tests, 1,031 assertions, and 3 expected skips; and dogfood passes with four tests and seven assertions. `git diff --check`, broad stale-reference and implementer scans, a fresh full-diff caller/callee, lifecycle, API, documentation, hot-path, and overengineering review, and independent code review are complete. The final review added the bulk lifecycle warning and signed off with no remaining finding.
+- **Assessment:** Every verified dispatch, batch, debounce, fake, unique-payload, metadata, documentation, and test-isolation defect is fixed at its lowest owner. The request-wide unique context and unsafe mutable connection surface are removed completely, cross-package assumptions are revalidated, and the ordinary hot paths retain only the narrow approved correctness checks. The result contains no workaround, speculative mechanism, stale compatibility code, or unresolved finding.
diff --git a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
index f68acc53d..ad144a5c4 100644
--- a/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-framework-coroutine-state-lifecycle-audit.md
@@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `bus`
-- **Ledger entries required for the active work:** `Correct event dispatch, queued-consumer isolation, and queue interoperability` (`queue-12`); `Normalize framework enum identifiers at string boundaries` (`support-02`); `Make Pipeline builders transient and restore current upstream fidelity` (`pipeline-01` through `pipeline-05`).
-- **Pending revalidation carried into the active work:** Revalidate `queue-12`, `support-02`, and Pipeline's shared-Bus invariant during the full Bus audit.
+- **Active package or work unit:** `core`
+- **Ledger entries required for the active work:** None identified before Core discovery.
+- **Pending revalidation carried into the active work:** None.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1050,14 +1050,18 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `events-05` | `events`, `broadcasting` | later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-05` |
| `events-06` | `events`, `foundation` | later full `foundation` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `events-06` |
| `queue-11` | `queue` | `events` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` |
-| `queue-12` | `bus`, `queue` | `events` (revalidation complete), `broadcasting`; later full `bus`, `queue`, and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
+| `queue-12` | `bus`, `queue` | `events` and `bus` (revalidation complete), `broadcasting`; later full `queue` and `broadcasting` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` |
| `foundation-01` | `foundation` | `support`; later full `foundation` and `support` audits | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` |
-| `support-02` | `support` | `auth`, `broadcasting`, `bus`, `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
+| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache`, `concurrency`, `console`, `container`, `contracts`, `cookie`, `database`, `events`, `filesystem` (revalidation complete), `foundation`, `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue`, `redis`, `reverb`, `routing`, `sanctum`, `scout`, `session`, `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-framework-enum-identifier-contracts.md` |
| `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` |
| `encryption-03` | `encryption` | `contracts`, `support`, and `filesystem` (revalidation complete), `foundation`; later full `foundation` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` |
| `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` |
| `process-02` | `process` | `concurrency` (revalidation complete) | `Make Process callbacks and pools failure-safe`; finding `process-02` |
| `server-process-10` | `server-process` | `foundation`; later full `foundation` audit | `Make custom server processes failure-safe`; finding `server-process-10` |
+| `bus-03` | `bus`, `contracts`, `foundation` | `foundation`, `queue`; later full consumer audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-03` |
+| `bus-10` | `bus`, `queue` | later full `queue` audit | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-10` |
+| `bus-17` | `bus`, `foundation`, `queue`, `testing` | `log` (revalidation complete), `foundation`, `queue`, `testing`; later full consumer audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-17` |
+| `bus-18` | `foundation`, `queue` | later full `foundation` and `queue` audits | `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; finding `bus-18` |
## Package checklist
@@ -1115,7 +1119,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
### Framework dispatch and runtime
- [x] `pipeline`
-- [ ] `bus`
+- [x] `bus`
- [ ] `core`
- [ ] `foundation`
- [ ] `console`
From cd0d2fb9b8e635d0325a43d3da11c3aa212ed8f0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 20 Jul 2026 15:49:56 +0000
Subject: [PATCH 14/22] fix(core): harden stdout logger lifecycle
Reload the default low-level logger after worker configuration is rebuilt so replacement workers publish validated levels and format before startup logging and readiness.
Make line interpolation PSR-safe, escape dynamic Symfony Console values without formatting ordinary messages, and emit raw resilient JSON with valid fallbacks and informative context normalization. Precompute enabled levels for constant-time filtering while preserving configured custom levels and PSR invalid-level errors.
Add focused coverage for reload atomicity and ordering, custom logger ownership, safe context conversion, markup and percent handling, malformed JSON inputs, throwing serializers, and level and format validation.
---
.../src/Bootstrap/WorkerStartCallback.php | 5 +
src/core/src/Logger/StdoutLogger.php | 235 +++++++++++-----
.../Bootstrap/WorkerStartCallbackTest.php | 84 ++++++
tests/Core/StdoutLoggerTest.php | 266 ++++++++++++++++--
4 files changed, 502 insertions(+), 88 deletions(-)
create mode 100644 tests/Core/Bootstrap/WorkerStartCallbackTest.php
diff --git a/src/core/src/Bootstrap/WorkerStartCallback.php b/src/core/src/Bootstrap/WorkerStartCallback.php
index 2c0e39fe9..ee99e53c9 100644
--- a/src/core/src/Bootstrap/WorkerStartCallback.php
+++ b/src/core/src/Bootstrap/WorkerStartCallback.php
@@ -12,6 +12,7 @@
use Hypervel\Core\Events\BeforeWorkerStart;
use Hypervel\Core\Events\MainWorkerStart;
use Hypervel\Core\Events\OtherWorkerStart;
+use Hypervel\Core\Logger\StdoutLogger;
use Swoole\Server as SwooleServer;
class WorkerStartCallback
@@ -27,6 +28,10 @@ public function onWorkerStart(SwooleServer $server, int $workerId): void
{
$this->dispatcher->dispatch(new BeforeWorkerStart($server, $workerId));
+ if ($this->logger instanceof StdoutLogger) {
+ $this->logger->reloadConfiguration();
+ }
+
if ($workerId === 0) {
$this->dispatcher->dispatch(new MainWorkerStart($server, $workerId));
} else {
diff --git a/src/core/src/Logger/StdoutLogger.php b/src/core/src/Logger/StdoutLogger.php
index 096844ceb..ba9b94d0e 100644
--- a/src/core/src/Logger/StdoutLogger.php
+++ b/src/core/src/Logger/StdoutLogger.php
@@ -4,110 +4,126 @@
namespace Hypervel\Core\Logger;
+use DateTimeInterface;
use Hypervel\Contracts\Config\Repository;
use Hypervel\Contracts\Log\StdoutLoggerInterface;
+use InvalidArgumentException;
+use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException;
+use Psr\Log\LoggerTrait;
use Psr\Log\LogLevel;
use Stringable;
+use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
-
-use function sprintf;
-use function str_replace;
+use Throwable;
/**
* Low-level PSR-3 logger that writes directly to stdout.
*
- * Used by Swoole server infrastructure (connection pools, server lifecycle,
- * response emitter) that needs logging before the application log stack is
- * available. Supports "line" (human-readable colored) and "json" (structured
- * JSON lines for log aggregators) output formats.
+ * Used by Swoole server infrastructure that needs logging before the application
+ * log stack is available. Supports human-readable line and structured JSON output.
*/
class StdoutLogger implements StdoutLoggerInterface
{
+ use LoggerTrait;
+
+ private const JSON_FLAGS = JSON_UNESCAPED_SLASHES
+ | JSON_UNESCAPED_UNICODE
+ | JSON_PRESERVE_ZERO_FRACTION
+ | JSON_INVALID_UTF8_SUBSTITUTE
+ | JSON_PARTIAL_OUTPUT_ON_ERROR;
+
+ private const STANDARD_LEVELS = [
+ LogLevel::EMERGENCY => true,
+ LogLevel::ALERT => true,
+ LogLevel::CRITICAL => true,
+ LogLevel::ERROR => true,
+ LogLevel::WARNING => true,
+ LogLevel::NOTICE => true,
+ LogLevel::INFO => true,
+ LogLevel::DEBUG => true,
+ ];
+
private OutputInterface $output;
private string $format;
+ /** @var array */
private array $logLevels;
- private array $tags = [
- 'component',
- ];
-
public function __construct(private Repository $config, ?OutputInterface $output = null)
{
$this->output = $output ?? new ConsoleOutput;
- $this->format = $this->config->string('app.stdout_log.format', 'line');
- $this->logLevels = $this->config->array('app.stdout_log.level', []);
- }
-
- public function emergency($message, array $context = []): void
- {
- $this->log(LogLevel::EMERGENCY, $message, $context);
+ $this->reloadConfiguration();
}
- public function alert($message, array $context = []): void
- {
- $this->log(LogLevel::ALERT, $message, $context);
- }
-
- public function critical($message, array $context = []): void
+ /**
+ * Reload the cached stdout logger configuration.
+ *
+ * Boot-only. The cached format and enabled levels affect every subsequent
+ * log entry in the worker.
+ */
+ public function reloadConfiguration(): void
{
- $this->log(LogLevel::CRITICAL, $message, $context);
- }
+ $format = $this->config->string('app.stdout_log.format');
- public function error($message, array $context = []): void
- {
- $this->log(LogLevel::ERROR, $message, $context);
- }
+ if (! in_array($format, ['line', 'json'], true)) {
+ throw new InvalidArgumentException("Unsupported stdout log format [{$format}].");
+ }
- public function warning($message, array $context = []): void
- {
- $this->log(LogLevel::WARNING, $message, $context);
- }
+ $logLevels = [];
- public function notice($message, array $context = []): void
- {
- $this->log(LogLevel::NOTICE, $message, $context);
- }
+ foreach ($this->config->array('app.stdout_log.level') as $level) {
+ if (! is_string($level)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Stdout log levels must be strings, %s given.',
+ get_debug_type($level),
+ ));
+ }
- public function info($message, array $context = []): void
- {
- $this->log(LogLevel::INFO, $message, $context);
- }
+ $logLevels[$level] = true;
+ }
- public function debug($message, array $context = []): void
- {
- $this->log(LogLevel::DEBUG, $message, $context);
+ $this->format = $format;
+ $this->logLevels = $logLevels;
}
/**
* Log a message at the given level.
* @param mixed $level
- * @param mixed $message
*/
- public function log($level, $message, array $context = []): void
+ public function log($level, string|Stringable $message, array $context = []): void
{
- // Check if the log level is allowed
- if (! in_array($level, $this->logLevels, true)) {
+ if (! is_string($level)) {
+ throw new PsrInvalidArgumentException(sprintf(
+ 'Log level must be a string, %s given.',
+ get_debug_type($level),
+ ));
+ }
+
+ if (! isset(self::STANDARD_LEVELS[$level]) && ! isset($this->logLevels[$level])) {
+ throw new PsrInvalidArgumentException("Unknown log level [{$level}].");
+ }
+
+ if (! isset($this->logLevels[$level])) {
return;
}
- $tags = array_intersect_key($context, array_flip($this->tags));
- $context = array_diff_key($context, $tags);
+ $tags = [];
- // Handle objects that are not Stringable
- foreach ($context as $key => $value) {
- if (is_object($value) && ! $value instanceof Stringable) {
- $context[$key] = '