From add16f01d8d965ed9eb730f68cd38a78b462ceec Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:44:14 +0000 Subject: [PATCH 01/23] Complete email verification and having-range documentation Document resetting an email verification status after an address change, and describe the not-between and OR variants alongside havingBetween. These APIs and the later MustVerifyEmail contract addition already match the current Laravel source; no runtime changes are necessary. Port both upstream human-readable comment corrections in the console traits. Preserve Hypervel's native property types and existing behavior. Upstream: https://github.com/laravel/framework/pull/58255 https://github.com/laravel/framework/pull/58701 https://github.com/laravel/framework/pull/58259 https://github.com/laravel/framework/pull/58266 Compared complete PR diffs with Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Checked Laravel docs first; the public usage explanations were absent. Formatting and diff checks pass. No runtime tests or static analysis needed for prose and comments. --- src/console/src/Concerns/InteractsWithIO.php | 2 +- src/console/src/Scheduling/ManagesAttributes.php | 2 +- src/docs/queries.md | 2 ++ src/docs/verification.md | 6 ++++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index c6a45d30eb..d293efbcb5 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -35,7 +35,7 @@ trait InteractsWithIO protected int $verbosity = OutputInterface::VERBOSITY_NORMAL; /** - * The mapping between human readable verbosity levels and Symfony's OutputInterface. + * The mapping between human-readable verbosity levels and Symfony's OutputInterface. */ protected array $verbosityMap = [ 'v' => OutputInterface::VERBOSITY_VERBOSE, diff --git a/src/console/src/Scheduling/ManagesAttributes.php b/src/console/src/Scheduling/ManagesAttributes.php index 4a68e1b3fa..7da4bceba1 100644 --- a/src/console/src/Scheduling/ManagesAttributes.php +++ b/src/console/src/Scheduling/ManagesAttributes.php @@ -80,7 +80,7 @@ trait ManagesAttributes protected array $rejects = []; /** - * The human readable description of the event. + * The human-readable description of the event. */ public ?string $description = null; diff --git a/src/docs/queries.md b/src/docs/queries.md index d286f1b2ba..0498ba97ad 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -1451,6 +1451,8 @@ $report = DB::table('orders') ->get(); ``` +The `havingNotBetween` method excludes results within the given range. You may use `orHavingBetween` and `orHavingNotBetween` to join these conditions to the previous having clause using `or`. + You may pass multiple arguments to the `groupBy` method to group by multiple columns: ```php diff --git a/src/docs/verification.md b/src/docs/verification.md index cd4107f6f9..6b7dd0605d 100644 --- a/src/docs/verification.md +++ b/src/docs/verification.md @@ -100,6 +100,12 @@ Before moving on, let's take a closer look at this route. First, you'll notice w Next, we can proceed directly to calling the `fulfill` method on the request. This method will call the `markEmailAsVerified` method on the authenticated user and dispatch the `Hypervel\Auth\Events\Verified` event. The `markEmailAsVerified` method is available to the default `App\Models\User` model via the `Hypervel\Foundation\Auth\User` base class. Once the user's email address has been verified, you may redirect them wherever you wish. +You may reset a user's email verification status using the `markEmailAsUnverified` method, for example, after the user changes their email address. This clears the stored verification timestamp and saves the user: + +```php +$user->markEmailAsUnverified(); +``` + By default, verification links expire after 60 minutes. You may change this duration using the `auth.verification.expire` configuration option. From a7272dbab0cca82083c2155fc4f2aa91778c0dbe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:54:35 +0000 Subject: [PATCH 02/23] Complete encryption and collection porting conventions Reconcile Laravel framework PRs #58262, #58283 and #58289 against 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Readable environment-file encryption, format detection and all applicable upstream tests are already present, as are eager/lazy collection argument guards and exception docs. Complete the remaining Hypervel adaptations: natively type encryption tests and their tampered-payload provider, use Hypervel fixture names in both environment commands' tests, link the encryption package's existing documentation and upstream, and restore the inherited split description. Preserve atomic file replacement, permissions, cancellation behavior, existing regression assertions, collection generic metadata and laziness. This changes no production behavior and adds no tests. All three affected test files pass. Scoped PHP-CS-Fixer and git diff checks pass; production changes are documentation only. https://github.com/laravel/framework/pull/58262 https://github.com/laravel/framework/pull/58283 https://github.com/laravel/framework/pull/58289 --- src/collections/src/LazyCollection.php | 2 + src/encryption/README.md | 6 +- tests/Encryption/EncrypterTest.php | 57 ++++++++++--------- .../Console/EnvironmentDecryptCommandTest.php | 44 +++++++------- .../Console/EnvironmentEncryptCommandTest.php | 6 +- 5 files changed, 62 insertions(+), 53 deletions(-) diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 5ea4f4371e..07186de265 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -1225,6 +1225,8 @@ public function slice(int $offset, ?int $length = null): static } /** + * Split a collection into a certain number of groups. + * * @throws InvalidArgumentException */ #[Override] diff --git a/src/encryption/README.md b/src/encryption/README.md index c22d0c8247..9eef4c8375 100644 --- a/src/encryption/README.md +++ b/src/encryption/README.md @@ -1,4 +1,8 @@ Encryption for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/encryption) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/encryption) + +Documentation: https://hypervel.org/docs/encryption + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Encryption diff --git a/tests/Encryption/EncrypterTest.php b/tests/Encryption/EncrypterTest.php index 71c4d8ecef..6dbcc16dcb 100644 --- a/tests/Encryption/EncrypterTest.php +++ b/tests/Encryption/EncrypterTest.php @@ -13,7 +13,7 @@ class EncrypterTest extends TestCase { - public function testEncryption() + public function testEncryption(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt('foo'); @@ -33,7 +33,7 @@ public function testEncryption() $this->assertSame($data, $e->decrypt($encryptedArray)); } - public function testRawStringEncryption() + public function testRawStringEncryption(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encryptString('foo'); @@ -41,7 +41,7 @@ public function testRawStringEncryption() $this->assertSame('foo', $e->decryptString($encrypted)); } - public function testRawStringEncryptionWithPreviousKeys() + public function testRawStringEncryptionWithPreviousKeys(): void { $previous = new Encrypter(str_repeat('b', 16)); $previousValue = $previous->encryptString('foo'); @@ -53,7 +53,7 @@ public function testRawStringEncryptionWithPreviousKeys() $this->assertSame('foo', $decrypted); } - public function testItValidatesMacOnPerKeyBasis() + public function testItValidatesMacOnPerKeyBasis(): void { // Payload created with (key: str_repeat('b', 16)) but will // "successfully" decrypt with (key: str_repeat('a', 16)), however it @@ -82,7 +82,7 @@ public function testItValidatesEveryMacBeforeDecryptingWithTheFirstValidKey(): v ); } - public function testEncryptionUsingBase64EncodedKey() + public function testEncryptionUsingBase64EncodedKey(): void { $e = new Encrypter(random_bytes(16)); $encrypted = $e->encrypt('foo'); @@ -90,7 +90,7 @@ public function testEncryptionUsingBase64EncodedKey() $this->assertSame('foo', $e->decrypt($encrypted)); } - public function testEncryptedLengthIsFixed() + public function testEncryptedLengthIsFixed(): void { $e = new Encrypter(str_repeat('a', 16)); $lengths = []; @@ -100,7 +100,7 @@ public function testEncryptedLengthIsFixed() $this->assertSame(min($lengths), max($lengths)); } - public function testWithCustomCipher() + public function testWithCustomCipher(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('bar'); @@ -113,7 +113,7 @@ public function testWithCustomCipher() $this->assertSame('foo', $e->decrypt($encrypted)); } - public function testCipherNamesCanBeMixedCase() + public function testCipherNamesCanBeMixedCase(): void { $upper = new Encrypter(str_repeat('b', 16), 'AES-128-GCM'); $encrypted = $upper->encrypt('bar'); @@ -126,7 +126,7 @@ public function testCipherNamesCanBeMixedCase() $this->assertSame('bar', $mixed->decrypt($encrypted)); } - public function testThatAnAeadCipherIncludesTag() + public function testThatAnAeadCipherIncludesTag(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -136,7 +136,7 @@ public function testThatAnAeadCipherIncludesTag() $this->assertNotEmpty($data->tag); } - public function testThatAnAeadTagMustBeProvidedInFullLength() + public function testThatAnAeadTagMustBeProvidedInFullLength(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -174,7 +174,7 @@ public function testThatAnAeadTagMustNotBeEmpty(): void $encrypter->decrypt(base64_encode(json_encode($payload))); } - public function testThatAnAeadTagCantBeModified() + public function testThatAnAeadTagCantBeModified(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM'); $encrypted = $e->encrypt('foo'); @@ -188,7 +188,7 @@ public function testThatAnAeadTagCantBeModified() $e->decrypt($encrypted); } - public function testThatANonAeadCipherIncludesMac() + public function testThatANonAeadCipherIncludesMac(): void { $e = new Encrypter(str_repeat('b', 32), 'AES-256-CBC'); $encrypted = $e->encrypt('foo'); @@ -198,7 +198,7 @@ public function testThatANonAeadCipherIncludesMac() $this->assertNotEmpty($data->mac); } - public function testDoNoAllowLongerKey() + public function testDoNoAllowLongerKey(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -206,7 +206,7 @@ public function testDoNoAllowLongerKey() new Encrypter(str_repeat('z', 32)); } - public function testWithBadKeyLength() + public function testWithBadKeyLength(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -214,7 +214,7 @@ public function testWithBadKeyLength() new Encrypter(str_repeat('a', 5)); } - public function testWithBadKeyLengthAlternativeCipher() + public function testWithBadKeyLengthAlternativeCipher(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -222,7 +222,7 @@ public function testWithBadKeyLengthAlternativeCipher() new Encrypter(str_repeat('a', 16), 'AES-256-GCM'); } - public function testWithUnsupportedCipher() + public function testWithUnsupportedCipher(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unsupported cipher or incorrect key length. Supported ciphers are: aes-128-cbc, aes-256-cbc, aes-128-gcm, aes-256-gcm.'); @@ -230,7 +230,7 @@ public function testWithUnsupportedCipher() new Encrypter(str_repeat('c', 16), 'AES-256-CFB8'); } - public function testExceptionThrownWhenPayloadIsInvalid() + public function testExceptionThrownWhenPayloadIsInvalid(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -241,7 +241,7 @@ public function testExceptionThrownWhenPayloadIsInvalid() $e->decrypt($payload); } - public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded() + public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('Unable to use tag because the cipher algorithm does not support AEAD.'); @@ -253,7 +253,7 @@ public function testDecryptionExceptionIsThrownWhenUnexpectedTagIsAdded() $e->decrypt(base64_encode(json_encode($decodedPayload))); } - public function testExceptionThrownWithDifferentKey() + public function testExceptionThrownWithDifferentKey(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The MAC is invalid.'); @@ -263,7 +263,7 @@ public function testExceptionThrownWithDifferentKey() $b->decrypt($a->encrypt('baz')); } - public function testExceptionThrownWhenIvIsTooLong() + public function testExceptionThrownWhenIvIsTooLong(): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -277,7 +277,7 @@ public function testExceptionThrownWhenIvIsTooLong() $e->decrypt($modified_payload); } - public function testSupportedMethodAcceptsAnyCasing() + public function testSupportedMethodAcceptsAnyCasing(): void { $key = str_repeat('a', 16); @@ -287,7 +287,7 @@ public function testSupportedMethodAcceptsAnyCasing() } #[DataProvider('provideTamperedData')] - public function testTamperedPayloadWillGetRejected($payload) + public function testTamperedPayloadWillGetRejected(array $payload): void { $this->expectException(DecryptException::class); $this->expectExceptionMessage('The payload is invalid.'); @@ -296,7 +296,10 @@ public function testTamperedPayloadWillGetRejected($payload) $enc->decrypt(base64_encode(json_encode($payload))); } - public static function provideTamperedData() + /** + * Provide tampered encrypted payloads. + */ + public static function provideTamperedData(): array { $validIv = base64_encode(str_repeat('.', 16)); @@ -312,7 +315,7 @@ public static function provideTamperedData() ]; } - public function testEncryptedReturnsTrueForEncryptedValue() + public function testEncryptedReturnsTrueForEncryptedValue(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt('foo'); @@ -320,7 +323,7 @@ public function testEncryptedReturnsTrueForEncryptedValue() $this->assertTrue(Encrypter::appearsEncrypted($encrypted)); } - public function testEncryptedReturnsTrueForEncryptedArray() + public function testEncryptedReturnsTrueForEncryptedArray(): void { $e = new Encrypter(str_repeat('a', 16)); $encrypted = $e->encrypt(['foo' => 'bar']); @@ -328,14 +331,14 @@ public function testEncryptedReturnsTrueForEncryptedArray() $this->assertTrue(Encrypter::appearsEncrypted($encrypted)); } - public function testEncryptedReturnsFalseForPlainText() + public function testEncryptedReturnsFalseForPlainText(): void { $this->assertFalse(Encrypter::appearsEncrypted('foo')); $this->assertFalse(Encrypter::appearsEncrypted('APP_NAME=Hypervel')); $this->assertFalse(Encrypter::appearsEncrypted("APP_NAME=Hypervel\nAPP_ENV=local")); } - public function testEncryptedReturnsFalseForNonString() + public function testEncryptedReturnsFalseForNonString(): void { $this->assertFalse(Encrypter::appearsEncrypted(123)); $this->assertFalse(Encrypter::appearsEncrypted(['foo' => 'bar'])); diff --git a/tests/Integration/Console/EnvironmentDecryptCommandTest.php b/tests/Integration/Console/EnvironmentDecryptCommandTest.php index 34a02f6247..aaaa719ab1 100644 --- a/tests/Integration/Console/EnvironmentDecryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentDecryptCommandTest.php @@ -107,7 +107,7 @@ public function testItGeneratesTheEnvironmentFileWithGeneratedKey(): void ->once() ->andReturn( (new Encrypter($key = Encrypter::generateKey('AES-256-CBC'), 'AES-256-CBC')) - ->encrypt('APP_NAME=Laravel') + ->encrypt('APP_NAME=Hypervel') ); $this->artisan('env:decrypt', ['--force' => true, '--key' => 'base64:' . base64_encode($key)]) @@ -115,7 +115,7 @@ public function testItGeneratesTheEnvironmentFileWithGeneratedKey(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME=Laravel', 0640); + ->with(base_path('.env'), 'APP_NAME=Hypervel', 0640); } public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void @@ -130,7 +130,7 @@ public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm', '--key' => 'abcdefghijklmnop']) @@ -138,7 +138,7 @@ public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void @@ -158,7 +158,7 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void ->once() ->andReturn( (new Encrypter('ponmlkjihgfedcbaponmlkjihgfedcba', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Three"') + ->encrypt('APP_NAME="Hypervel Three"') ); $this->artisan('env:decrypt') @@ -166,7 +166,7 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Three"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Three"', 0600); } finally { if ($hadEncryptionKey) { $_SERVER['HYPERVEL_ENV_ENCRYPTION_KEY'] = $previousEncryptionKey; @@ -188,7 +188,7 @@ public function testItGeneratesTheEnvironmentFileWhenForcing(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--force' => true, '--key' => 'abcdefghijklmnop', '--cipher' => 'aes-128-gcm']) @@ -196,13 +196,13 @@ public function testItGeneratesTheEnvironmentFileWhenForcing(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0640); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0640); } public function testItDecryptsMultiLineEnvironmentCorrectly(): void { $contents = <<<'Text' - APP_NAME=Laravel + APP_NAME=Hypervel APP_ENV=local APP_DEBUG=true APP_URL=http://localhost @@ -214,7 +214,7 @@ public function testItDecryptsMultiLineEnvironmentCorrectly(): void DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 - DB_DATABASE=laravel + DB_DATABASE=hypervel DB_USERNAME=root DB_PASSWORD= Text; @@ -252,7 +252,7 @@ public function testItWritesTheEnvironmentFileCustomFilename(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env']) @@ -260,7 +260,7 @@ public function testItWritesTheEnvironmentFileCustomFilename(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItWritesTheEnvironmentFileCustomPath(): void @@ -275,7 +275,7 @@ public function testItWritesTheEnvironmentFileCustomPath(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--path' => '/tmp']) @@ -283,7 +283,7 @@ public function testItWritesTheEnvironmentFileCustomPath(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env.production', 'APP_NAME="Laravel Two"', 0600); + ->with('/tmp' . DIRECTORY_SEPARATOR . '.env.production', 'APP_NAME="Hypervel Two"', 0600); } public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void @@ -298,7 +298,7 @@ public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void ->once() ->andReturn( (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env', '--path' => '/tmp']) @@ -306,7 +306,7 @@ public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME="Laravel Two"', 0600); + ->with('/tmp' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME="Hypervel Two"', 0600); } public function testItCannotOverwriteEncryptedFiles(): void @@ -332,7 +332,7 @@ public function testItGeneratesTheEnvironmentFileWithInteractivelyUserProvidedKe ->once() ->andReturn( (new Encrypter($key = 'abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Laravel Two"') + ->encrypt('APP_NAME="Hypervel Two"') ); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm']) @@ -341,7 +341,7 @@ public function testItGeneratesTheEnvironmentFileWithInteractivelyUserProvidedKe ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Laravel Two"', 0600); + ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); } public function testItAutoDetectsAndDecryptsReadableFormat(): void @@ -350,7 +350,7 @@ public function testItAutoDetectsAndDecryptsReadableFormat(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create readable format encrypted content - $encryptedContent = 'APP_NAME=' . $encrypter->encryptString('Laravel') . "\n" + $encryptedContent = 'APP_NAME=' . $encrypter->encryptString('Hypervel') . "\n" . 'APP_ENV=' . $encrypter->encryptString('local'); $this->filesystem->shouldReceive('exists') @@ -368,7 +368,7 @@ public function testItAutoDetectsAndDecryptsReadableFormat(): void ->assertExitCode(0); $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), "APP_NAME=Laravel\nAPP_ENV=local\n", 0600); + ->with(base_path('.env'), "APP_NAME=Hypervel\nAPP_ENV=local\n", 0600); } public function testItStillDecryptsBlobFormat(): void @@ -377,7 +377,7 @@ public function testItStillDecryptsBlobFormat(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create blob format (entire file encrypted as one) - $originalContent = "APP_NAME=Laravel\nAPP_ENV=local"; + $originalContent = "APP_NAME=Hypervel\nAPP_ENV=local"; $encryptedContent = $encrypter->encrypt($originalContent); $this->filesystem->shouldReceive('exists') @@ -404,7 +404,7 @@ public function testItDecryptsBlobFormatWithNewlineInContent(): void $encrypter = new Encrypter($key, 'AES-256-CBC'); // Create blob format and inject a newline (simulating wrapped base64) - $originalContent = "APP_NAME=Laravel\nAPP_ENV=local"; + $originalContent = "APP_NAME=Hypervel\nAPP_ENV=local"; $encryptedContent = $encrypter->encrypt($originalContent); // Insert a newline in the middle of the base64 string diff --git a/tests/Integration/Console/EnvironmentEncryptCommandTest.php b/tests/Integration/Console/EnvironmentEncryptCommandTest.php index 995d90be1c..eda3c53152 100644 --- a/tests/Integration/Console/EnvironmentEncryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentEncryptCommandTest.php @@ -23,7 +23,7 @@ protected function setUp(): void $this->filesystem = m::spy(Filesystem::class); $this->filesystem->shouldReceive('get') - ->andReturn('APP_NAME=Laravel'); + ->andReturn('APP_NAME=Hypervel'); $this->filesystem->shouldReceive('replace'); $this->filesystem->shouldReceive('chmod')->andReturn('0640'); File::swap($this->filesystem); @@ -208,7 +208,7 @@ public function testItEncryptsInReadableFormat(): void $filesystem->shouldReceive('get') ->with(base_path('.env')) ->once() - ->andReturn("APP_NAME=Laravel\nAPP_ENV=local"); + ->andReturn("APP_NAME=Hypervel\nAPP_ENV=local"); $filesystem->shouldReceive('replace') ->once() ->with(base_path('.env.encrypted'), m::on(function ($content) { @@ -239,7 +239,7 @@ public function testItSkipsCommentsAndBlankLinesInReadableFormat(): void $filesystem->shouldReceive('get') ->with(base_path('.env')) ->once() - ->andReturn("# Comment\nAPP_NAME=Laravel\n\nAPP_ENV=local"); + ->andReturn("# Comment\nAPP_NAME=Hypervel\n\nAPP_ENV=local"); $filesystem->shouldReceive('replace') ->once() ->with(base_path('.env.encrypted'), m::on(function ($content) { From fae6b7521dd380592e8fb87bf78085dc2cde8797 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:16:00 +0000 Subject: [PATCH 03/23] Complete binary-cast annotations and filesystem fake test parity Restore all seven current upstream filesystem fake tests covering missing files, inherited throw settings, explicit overrides, and string-backed enum disks. Merge them into the existing StorageFakeTest so integer-zero enum, parallel-token, and temporary-URL coverage remain alongside the upstream cases. Use Hypervel's config helper, typed methods, and configured camelCase PHPUnit method names. Complete the current AsBinary and BinaryCodec exception annotations and required method/provider titles. Narrow the null/blank test parameters to nullable strings. Preserve Symfony UUID conversion, exact binary identifier recognition, reusable PDO streams, worker-state cleanup, and list-shaped format names. No production executable code changes. Add the filesystem documentation link and put its upstream reference after the existing differences section without changing those explanations. Laravel PRs: https://github.com/laravel/framework/pull/58254 https://github.com/laravel/framework/pull/58287 https://github.com/laravel/framework/pull/53779 Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The binary exception annotations incorporate the relevant current changes from #59016; its broader surface remains to be reconciled separately. Validation: all three changed test files pass; scoped PHP CS Fixer and whitespace checks pass. The filesystem tests exercise real local-adapter failures and overrides rather than only inspecting configuration. --- src/database/src/Eloquent/Casts/AsBinary.php | 11 +++ src/filesystem/README.md | 4 +- src/support/src/BinaryCodec.php | 4 ++ .../DatabaseEloquentAsBinaryCastTest.php | 3 + .../Filesystem/StorageFakeTest.php | 71 +++++++++++++++++-- tests/Support/SupportBinaryCodecTest.php | 10 ++- 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php index 3b22c2defa..98a44e9cd8 100644 --- a/src/database/src/Eloquent/Casts/AsBinary.php +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -15,12 +15,17 @@ class AsBinary implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array{string} $arguments + * + * @throws InvalidArgumentException */ public static function castUsing(array $arguments): CastsAttributes { return new class($arguments) implements CastsAttributes { protected string $format; + /** + * Create a new binary cast instance. + */ public function __construct(protected array $arguments) { $this->format = $this->arguments[0] @@ -35,6 +40,9 @@ public function __construct(protected array $arguments) } } + /** + * Transform the attribute from the underlying model values. + */ public function get(mixed $model, string $key, mixed $value, array $attributes): ?string { $attribute = $attributes[$key] ?? null; @@ -50,6 +58,9 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): return BinaryCodec::decode($attribute, $this->format); } + /** + * Transform the attribute to its underlying model values. + */ public function set(mixed $model, string $key, mixed $value, array $attributes): array { return [$key => BinaryCodec::encode($value, $this->format)]; diff --git a/src/filesystem/README.md b/src/filesystem/README.md index c2acd761a9..e7a4648495 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -3,7 +3,7 @@ Filesystem for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/filesystem) -Ported from: https://github.com/laravel/framework (illuminate/filesystem) +Documentation: https://hypervel.org/docs/filesystem ## Differences From Laravel @@ -18,3 +18,5 @@ Filesystem construction differs from Laravel in how it carries logical disk iden Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes. Hypervel also provides `ScopedFilesystemProxy` and `ScopedCloudFilesystemProxy` for prefixes resolved independently on every operation. The underlying disk may be fixed or resolved once per operation when its configuration varies with the current context. These decorators fail closed on empty prefixes and reject unmapped calls so request- or tenant-scoped boundaries cannot be bypassed. + +Ported from: https://github.com/laravel/framework (illuminate/filesystem) diff --git a/src/support/src/BinaryCodec.php b/src/support/src/BinaryCodec.php index 54dadd08b7..77cb85cca5 100644 --- a/src/support/src/BinaryCodec.php +++ b/src/support/src/BinaryCodec.php @@ -33,6 +33,8 @@ public static function register(string $name, callable $encode, callable $decode /** * Encode a value to binary. + * + * @throws InvalidArgumentException */ public static function encode(Uuid|Ulid|string|null $value, string $format): ?string { @@ -63,6 +65,8 @@ public static function encode(Uuid|Ulid|string|null $value, string $format): ?st /** * Decode a binary value to string. + * + * @throws InvalidArgumentException */ public static function decode(?string $value, string $format): ?string { diff --git a/tests/Database/DatabaseEloquentAsBinaryCastTest.php b/tests/Database/DatabaseEloquentAsBinaryCastTest.php index 369d145fa5..cfc3b774c4 100644 --- a/tests/Database/DatabaseEloquentAsBinaryCastTest.php +++ b/tests/Database/DatabaseEloquentAsBinaryCastTest.php @@ -122,6 +122,9 @@ class TestModel extends Model { protected array $guarded = []; + /** + * Get the attributes that should be cast. + */ protected function casts(): array { return [ diff --git a/tests/Integration/Filesystem/StorageFakeTest.php b/tests/Integration/Filesystem/StorageFakeTest.php index c953445c3a..e54a4a2d44 100644 --- a/tests/Integration/Filesystem/StorageFakeTest.php +++ b/tests/Integration/Filesystem/StorageFakeTest.php @@ -8,10 +8,63 @@ use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Support\Facades\Storage; use Hypervel\Testbench\TestCase; +use League\Flysystem\UnableToReadFile; class StorageFakeTest extends TestCase { - public function testFakePreservesOriginalDiskThrowConfig() + public function testFakeWhenDiskNotConfiguredDoesNotThrowExceptionOnError(): void + { + $result = Storage::fake('test')->get('nonExistentFile'); + + $this->assertNull($result); + } + + public function testFakeWhenThrowSetToDiskThrowsExceptionOnError(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $this->expectException(UnableToReadFile::class); + Storage::fake('test')->get('nonExistentFile'); + } + + public function testFakeWhenThrowOverwrittenUsesOverwrite(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $result = Storage::fake('test', ['throw' => false])->get('nonExistentFile'); + $this->assertNull($result); + } + + public function testPersistentFakeWhenDiskNotConfiguredDoesNotThrowExceptionOnError(): void + { + $result = Storage::persistentFake('test')->get('nonExistentFile'); + + $this->assertNull($result); + } + + public function testPersistentFakeWhenThrowSetToDiskThrowsExceptionOnError(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $this->expectException(UnableToReadFile::class); + Storage::persistentFake('test')->get('nonExistentFile'); + } + + public function testPersistentFakeWhenThrowOverwrittenUsesOverwrite(): void + { + config(['filesystems.disks.test' => ['throw' => true]]); + + $result = Storage::persistentFake('test', ['throw' => false])->get('nonExistentFile'); + $this->assertNull($result); + } + + public function testStorageFakeMethodsWithEnums(): void + { + $this->assertNull(Storage::persistentFake(StorageFakeStringDisk::Test)->get('nonExistentFile')); + $this->assertNull(Storage::fake(StorageFakeStringDisk::Public)->get('nonExistentFile')); + } + + public function testFakePreservesOriginalDiskThrowConfig(): void { config(['filesystems.disks.local.throw' => true]); @@ -21,7 +74,7 @@ public function testFakePreservesOriginalDiskThrowConfig() $this->assertTrue($fake->getConfig()['throw']); } - public function testFakeDefaultsThrowToFalseWhenNotConfigured() + public function testFakeDefaultsThrowToFalseWhenNotConfigured(): void { config(['filesystems.disks.local' => ['driver' => 'local', 'root' => storage_path('app')]]); @@ -31,7 +84,7 @@ public function testFakeDefaultsThrowToFalseWhenNotConfigured() $this->assertFalse($fake->getConfig()['throw']); } - public function testFakeRegistersTemporaryUploadUrlBuilder() + public function testFakeRegistersTemporaryUploadUrlBuilder(): void { $fake = Storage::fake('local'); @@ -40,7 +93,7 @@ public function testFakeRegistersTemporaryUploadUrlBuilder() $this->assertTrue($fake->providesTemporaryUploadUrls()); } - public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders() + public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders(): void { $fake = Storage::fake('local'); @@ -52,7 +105,7 @@ public function testFakeTemporaryUploadUrlReturnsArrayWithUrlAndHeaders() $this->assertArrayHasKey('headers', $result); } - public function testFakeUsesParallelTestingTokenSuffix() + public function testFakeUsesParallelTestingTokenSuffix(): void { ParallelTesting::resolveTokenUsing(fn () => '42'); @@ -68,7 +121,7 @@ public function testFakeUsesParallelTestingTokenSuffix() } } - public function testPersistentFakePreservesOriginalDiskThrowConfig() + public function testPersistentFakePreservesOriginalDiskThrowConfig(): void { config(['filesystems.disks.local.throw' => true]); @@ -111,3 +164,9 @@ enum StorageFakeDisk: int { case Zero = 0; } + +enum StorageFakeStringDisk: string +{ + case Test = 'test'; + case Public = 'public'; +} diff --git a/tests/Support/SupportBinaryCodecTest.php b/tests/Support/SupportBinaryCodecTest.php index bc9fe2525f..cb7902d643 100644 --- a/tests/Support/SupportBinaryCodecTest.php +++ b/tests/Support/SupportBinaryCodecTest.php @@ -63,19 +63,22 @@ public function testRegisterOverridesDefaultFormat(): void } #[DataProvider('nullAndBlankProvider')] - public function testEncodeReturnsNullForNullAndBlank(mixed $value): void + public function testEncodeReturnsNullForNullAndBlank(?string $value): void { $this->assertNull(BinaryCodec::encode($value, 'uuid')); $this->assertNull(BinaryCodec::encode($value, 'ulid')); } #[DataProvider('nullAndBlankProvider')] - public function testDecodeReturnsNullForNullAndBlank(mixed $value): void + public function testDecodeReturnsNullForNullAndBlank(?string $value): void { $this->assertNull(BinaryCodec::decode($value, 'uuid')); $this->assertNull(BinaryCodec::decode($value, 'ulid')); } + /** + * Provide null and blank values. + */ public static function nullAndBlankProvider(): array { return [ @@ -209,6 +212,9 @@ public function testBlankBuiltInBinaryValuesRoundTrip(string $format, string $bi $this->assertSame($binary, BinaryCodec::encode($text, $format)); } + /** + * Provide binary identifiers whose bytes are blank strings. + */ public static function blankBuiltInBinaryProvider(): array { return [ From 7dab54ea7f5cf40886da3ab690895b9de95e0a39 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:38:37 +0000 Subject: [PATCH 04/23] Accept closure subqueries across between query methods Complete the supported subquery surface introduced by Laravel PRs #58290 and #58441. Both implementation bodies and their original regression tests were already present, but Hypervel's eight native column parameter unions rejected closures before the existing subquery parser could execute them. Add Closure to all eight between/between-columns signatures and their existing detailed annotations. Keep query construction, binding order and all method bodies unchanged: createSub already executes closures against a fresh query and retains only the resulting SQL and bindings. Extend the two existing forwarding tests with closure inputs alongside builder inputs, preserving their full SQL and binding assertions. Document the public subquery argument with a correlated example; the pinned Laravel docs do not yet describe it. Upstream: https://github.com/laravel/framework/pull/58290 https://github.com/laravel/framework/pull/58441 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Docs: 2914ba0b06c6be40c2f1f992555853f6266707d6 Validation: full DatabaseQueryBuilderTest, scoped PHP-CS-Fixer, both composer analyse passes, and git diff --check. Pre-fix probes reproduced TypeError for all eight closure inputs; the regression assertions now verify their SQL and binding order. --- src/database/src/Query/Builder.php | 26 ++++----- src/docs/queries.md | 18 +++++++ tests/Database/DatabaseQueryBuilderTest.php | 60 ++++++++++++--------- 3 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d7dd751f13..dc9556f67e 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -1340,9 +1340,9 @@ public function whereNotNull(string|array|ExpressionContract $columns, string $b /** * Add a "where between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static + public function whereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and', bool $not = false): static { $type = 'between'; @@ -1367,9 +1367,9 @@ public function whereBetween(self|EloquentBuilder|Relation|ExpressionContract|st /** * Add a "where between" statement using columns to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static + public function whereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and', bool $not = false): static { $type = 'betweenColumns'; @@ -1388,9 +1388,9 @@ public function whereBetweenColumns(self|EloquentBuilder|Relation|ExpressionCont /** * Add an "or where between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function orWhereBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static + public function orWhereBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { return $this->whereBetween($column, $values, 'or'); } @@ -1398,7 +1398,7 @@ public function orWhereBetween(self|EloquentBuilder|Relation|ExpressionContract| /** * Add an "or where between" statement using columns to the query. */ - public function orWhereBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static + public function orWhereBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { return $this->whereBetweenColumns($column, $values, 'or'); } @@ -1406,9 +1406,9 @@ public function orWhereBetweenColumns(self|EloquentBuilder|Relation|ExpressionCo /** * Add a "where not between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function whereNotBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static + public function whereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values, string $boolean = 'and'): static { return $this->whereBetween($column, $values, $boolean, true); } @@ -1416,7 +1416,7 @@ public function whereNotBetween(self|EloquentBuilder|Relation|ExpressionContract /** * Add a "where not between" statement using columns to the query. */ - public function whereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static + public function whereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values, string $boolean = 'and'): static { return $this->whereBetweenColumns($column, $values, $boolean, true); } @@ -1424,9 +1424,9 @@ public function whereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionC /** * Add an "or where not between" statement to the query. * - * @param \Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column + * @param \Closure|\Hypervel\Database\Query\Builder|\Hypervel\Database\Eloquent\Builder<*>|\Hypervel\Database\Eloquent\Relations\Relation<*, *, *>|\Hypervel\Contracts\Database\Query\Expression|string $column */ - public function orWhereNotBetween(self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static + public function orWhereNotBetween(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, iterable $values): static { return $this->whereNotBetween($column, $values, 'or'); } @@ -1434,7 +1434,7 @@ public function orWhereNotBetween(self|EloquentBuilder|Relation|ExpressionContra /** * Add an "or where not between" statement using columns to the query. */ - public function orWhereNotBetweenColumns(self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static + public function orWhereNotBetweenColumns(Closure|self|EloquentBuilder|Relation|ExpressionContract|string $column, array $values): static { return $this->whereNotBetweenColumns($column, $values, 'or'); } diff --git a/src/docs/queries.md b/src/docs/queries.md index 0498ba97ad..ac61025c9e 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -964,6 +964,22 @@ $users = DB::table('users') ->get(); ``` +You may also pass a query builder or closure as the first argument to compare a subquery's result to the given values. For example, the following query retrieves users whose most recent score is between 50 and 100: + +```php +use Hypervel\Database\Query\Builder; + +$users = DB::table('users') + ->whereBetween(function (Builder $query) { + $query->select('score') + ->from('scores') + ->whereColumn('scores.user_id', 'users.id') + ->orderByDesc('scores.created_at') + ->limit(1); + }, [50, 100]) + ->get(); +``` + **whereNotBetween / orWhereNotBetween** The `whereNotBetween` method verifies that a column's value lies outside of two values: @@ -992,6 +1008,8 @@ $patients = DB::table('patients') ->get(); ``` +Like `whereBetween`, these methods also accept a query builder or closure as the first argument to compare a subquery's result to the two column values. + **whereValueBetween / whereValueNotBetween / orWhereValueBetween / orWhereValueNotBetween** The `whereValueBetween` method verifies that a given value is between the values of two columns of the same type in the same table row: diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 5b35485264..bf2a53d54f 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -3231,38 +3231,46 @@ public function testWhereForwardersAcceptQueryBuilderSubqueries(): void $this->assertSame([1, true, 5, true, 0, true, 3], $builder->getBindings()); } - public function testBetweenForwardersAcceptQueryBuilderSubqueries(): void + public function testBetweenForwardersAcceptQueryableSubqueries(): void { - $subquery = $this->getBuilder()->select('score')->from('scores')->where('active', true); - $builder = $this->getBuilder() - ->from('parents') - ->whereBetween($subquery, [1, 2]) - ->orWhereBetween($subquery, [3, 4]) - ->whereNotBetween($subquery, [5, 6]) - ->orWhereNotBetween($subquery, [7, 8]); + foreach ([ + $this->getBuilder()->select('score')->from('scores')->where('active', true), + static fn (Builder $query): Builder => $query->select('score')->from('scores')->where('active', true), + ] as $subquery) { + $builder = $this->getBuilder() + ->from('parents') + ->whereBetween($subquery, [1, 2]) + ->orWhereBetween($subquery, [3, 4]) + ->whereNotBetween($subquery, [5, 6]) + ->orWhereNotBetween($subquery, [7, 8]); - $this->assertSame( - 'select * from "parents" where (select "score" from "scores" where "active" = ?) between ? and ? or (select "score" from "scores" where "active" = ?) between ? and ? and (select "score" from "scores" where "active" = ?) not between ? and ? or (select "score" from "scores" where "active" = ?) not between ? and ?', - $builder->toSql() - ); - $this->assertSame([true, 1, 2, true, 3, 4, true, 5, 6, true, 7, 8], $builder->getBindings()); + $this->assertSame( + 'select * from "parents" where (select "score" from "scores" where "active" = ?) between ? and ? or (select "score" from "scores" where "active" = ?) between ? and ? and (select "score" from "scores" where "active" = ?) not between ? and ? or (select "score" from "scores" where "active" = ?) not between ? and ?', + $builder->toSql() + ); + $this->assertSame([true, 1, 2, true, 3, 4, true, 5, 6, true, 7, 8], $builder->getBindings()); + } } - public function testBetweenColumnsForwardersAcceptQueryBuilderSubqueries(): void + public function testBetweenColumnsForwardersAcceptQueryableSubqueries(): void { - $subquery = $this->getBuilder()->select('score')->from('scores')->where('active', true); - $builder = $this->getBuilder() - ->from('parents') - ->whereBetweenColumns($subquery, ['minimum', 'maximum']) - ->orWhereBetweenColumns($subquery, ['minimum', 'maximum']) - ->whereNotBetweenColumns($subquery, ['minimum', 'maximum']) - ->orWhereNotBetweenColumns($subquery, ['minimum', 'maximum']); + foreach ([ + $this->getBuilder()->select('score')->from('scores')->where('active', true), + static fn (Builder $query): Builder => $query->select('score')->from('scores')->where('active', true), + ] as $subquery) { + $builder = $this->getBuilder() + ->from('parents') + ->whereBetweenColumns($subquery, ['minimum', 'maximum']) + ->orWhereBetweenColumns($subquery, ['minimum', 'maximum']) + ->whereNotBetweenColumns($subquery, ['minimum', 'maximum']) + ->orWhereNotBetweenColumns($subquery, ['minimum', 'maximum']); - $this->assertSame( - 'select * from "parents" where (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" and (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum"', - $builder->toSql() - ); - $this->assertSame([true, true, true, true], $builder->getBindings()); + $this->assertSame( + 'select * from "parents" where (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) between "minimum" and "maximum" and (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum" or (select "score" from "scores" where "active" = ?) not between "minimum" and "maximum"', + $builder->toSql() + ); + $this->assertSame([true, true, true, true], $builder->getBindings()); + } } public function testOrderForwardersAcceptQueryableSubqueries(): void From 59140c25661b287a47b3b9a5998f8790aafb7d8b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:53:55 +0000 Subject: [PATCH 05/23] Complete appended validation rules and resource test parity Port the four missing current Laravel validation cases for escaped-dot keys through constructor rules, appendRules and sometimes, and for successive pipe-separated appended rules. The implementation already handles these paths; preserve Hypervel's placeholder encoding, wildcard expansion and cached lookup invalidation. Restore addRules' upstream internal annotation and the resource collector's LogicException annotation. Correct two misleading upstream comments, and document appendRules at the public manually-created-validator surface. Resource collection conversion and wrapping coverage is already present. Correct the two inherited Request::create fixtures that reverse the URI and HTTP method, and complete the touched method/provider docblocks. Preserve every resource dataset and assertion. No production method body or native signature changes. Upstream: https://github.com/laravel/framework/pull/58291 https://github.com/laravel/framework/pull/58304 https://github.com/laravel/framework/pull/58299 https://github.com/laravel/framework/pull/58302 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Docs: 2914ba0b06c6be40c2f1f992555853f6266707d6 Validation: each changed test file passes (ValidationValidatorTest, ResourceTest and ResourceCollectionTest); scoped configured PHP-CS-Fixer and git diff --check pass. The final collection-stub docblock correction was followed by another successful ResourceCollectionTest run. --- src/docs/validation.md | 10 ++++++++++ src/http/src/Resources/CollectsResources.php | 2 ++ src/validation/src/Validator.php | 4 +++- tests/Integration/Http/ResourceTest.php | 5 ++++- .../Resources/Json/ResourceCollectionTest.php | 8 +++++++- tests/Validation/ValidationValidatorTest.php | 20 +++++++++++++++++-- 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index 2545a4f328..4c8b31d2ba 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -998,6 +998,16 @@ The first argument passed to the `make` method is the data under validation. The After determining whether the request validation failed, you may use the `withErrors` method to flash the error messages to the session. When using this method, the `$errors` variable will automatically be shared with your views after redirection, allowing you to easily display them back to the user. The `withErrors` method accepts a validator, a `MessageBag`, or a PHP `array`. +#### Appending Rules + +Before running validation, you may use the `appendRules` method to add rules to an existing validator. The new rules are added to any rules already defined for each field: + +```php +$validator->appendRules([ + 'title' => 'min:5', +]); +``` + #### Stopping on First Validation Failure The `stopOnFirstFailure` method will inform the validator that it should stop validating all attributes once a single validation failure has occurred: diff --git a/src/http/src/Resources/CollectsResources.php b/src/http/src/Resources/CollectsResources.php index 217e7d09ed..7e452314c1 100644 --- a/src/http/src/Resources/CollectsResources.php +++ b/src/http/src/Resources/CollectsResources.php @@ -52,6 +52,8 @@ protected function collectResource(mixed $resource): mixed * Get the resource that this resource collects. * * @return null|class-string<\Hypervel\Http\Resources\Json\JsonResource> + * + * @throws LogicException */ protected function collects(): ?string { diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 25b49633b6..8727d6619a 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1671,10 +1671,12 @@ public function appendRules(array $rules): static /** * Parse the given rules and merge them into current rules. + * + * @internal */ public function addRules(array $rules): void { - // The primary purpose of this parser is to expand any "*" rules to the all + // The primary purpose of this parser is to expand any "*" rules to all // of the explicit rules needed for the given data. For example the rule // names.* would get expanded to names.0, names.1, etc. for this data. $response = (new ValidationRuleParser($this->data)) diff --git a/tests/Integration/Http/ResourceTest.php b/tests/Integration/Http/ResourceTest.php index c67f949342..0ac0d5bc19 100644 --- a/tests/Integration/Http/ResourceTest.php +++ b/tests/Integration/Http/ResourceTest.php @@ -59,6 +59,9 @@ class ResourceTest extends TestCase public function testResourceMayBeConvertedToArray(): void { $resource = new class((new User)->forceFill(['id' => 1, 'name' => 'Taylor Otwell'])) extends JsonResource { + /** + * Transform the resource into an array. + */ public function toArray(Request $request): array { return [ @@ -80,7 +83,7 @@ public function toArray(Request $request): array } }; - $request = Request::create('GET', '/users'); + $request = Request::create('/users', 'GET'); tap($resource->toArray($request), function ($userAsArray) use ($request) { $this->assertSame(1, $userAsArray['id']); diff --git a/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php b/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php index c8355885b5..66b2c44fd4 100644 --- a/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php +++ b/tests/Integration/Http/Resources/Json/ResourceCollectionTest.php @@ -16,11 +16,14 @@ class ResourceCollectionTest extends TestCase #[DataProvider('toArrayDataProvider')] public function testItCanReturnToArray(ResourceCollection $collection, mixed $expected): void { - $request = Request::create('GET', '/'); + $request = Request::create('/', 'GET'); $this->assertSame($expected, $collection->toArray($request)); } + /** + * Provide resource collections and their expected arrays. + */ public static function toArrayDataProvider(): iterable { yield [ @@ -49,6 +52,9 @@ public static function toArrayDataProvider(): iterable yield [ new class(['list' => new Fluent(['id' => 1]), 'total' => 1]) extends ResourceCollection { + /** + * Transform the resource into a JSON array. + */ public function toArray(Request $request): array { return $this->resource->toArray(); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index e710cb8c95..c87cb5859a 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -7782,7 +7782,7 @@ public function testValidateImplicitEachWithAsterisksForRequiredNonExistingKey() $this->assertFalse($v->passes()); } - public function testParsingArrayKeysWithDot() + public function testParsingArrayKeysWithDot(): void { $trans = $this->getArrayTranslator(); // Interpreted dot fails on empty value @@ -7791,7 +7791,7 @@ public function testParsingArrayKeysWithDot() // Escaped dot fails on empty value $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => ''], ['foo\.bar' => 'required']); $this->assertTrue($v->fails()); - // Interpreted dot succeeds + // Escaped dot succeeds $v = new Validator($trans, ['foo' => ['bar' => 'valid'], 'foo.bar' => 'zxc'], ['foo\.bar' => 'required']); $this->assertFalse($v->fails()); // Interpreted dot followed by escaped dot fails on empty value @@ -7800,6 +7800,22 @@ public function testParsingArrayKeysWithDot() // Interpreted dot followed by escaped dot fails on empty value $v = new Validator($trans, ['foo' => [['bar.baz' => ''], ['bar.baz' => '']]], ['foo.*.bar\.baz' => 'required']); $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], ['foo\.bar' => 'required']); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], []); + $v->appendRules(['foo\.bar' => 'required']); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['foo.bar' => 'valid'], []); + $v->sometimes('foo\.bar', 'required', fn (): bool => true); + $this->assertFalse($v->fails()); + + $v = new Validator($trans, ['name' => 'ab'], ['name' => 'required']); + $v->appendRules(['name' => 'string']); + $v->appendRules(['name' => 'min:5|max:255']); + $this->assertTrue($v->fails()); } public function testParsingArrayKeysWithAsterisk(): void From c5adaab22339e344b8e07fb924bb480521481aff Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:39:33 +0000 Subject: [PATCH 06/23] Complete array filtering and cloned connection test parity Bring the remaining tests and documentation for Laravel #58317, #58288 and #58311 into parity with framework source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 and docs source 2914ba0b06c6be40c2f1f992555853f6266707d6. Port the five strict empty-array assertions and correct four array-filtering examples to show preserved keys. Replace three mislabeled SELECT assertions with actual insert, update and delete calls through the cloned connection in pretend mode. Assert the interpolated SQL emitted by Hypervel and retain schema and original-connection isolation coverage. Type the local test helpers and remove stale Laravel annotations. Clarify that isolated Blade includes still receive shared view data. The array examples, duplicated SELECT checks and overly restrictive Blade wording also exist upstream; runtime behavior remains unchanged. Validated both affected PHPUnit classes immediately, their combined focused ParaTest run, scoped PHP-CS-Fixer and diff checks. Upstream: https://github.com/laravel/framework/pull/58317 https://github.com/laravel/framework/pull/58288 https://github.com/laravel/framework/pull/58311 https://github.com/laravel/docs/pull/10999 --- src/docs/blade.md | 2 +- src/docs/helpers.md | 12 ++--- ...EloquentIntegrationWithTablePrefixTest.php | 44 +++++++++---------- tests/Support/SupportArrTest.php | 10 ++--- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/docs/blade.md b/src/docs/blade.md index 6bbc3b6e1c..4429f63008 100644 --- a/src/docs/blade.md +++ b/src/docs/blade.md @@ -586,7 +586,7 @@ To include the first view that exists from a given array of views, you may use t @includeFirst(['custom.admin', 'admin'], ['status' => 'complete']) ``` -If you would like to include a view without inheriting any variables from the parent view, you may use the `@includeIsolated` directive. The included view will only have access to variables you explicitly pass: +If you would like to include a view without inheriting any variables from the parent view, you may use the `@includeIsolated` directive. Variables shared with all views remain available, and you may pass additional data as the second argument: ```blade @includeIsolated('view.name', ['user' => $user]) diff --git a/src/docs/helpers.md b/src/docs/helpers.md index 5b3e0fc2c6..753ccff0f2 100644 --- a/src/docs/helpers.md +++ b/src/docs/helpers.md @@ -452,7 +452,7 @@ $filtered = Arr::except($array, ['price']); #### `Arr::exceptValues()` {.collection-method} -The `Arr::exceptValues` method removes the specified values from an array: +The `Arr::exceptValues` method removes the specified values from an array, preserving the original keys: ```php use Hypervel\Support\Arr; @@ -461,7 +461,7 @@ $array = ['foo', 'bar', 'baz', 'qux']; $filtered = Arr::exceptValues($array, ['foo', 'baz']); -// ['bar', 'qux'] +// [1 => 'bar', 3 => 'qux'] ``` You may also pass `true` to the `strict` argument to use strict type comparisons when filtering: @@ -473,7 +473,7 @@ $array = [1, '1', 2, '2']; $filtered = Arr::exceptValues($array, [1, 2], strict: true); -// ['1', '2'] +// [1 => '1', 3 => '2'] ``` @@ -889,7 +889,7 @@ $slice = Arr::only($array, ['name', 'price']); #### `Arr::onlyValues()` {.collection-method} -The `Arr::onlyValues` method returns only the specified values from an array: +The `Arr::onlyValues` method returns only the specified values from an array, preserving the original keys: ```php use Hypervel\Support\Arr; @@ -898,7 +898,7 @@ $array = ['foo', 'bar', 'baz', 'qux']; $filtered = Arr::onlyValues($array, ['foo', 'baz']); -// ['foo', 'baz'] +// [0 => 'foo', 2 => 'baz'] ``` You may also pass `true` to the `strict` argument to use strict type comparisons when filtering: @@ -910,7 +910,7 @@ $array = [1, '1', 2, '2']; $filtered = Arr::onlyValues($array, [1, 2], strict: true); -// [1, 2] +// [0 => 1, 2 => 2] ``` diff --git a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php index 69e65f7bc8..ac063cb5ae 100644 --- a/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php +++ b/tests/Database/DatabaseEloquentIntegrationWithTablePrefixTest.php @@ -5,9 +5,11 @@ namespace Hypervel\Tests\Database\DatabaseEloquentIntegrationWithTablePrefixTest; use Hypervel\Database\Capsule\Manager as DB; +use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\Schema\Builder; use Hypervel\Testbench\TestCase; class DatabaseEloquentIntegrationWithTablePrefixTest extends TestCase @@ -34,7 +36,10 @@ protected function setUp(): void $this->createSchema(); } - protected function createSchema() + /** + * Create the database schema. + */ + protected function createSchema(): void { $this->schema('default')->create('users', function ($table) { $table->increments('id'); @@ -80,7 +85,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testBasicModelHydration() + public function testBasicModelHydration(): void { User::create(['email' => 'taylorotwell@gmail.com']); User::create(['email' => 'abigailotwell@gmail.com']); @@ -93,7 +98,7 @@ public function testBasicModelHydration() $this->assertCount(1, $models); } - public function testTablePrefixWithClonedConnection() + public function testTablePrefixWithClonedConnection(): void { $originalConnection = $this->connection(); $originalPrefix = $originalConnection->getTablePrefix(); @@ -116,7 +121,7 @@ public function testTablePrefixWithClonedConnection() $clonedConnection->getSchemaBuilder()->drop('test_table'); } - public function testQueryGrammarUsesCorrectPrefixAfterCloning() + public function testQueryGrammarUsesCorrectPrefixAfterCloning(): void { $originalConnection = $this->connection(); @@ -126,42 +131,35 @@ public function testQueryGrammarUsesCorrectPrefixAfterCloning() $selectSql = $clonedConnection->table('users')->toSql(); $this->assertStringContainsString('new_prefix_users', $selectSql); - $insertSql = $clonedConnection->table('users')->toSql(); - $this->assertStringContainsString('new_prefix_users', $insertSql); - - $updateSql = $clonedConnection->table('users')->where('id', 1)->toSql(); - $this->assertStringContainsString('new_prefix_users', $updateSql); + $queries = $clonedConnection->pretend(function (Connection $connection): void { + $connection->table('users')->insert(['email' => 'taylor@example.com']); + $connection->table('users')->where('id', 1)->update(['email' => 'abigail@example.com']); + $connection->table('users')->where('id', 1)->delete(); + }); - $deleteSql = $clonedConnection->table('users')->where('id', 1)->toSql(); - $this->assertStringContainsString('new_prefix_users', $deleteSql); + $this->assertSame([ + 'insert into "new_prefix_users" ("email") values (\'taylor@example.com\')', + 'update "new_prefix_users" set "email" = \'abigail@example.com\' where "id" = 1', + 'delete from "new_prefix_users" where "id" = 1', + ], array_column($queries, 'query')); $originalSql = $originalConnection->table('users')->toSql(); $this->assertStringContainsString('prefix_users', $originalSql); $this->assertStringNotContainsString('new_prefix_users', $originalSql); } - /** - * Helpers... - * @param mixed $connection - */ - /** * Get a database connection instance. - * - * @return \Illuminate\Database\Connection */ - protected function connection($connection = 'default') + protected function connection(string $connection = 'default'): Connection { return Eloquent::getConnectionResolver()->connection($connection); } /** * Get a schema builder instance. - * - * @param mixed $connection - * @return \Illuminate\Database\Schema\Builder */ - protected function schema($connection = 'default') + protected function schema(string $connection = 'default'): Builder { return $this->connection($connection)->getSchemaBuilder(); } diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index bb9bfcb5bb..aae8298bb0 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -365,16 +365,16 @@ public function testExceptValues(): void $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; $this->assertEquals(['b' => 2, 'd' => 3], Arr::exceptValues($array, 1)); - $this->assertEquals([], Arr::exceptValues([], 'foo')); + $this->assertSame([], Arr::exceptValues([], 'foo')); $this->assertEquals(['foo', 'bar'], Arr::exceptValues(['foo', 'bar'], [])); $array = [1, '1', 2, '2', 3]; $this->assertEquals([1 => '1', 3 => '2'], Arr::exceptValues($array, [1, 2, 3], true)); - $this->assertEquals([], Arr::exceptValues($array, [1, 2, 3])); + $this->assertSame([], Arr::exceptValues($array, [1, 2, 3])); $array = ['a' => true, 'b' => false, 'c' => 1, 'd' => 0]; $this->assertEquals(['a' => true, 'b' => false], Arr::exceptValues($array, [1, 0], true)); - $this->assertEquals([], Arr::exceptValues($array, [1, 0])); + $this->assertSame([], Arr::exceptValues($array, [1, 0])); } public function testExists(): void @@ -1076,8 +1076,8 @@ public function testOnlyValues(): void $array = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 3]; $this->assertEquals(['a' => 1, 'c' => 1], Arr::onlyValues($array, 1)); - $this->assertEquals([], Arr::onlyValues([], 'foo')); - $this->assertEquals([], Arr::onlyValues(['foo', 'bar'], [])); + $this->assertSame([], Arr::onlyValues([], 'foo')); + $this->assertSame([], Arr::onlyValues(['foo', 'bar'], [])); $array = [1, '1', 2, '2', 3]; $this->assertEquals([0 => 1, 2 => 2, 4 => 3], Arr::onlyValues($array, [1, 2, 3], true)); From d6c3f41a892c5ca78c6313c9ba4cb0a43ce08d49 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:09:06 +0000 Subject: [PATCH 07/23] Complete migration and schema typing from Laravel Port the current migration repository record shapes, batch maps, creation and connection resolver callbacks, Blueprint callbacks, schema return inference, and MySQL DDL lock value annotations. Include the later fixes for malformed unions and incorrect array/object annotations rather than reproducing the original typing defects. Creation hooks accept a nullable table name for generic migrations. Keep the prospective migration class name typed as string: the caller checks whether that class exists. Preserve coroutine connection routing and physical-session foreign-key suppression without changing runtime APIs. Document the creation hook's worker lifetime and share schema defaults with the existing static reset through typed constants. Correct the inherited MySQL DDL example: ALGORITHM=INSTANT permits only LOCK=DEFAULT. Keep the upstream compiler tests that verify clause forwarding. Remove unsupported SQL Server-only definition metadata. Add type fixtures for public repository records, callback inference, schema refinements, and exact first-class callable lock signatures. Schema facade generation remains unchanged because the generator simplifies these refinements; no manual facade changes are included. Upstream source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. https://github.com/laravel/framework/pull/58293 https://github.com/laravel/framework/pull/58561 https://github.com/laravel/framework/pull/58624 https://github.com/laravel/framework/pull/58638 https://github.com/laravel/framework/pull/59875 https://github.com/laravel/framework/pull/59876 https://github.com/laravel/framework/pull/59887 Verified with full source/type analysis, focused migration and schema tests, formatting, and facade-generation lint. --- .../DatabaseMigrationRepository.php | 12 +++++ .../src/Migrations/MigrationCreator.php | 7 +++ .../MigrationRepositoryInterface.php | 12 +++++ src/database/src/Migrations/Migrator.php | 5 ++ src/database/src/Schema/Blueprint.php | 4 ++ src/database/src/Schema/Builder.php | 49 +++++++++++++------ src/database/src/Schema/ColumnDefinition.php | 3 +- .../src/Schema/ForeignKeyDefinition.php | 4 +- src/database/src/Schema/IndexDefinition.php | 4 +- src/docs/migrations.md | 6 +-- types/Database/Migrations.php | 46 +++++++++++++++++ types/Database/Schema.php | 38 ++++++++++++++ 12 files changed, 165 insertions(+), 25 deletions(-) create mode 100644 types/Database/Migrations.php diff --git a/src/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php index 156e9ec952..8a33fd7ba6 100755 --- a/src/database/src/Migrations/DatabaseMigrationRepository.php +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -26,6 +26,8 @@ public function __construct( /** * Get the completed migrations. + * + * @return string[] */ public function getRan(): array { @@ -37,6 +39,8 @@ public function getRan(): array /** * Get the list of migrations. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrations(int $steps): array { @@ -51,6 +55,8 @@ public function getMigrations(int $steps): array /** * Get the list of the migrations by batch number. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrationsByBatch(int $batch): array { @@ -63,6 +69,8 @@ public function getMigrationsByBatch(int $batch): array /** * Get the last migration batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getLast(): array { @@ -73,6 +81,8 @@ public function getLast(): array /** * Get the completed migrations with their batch numbers. + * + * @return array */ public function getMigrationBatches(): array { @@ -94,6 +104,8 @@ public function log(string $file, int $batch): void /** * Remove a migration from the log. + * + * @param object{id?: int, migration: string, batch?: int} $migration */ public function delete(object $migration): void { diff --git a/src/database/src/Migrations/MigrationCreator.php b/src/database/src/Migrations/MigrationCreator.php index 622c18d2d4..9574394dce 100644 --- a/src/database/src/Migrations/MigrationCreator.php +++ b/src/database/src/Migrations/MigrationCreator.php @@ -19,6 +19,8 @@ class MigrationCreator /** * The registered post create hooks. + * + * @var (Closure(?string, string): void)[] */ protected array $postCreate = []; @@ -169,6 +171,11 @@ protected function firePostCreateHooks(?string $table, string $path): void /** * Register a post migration create hook. + * + * Boot-only. Hooks persist on the migration creator for the worker + * lifetime and run for every subsequent migration creation. + * + * @param Closure(?string, string): void $callback */ public function afterCreate(Closure $callback): void { diff --git a/src/database/src/Migrations/MigrationRepositoryInterface.php b/src/database/src/Migrations/MigrationRepositoryInterface.php index 147941a14e..9de783476b 100755 --- a/src/database/src/Migrations/MigrationRepositoryInterface.php +++ b/src/database/src/Migrations/MigrationRepositoryInterface.php @@ -8,26 +8,36 @@ interface MigrationRepositoryInterface { /** * Get the completed migrations. + * + * @return string[] */ public function getRan(): array; /** * Get the list of migrations. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrations(int $steps): array; /** * Get the list of the migrations by batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getMigrationsByBatch(int $batch): array; /** * Get the last migration batch. + * + * @return object{id: int, migration: string, batch: int}[] */ public function getLast(): array; /** * Get the completed migrations with their batch numbers. + * + * @return array */ public function getMigrationBatches(): array; @@ -38,6 +48,8 @@ public function log(string $file, int $batch): void; /** * Remove a migration from the log. + * + * @param object{id?: int, migration: string, batch?: int} $migration */ public function delete(object $migration): void; diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index e402964267..99f28139f4 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -35,6 +35,8 @@ class Migrator { /** * The custom connection resolver callback. + * + * @var null|(Closure(Resolver, ?string): Connection) */ protected static ?Closure $connectionResolverCallback = null; @@ -263,6 +265,7 @@ public function rollback(array|string $paths = [], array $options = []): array * Get the migrations for a rollback operation. * * @param array $options + * @return object{id: int, migration: string, batch: int}[] */ protected function getMigrationsForRollback(array $options): array { @@ -799,6 +802,8 @@ public function resolveConnection(?string $connection): Connection * * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every migration's connection resolution. + * + * @param Closure(Resolver, ?string): Connection $callback */ public static function resolveConnectionsUsing(Closure $callback): void { diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index fdc6ddf931..d2e4309fcc 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -88,6 +88,8 @@ class Blueprint /** * Create a new schema blueprint. + * + * @param null|(Closure(self): void) $callback */ public function __construct(Connection $connection, string $table, ?Closure $callback = null) { @@ -1524,6 +1526,8 @@ protected function addColumnDefinition(ColumnDefinition $definition): ColumnDefi /** * Add the columns from the callback after the given column. + * + * @param Closure(self): void $callback */ public function after(string $column, Closure $callback): void { diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index ad297f980b..9febedbcb4 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -17,6 +17,12 @@ class Builder { use Macroable; + protected const int DEFAULT_STRING_LENGTH = 255; + + protected const int DEFAULT_TIME_PRECISION = 0; + + protected const string DEFAULT_MORPH_KEY_TYPE = 'int'; + /** * The database connection instance. */ @@ -36,18 +42,22 @@ class Builder /** * The default string length for migrations. + * + * @var null|non-negative-int */ - public static ?int $defaultStringLength = 255; + public static ?int $defaultStringLength = self::DEFAULT_STRING_LENGTH; /** * The default time precision for migrations. */ - public static ?int $defaultTimePrecision = 0; + public static ?int $defaultTimePrecision = self::DEFAULT_TIME_PRECISION; /** * The default relationship morph key type. + * + * @var 'int'|'ulid'|'uuid' */ - public static string $defaultMorphKeyType = 'int'; + public static string $defaultMorphKeyType = self::DEFAULT_MORPH_KEY_TYPE; /** * Create a new database Schema manager. @@ -63,6 +73,8 @@ public function __construct(Connection $connection) * * Boot-only. The length persists in a static property for the worker * lifetime and applies to every Blueprint::string() across all coroutines. + * + * @param non-negative-int $length */ public static function defaultStringLength(int $length): void { @@ -97,17 +109,6 @@ public static function defaultMorphKeyType(string $type): void static::$defaultMorphKeyType = $type; } - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$defaultStringLength = 255; - static::$defaultTimePrecision = 0; - static::$defaultMorphKeyType = 'int'; - static::flushMacros(); - } - /** * Set the default morph key type for migrations to UUIDs. * @@ -575,6 +576,11 @@ public function disableForeignKeyConstraints(): bool /** * Disable foreign key constraints during the execution of a callback. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn */ public function withoutForeignKeyConstraints(Closure $callback): mixed { @@ -750,6 +756,10 @@ public function getCurrentSchemaName(): ?string /** * Parse the given database object reference and extract the schema and table. + * + * @return array{null|string, string} + * + * @throws InvalidArgumentException */ public function parseSchemaAndTable(string $reference, bool|string|null $withDefaultSchema = null): array { @@ -790,4 +800,15 @@ public function blueprintResolver(Closure $resolver): void { $this->resolver = $resolver; } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$defaultStringLength = self::DEFAULT_STRING_LENGTH; + static::$defaultTimePrecision = self::DEFAULT_TIME_PRECISION; + static::$defaultMorphKeyType = self::DEFAULT_MORPH_KEY_TYPE; + static::flushMacros(); + } } diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index 40a9381dd9..a62b767957 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -23,9 +23,8 @@ * @method $this instant() Specify that algorithm=instant should be used for the column operation (MySQL) * @method $this index(bool|string $indexName = null) Add an index * @method $this invisible() Specify that the column should be invisible to "SELECT *" (MySQL) - * @method $this lock(string $value) Specify the DDL lock mode for the column operation (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the column operation (MySQL) * @method $this nullable(bool $value = true) Allow NULL values to be inserted into the column - * @method $this persisted() Mark the computed generated column as persistent (SQL Server) * @method $this primary(bool $value = true) Add a primary index * @method $this spatialIndex(bool|string $indexName = null) Add a spatial index * @method $this vectorIndex(bool|string $indexName = null) Add a vector index diff --git a/src/database/src/Schema/ForeignKeyDefinition.php b/src/database/src/Schema/ForeignKeyDefinition.php index 8299fe0609..47a255bb83 100644 --- a/src/database/src/Schema/ForeignKeyDefinition.php +++ b/src/database/src/Schema/ForeignKeyDefinition.php @@ -9,11 +9,11 @@ /** * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) - * @method ForeignKeyDefinition lock(string $value) Specify the DDL lock mode for the foreign key operation (MySQL) + * @method ForeignKeyDefinition lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the foreign key operation (MySQL) * @method ForeignKeyDefinition on(string $table) Specify the referenced table * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition references(array|string $columns) Specify the referenced column(s) + * @method ForeignKeyDefinition references(string|string[] $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { diff --git a/src/database/src/Schema/IndexDefinition.php b/src/database/src/Schema/IndexDefinition.php index 3f6b883fe4..b7b9bf7f56 100644 --- a/src/database/src/Schema/IndexDefinition.php +++ b/src/database/src/Schema/IndexDefinition.php @@ -12,9 +12,9 @@ * @method $this deferrable(bool $value = true) Specify that the unique index is deferrable (PostgreSQL) * @method $this initiallyImmediate(bool $value = true) Specify the default time to check the unique index constraint (PostgreSQL) * @method $this language(string $language) Specify a language for the full text index (PostgreSQL) - * @method $this lock(string $value) Specify the DDL lock mode for the index operation (MySQL) + * @method $this lock(('default'|'exclusive'|'none'|'shared') $value) Specify the DDL lock mode for the index operation (MySQL) * @method $this nullsNotDistinct(bool $value = true) Specify that the null values should not be treated as distinct (PostgreSQL) - * @method $this online(bool $value = true) Specify that index creation should not lock the table (PostgreSQL/SqlServer) + * @method $this online(bool $value = true) Specify that index creation should not lock the table (PostgreSQL) */ class IndexDefinition extends Fluent { diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 6d3ff8a855..1a5fa7f6ff 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1405,11 +1405,7 @@ $table->string('name')->lock('none'); $table->index('email')->lock('shared'); ``` -If the requested lock mode is incompatible with the operation, MySQL will raise an error. The `lock` modifier may be combined with the `instant` modifier to further optimize schema changes: - -```php -$table->string('name')->instant()->lock('none'); -``` +If the requested lock mode is incompatible with the operation, MySQL will raise an error. When the `instant` modifier is used, MySQL permits only the `default` lock mode. ### Modifying Columns diff --git a/types/Database/Migrations.php b/types/Database/Migrations.php new file mode 100644 index 0000000000..fb3deef27e --- /dev/null +++ b/types/Database/Migrations.php @@ -0,0 +1,46 @@ +', $repository->getRan()); + assertType('array', $database->getRan()); + assertType('array', $repository->getMigrationBatches()); + assertType('array', $database->getMigrationBatches()); + + assertType('array', $repository->getMigrations(1)); + assertType('array', $database->getMigrations(1)); + assertType('array', $repository->getMigrationsByBatch(1)); + assertType('array', $database->getMigrationsByBatch(1)); + assertType('array', $repository->getLast()); + assertType('array', $database->getLast()); + + $repository->delete((object) ['migration' => 'create_users_table']); + $database->delete((object) ['migration' => 'create_users_table']); +} + +function testMigrationCallbackTypes(MigrationCreator $creator, Connection $connection): void +{ + $creator->afterCreate(function ($table, $path): void { + assertType('string|null', $table); + assertType('string', $path); + }); + + Migrator::resolveConnectionsUsing(function ($resolver, $name) use ($connection): Connection { + assertType('Hypervel\Database\ConnectionResolverInterface', $resolver); + assertType('string|null', $name); + + return $connection; + }); +} diff --git a/types/Database/Schema.php b/types/Database/Schema.php index eca60fffcd..eb4c9ccb92 100644 --- a/types/Database/Schema.php +++ b/types/Database/Schema.php @@ -5,6 +5,7 @@ namespace Hypervel\Types\Database\Schema; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\Schema\Builder; use function PHPStan\Testing\assertType; @@ -22,3 +23,40 @@ function testIndexDefinitionsUseConcreteTypes(Blueprint $table): void $table->index('archived_at')->whereNotNull('archived_at'), ); } + +function testSchemaCallbackAndReferenceTypes(Builder $schema): void +{ + assertType('int<0, max>|null', Builder::$defaultStringLength); + assertType("'int'|'ulid'|'uuid'", Builder::$defaultMorphKeyType); + assertType('Closure(int<0, max>): void', Builder::defaultStringLength(...)); + assertType('42', $schema->withoutForeignKeyConstraints(fn (): int => 42)); + assertType('array{string|null, string}', $schema->parseSchemaAndTable('users')); + + new Blueprint($schema->getConnection(), 'users', function ($table): void { + assertType('Hypervel\Database\Schema\Blueprint', $table); + + $table->after('id', function ($table): void { + assertType('Hypervel\Database\Schema\Blueprint', $table); + }); + }); +} + +function testDdlLockTypes(Blueprint $table): void +{ + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\ColumnDefinition", + $table->string('name')->lock(...), + ); + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\IndexDefinition", + $table->index('name')->lock(...), + ); + assertType( + "Closure('default'|'exclusive'|'none'|'shared'): Hypervel\\Database\\Schema\\ForeignKeyDefinition", + $table->foreign('user_id')->lock(...), + ); + assertType( + 'Closure(array|string): Hypervel\Database\Schema\ForeignKeyDefinition', + $table->foreign('user_id')->references(...), + ); +} From 532f39bc6a67b87b91406a858c3f1ed38c786857 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:18:16 +0000 Subject: [PATCH 08/23] Preserve blueprint resolvers across schema facade calls Schema::blueprintResolver() configured a throwaway builder, so subsequent schema operations silently ignored the callback. Keep the facade default on the existing SchemaProxy and apply it to each freshly selected builder. Route Schema::connection() through that proxy as well, so named connections and usingConnection() receive the same configuration. Retain only the boot-time callback, never a builder or its pooled connection. Builder::blueprintResolver() stays instance-local, allowing a retained builder to override the default without affecting other builders. Document the default registrar's worker lifetime. The defect also exists in Laravel, whose Schema facade disables caching. Its earlier static Builder fix was introduced and reverted in: https://github.com/laravel/framework/pull/55607 https://github.com/laravel/framework/pull/55690 This fix preserves local builder state instead of restoring that design. Current callback types already incorporate: https://github.com/laravel/framework/pull/55687 https://github.com/laravel/framework/pull/56392 Upstream reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Approved testing difference: Schema::connection() now honors a mocked Schema facade instead of bypassing it for a real builder. Such tests must configure the connection expectation; ordinary application calls retain their API and connection selection. Add two real-application SQLite regressions for resolver arguments, default/named/temporary connection selection, create/alter callbacks, and local override isolation. Both fail before the fix and pass afterward. Focused database tests, full PHPStan analysis, formatting and Schema facade lint pass. --- src/database/src/Schema/SchemaProxy.php | 32 +++++++- src/support/src/Facades/Schema.php | 6 +- tests/Database/DatabaseSchemaProxyTest.php | 95 ++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/Database/DatabaseSchemaProxyTest.php diff --git a/src/database/src/Schema/SchemaProxy.php b/src/database/src/Schema/SchemaProxy.php index 49627c1ffc..ca84dcc660 100644 --- a/src/database/src/Schema/SchemaProxy.php +++ b/src/database/src/Schema/SchemaProxy.php @@ -4,13 +4,23 @@ namespace Hypervel\Database\Schema; +use Closure; use Hypervel\Container\Container; +use Hypervel\Database\Connection; /** * @mixin Builder */ class SchemaProxy { + /** + * @var null|(Closure(Connection, string, null|Closure): Blueprint) + */ + protected ?Closure $resolver = null; + + /** + * Forward a schema operation to the current connection's builder. + */ public function __call(string $name, array $arguments): mixed { return $this->connection() @@ -24,9 +34,29 @@ public function __call(string $name, array $arguments): mixed */ public function connection(?string $name = null): Builder { - return Container::getInstance() + $builder = Container::getInstance() ->make('db') ->connection($name) ->getSchemaBuilder(); + + // Retain configuration without retaining a coroutine's pooled connection. + if ($this->resolver !== null) { + $builder->blueprintResolver($this->resolver); + } + + return $builder; + } + + /** + * Set the default Schema Blueprint resolver callback. + * + * Boot-only. The callback persists on the shared proxy for the worker + * lifetime and applies to every subsequent schema builder it creates. + * + * @param Closure(Connection, string, null|Closure): Blueprint $resolver + */ + public function blueprintResolver(Closure $resolver): void + { + $this->resolver = $resolver; } } diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index a845765aec..e11f0a003a 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -4,7 +4,6 @@ namespace Hypervel\Support\Facades; -use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Database\Schema\Builder; /** @@ -77,10 +76,7 @@ class Schema extends Facade */ public static function connection(?string $name = null): Builder { - /** @var ContainerContract $app */ - $app = static::$app; - - return $app->make('db')->connection($name)->getSchemaBuilder(); + return static::getFacadeRoot()->connection($name); } /** diff --git a/tests/Database/DatabaseSchemaProxyTest.php b/tests/Database/DatabaseSchemaProxyTest.php new file mode 100644 index 0000000000..96e7e33426 --- /dev/null +++ b/tests/Database/DatabaseSchemaProxyTest.php @@ -0,0 +1,95 @@ +make('config')->set('database.default', 'primary'); + $app->make('config')->set('database.connections', [ + 'primary' => ['driver' => 'sqlite', 'database' => ':memory:'], + 'secondary' => ['driver' => 'sqlite', 'database' => ':memory:'], + ]); + } + + public function testFacadeResolverAppliesToFreshBuildersOnTheSelectedConnection(): void + { + $calls = []; + + Schema::blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$calls): Blueprint { + $calls[] = [$connection, $table, $callback]; + + return new Blueprint($connection, $table, $callback); + }); + + $create = static function (Blueprint $table): void { + $table->id(); + }; + $alter = static function (Blueprint $table): void { + $table->string('name'); + }; + + Schema::create('users', $create); + Schema::table('users', $alter); + Schema::connection('secondary')->create('users', $create); + DB::usingConnection('secondary', static function () use ($create): void { + Schema::create('posts', $create); + }); + + $this->assertSame([ + [DB::connection('primary'), 'users', null], + [DB::connection('primary'), 'users', $alter], + [DB::connection('secondary'), 'users', null], + [DB::connection('secondary'), 'posts', null], + ], $calls); + $this->assertTrue(Schema::hasColumn('users', 'name')); + $this->assertFalse(Schema::hasTable('posts')); + $this->assertTrue(Schema::connection('secondary')->hasTable('posts')); + } + + public function testBuilderResolverOverridesRemainLocal(): void + { + $defaultTables = []; + $localTables = []; + + Schema::blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$defaultTables): Blueprint { + $defaultTables[] = $table; + + return new Blueprint($connection, $table, $callback); + }); + + $local = Schema::connection(); + $other = Schema::connection(); + + $local->blueprintResolver(function (Connection $connection, string $table, ?Closure $callback) use (&$localTables): Blueprint { + $localTables[] = $table; + + return new Blueprint($connection, $table, $callback); + }); + + $create = static function (Blueprint $table): void { + $table->id(); + }; + + $local->create('local_users', $create); + $other->create('other_users', $create); + Schema::create('default_users', $create); + + $this->assertSame(['local_users'], $localTables); + $this->assertSame(['other_users', 'default_users'], $defaultTables); + } +} From 40c3e2ae2f3ac3a1f260c6882ad944d5e5b85072 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:14:07 +0000 Subject: [PATCH 09/23] Complete queue routing regression coverage Restore Laravel's scalar-route and string-backed enum registration tests, including both direct and array registration. Preserve Hypervel's existing unit-enum and integer-backed enum coverage. Correct the listener, broadcast, scheduler and mail routing tests so they verify the selected connection. QueueFake::connection() ignores its argument, so chaining it before assertPushedOn() never tested connection selection and even concealed a queue-name typo in the scheduler test. Observe the factory call while preserving each test's queue and dispatch assertions. Explain the partial fakes beside their construction to prevent losing this coverage. Restore the current Laravel documentation's scalar queue-only example and Concerns namespace. Production behavior and public APIs are unchanged. Upstream framework: https://github.com/laravel/framework/pull/58094 https://github.com/laravel/framework/pull/59711 https://github.com/laravel/framework/pull/60402 Source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Upstream documentation: https://github.com/laravel/docs/pull/11025 https://github.com/laravel/docs/pull/11348 Source: 2914ba0b06c6be40c2f1f992555853f6266707d6 Validation: each affected class passes, as does focused ParaTest including unique-job scheduling. Wrong-connection probes fail in all four corrected tests. Scoped formatting and diff checks pass. --- src/docs/queues.md | 4 +-- tests/Events/QueuedEventsTest.php | 14 ++++---- .../Broadcasting/BroadcastManagerTest.php | 8 +++-- .../Integration/Console/JobSchedulingTest.php | 10 ++++-- .../Mail/SendingQueuedMailTest.php | 9 +++-- tests/Queue/QueueRoutesTest.php | 35 +++++++++++++++++++ 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index d48e079021..da6f0f0387 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -1655,9 +1655,9 @@ In addition to routing specific job classes, you may also pass an interface, tra Typically, you should call the `route` method from the `boot` method of a service provider: ```php +use App\Concerns\RequiresVideo; use App\Jobs\ProcessPodcast; use App\Jobs\ProcessVideo; -use App\Traits\RequiresVideo; use Hypervel\Support\Facades\Queue; /** @@ -1681,7 +1681,7 @@ You may also route multiple job classes at once by passing an array to the `rout ```php Queue::route([ ProcessPodcast::class => ['redis', 'podcasts'], // Connection and queue - ProcessVideo::class => [null, 'videos'], // Queue only (uses default connection) + ProcessVideo::class => 'videos', // Queue only (uses default connection) ]); ``` diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index c1391e13ea..9869434a3c 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -231,7 +231,7 @@ public function testQueueIsSetByGetConnectionDynamically() ]); } - public function testQueueIsSetUsingQueueRoutes() + public function testQueueIsSetUsingQueueRoutes(): void { $container = new Container; $d = new Dispatcher($container); @@ -240,18 +240,20 @@ public function testQueueIsSetUsingQueueRoutes() $queueRoutes->set(TestDispatcherQueueRoutes::class, 'event-queue', 'event-connection'); $container->instance('queue.routes', $queueRoutes); - $fakeQueue = new QueueFake($container); + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + + $factory->shouldReceive('connection')->once()->with('event-connection')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('event-queue', m::type(CallQueuedListener::class)); Container::setInstance($container); - $d->setQueueResolver(function () use ($fakeQueue) { - return $fakeQueue; + $d->setQueueResolver(function () use ($factory): QueueFactory { + return $factory; }); $d->listen('some.event', TestDispatcherQueueRoutes::class . '@handle'); $d->dispatch('some.event', ['foo', 'bar']); - - $fakeQueue->connection('event-connection')->assertPushedOn('event-queue', CallQueuedListener::class); } public function testDelayIsSetByWithDelayDynamically() diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 027c3fea04..e2084e0a63 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -42,6 +42,7 @@ use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; use InvalidArgumentException; use Mockery as m; @@ -120,13 +121,16 @@ public function testQueuedOrdinaryEventIsClonedOnce(): void public function testEventsCanBeBroadcastUsingQueueRoutes(): void { Bus::fake(); - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('broadcast-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(TestEvent::class, 'broadcast-queue', 'broadcast-connection'); Broadcast::queue(new TestEvent); Bus::assertNotDispatched(BroadcastEvent::class); - Queue::connection('broadcast-connection')->assertPushedOn('broadcast-queue', BroadcastEvent::class); + Queue::assertPushedOn('broadcast-queue', BroadcastEvent::class); } public function testEventsCanBeRescued(): void diff --git a/tests/Integration/Console/JobSchedulingTest.php b/tests/Integration/Console/JobSchedulingTest.php index c3fdcc9c8f..d5680a7e87 100644 --- a/tests/Integration/Console/JobSchedulingTest.php +++ b/tests/Integration/Console/JobSchedulingTest.php @@ -9,7 +9,9 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; +use Mockery as m; class JobSchedulingTest extends TestCase { @@ -105,7 +107,11 @@ public function testJobQueuingNormalizesIntegerBackedEnumQueueAndConnection(): v public function testJobQueuingRespectsQueueRoutes(): void { - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->twice()->with(null)->andReturnSelf(); + $queue->shouldReceive('connection')->once()->with('some-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(JobWithDefaultQueue::class, 'default-queue'); Queue::route(JobWithoutDefaultQueue::class, 'fallback-queue'); @@ -126,7 +132,7 @@ public function testJobQueuingRespectsQueueRoutes(): void // Own queue takes precedence over default Queue::assertPushedOn('test-queue', JobWithDefaultQueue::class); Queue::assertPushedOn('fallback-queue', JobWithoutDefaultQueue::class); - Queue::connection('some-queue')->assertPushedOn('some-queue', JobWithoutDefaultConnection::class); + Queue::assertPushedOn('some-queue', JobWithoutDefaultConnection::class); } } diff --git a/tests/Integration/Mail/SendingQueuedMailTest.php b/tests/Integration/Mail/SendingQueuedMailTest.php index 01f4ce8e70..3dc80f4318 100644 --- a/tests/Integration/Mail/SendingQueuedMailTest.php +++ b/tests/Integration/Mail/SendingQueuedMailTest.php @@ -10,7 +10,9 @@ use Hypervel\Queue\Middleware\RateLimited; use Hypervel\Support\Facades\Mail; use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; +use Mockery as m; class SendingQueuedMailTest extends TestCase { @@ -39,13 +41,16 @@ public function testMailIsSentWithDefaultLocale(): void public function testMailIsSentWhenRoutingQueue(): void { - Queue::fake(); + // QueueFake ignores connection names, so verify selection separately from its push assertions. + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('mail-connection')->andReturnSelf(); + Queue::swap($queue); Queue::route(Mailable::class, 'mail-queue', 'mail-connection'); Mail::to('test@mail.com')->queue(new SendingQueuedMailTestMail); - Queue::connection('mail-connection')->assertPushedOn('mail-queue', SendQueuedMailable::class); + Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); } public function testMailIsSentWithDelay(): void diff --git a/tests/Queue/QueueRoutesTest.php b/tests/Queue/QueueRoutesTest.php index 4d9d3da4f9..798708c6fe 100644 --- a/tests/Queue/QueueRoutesTest.php +++ b/tests/Queue/QueueRoutesTest.php @@ -73,6 +73,31 @@ public function testGetConnection(): void $this->assertNull($defaults->getConnection(new Payment)); } + public function testStringRouteDefaultsToQueueNotConnection(): void + { + $defaults = new QueueRoutes; + + $defaults->set([BaseNotification::class => 'notifications']); + + $this->assertSame('notifications', $defaults->getQueue(new FinanceNotification)); + $this->assertNull($defaults->getConnection(new FinanceNotification)); + } + + public function testEnumsAreResolved(): void + { + $defaults = new QueueRoutes; + + $defaults->set(SomeJob::class, QueueName::Payments, ConnectionName::Redis); + + $this->assertSame('payments', $defaults->getQueue(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection(new SomeJob)); + + $defaults->set([SomeJob::class => [ConnectionName::Redis, QueueName::Payments]]); + + $this->assertSame('payments', $defaults->getQueue(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection(new SomeJob)); + } + public function testEnumRoutesAreNormalizedAndScalarRoutesRemainQueueOnly(): void { $defaults = new QueueRoutes; @@ -92,6 +117,16 @@ public function testEnumRoutesAreNormalizedAndScalarRoutesRemainQueueOnly(): voi } } +enum QueueName: string +{ + case Payments = 'payments'; +} + +enum ConnectionName: string +{ + case Redis = 'redis'; +} + trait CustomTrait { } From b0efb079eb012b8ee2076059de9a4513b00f7653 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:14:29 +0000 Subject: [PATCH 10/23] Reject unknown PDO sessions without configurators Unconfigured connections bypassed session synchronization even after a physical transaction or cleanup failure marked their PDO state unknown. Subsequent reads and writes could continue inside a transaction whose rollback had failed. Keep the allocation-free fast path only for known sessions. Reuse the existing synchronization path to replace an unknown PDO outside transactions and reject its use inside an active transaction. Raw PDO access and physical cleanup remain unchanged; no additional state or recovery mechanism is introduced. Extend the existing write replacement, read replacement and active-transaction tests to run with and without registered configurators. Preserve the no-allocation regression for healthy unconfigured connections. Found while investigating Laravel framework PR https://github.com/laravel/framework/pull/58978; this commit fixes the independent Hypervel session-state defect and does not port reservation recovery. Validation: focused database transaction/session tests, full source and type-fixture analysis, PHP-CS-Fixer and diff checks pass. --- src/database/src/PdoConnection.php | 5 ++- .../DatabaseSessionConfiguratorTest.php | 42 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 573b9a5adb..3d24263880 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -352,7 +352,8 @@ public function getPdo(): PDO $this->latestReadWriteTypeRetrieved = 'write'; $pdo = $this->resolvePdo(); - return static::$sessionConfigurators === [] + // Transaction and cleanup failures can invalidate a PDO even without session configurators. + return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) ? $pdo : $this->synchronizeSession($pdo, read: false); } @@ -382,7 +383,7 @@ public function getReadPdo(): PDO $this->latestReadWriteTypeRetrieved = 'read'; $pdo = $this->resolveReadPdo(); - return static::$sessionConfigurators === [] + return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) ? $pdo : $this->synchronizeSession($pdo, read: true); } diff --git a/tests/Database/DatabaseSessionConfiguratorTest.php b/tests/Database/DatabaseSessionConfiguratorTest.php index 32ba4d335e..2338d2b035 100644 --- a/tests/Database/DatabaseSessionConfiguratorTest.php +++ b/tests/Database/DatabaseSessionConfiguratorTest.php @@ -17,6 +17,7 @@ use Hypervel\Tests\TestCase; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Throwable; @@ -335,10 +336,15 @@ public function testReentrantConfigurationFailsClosedAcrossWrappersSharingAPdo() } } - public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigured(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownWriteSessionIsReplacedOnce(bool $configureSession): void { $configurator = $this->configurator(); - PdoConnection::configureSessionUsing($configurator); + + if ($configureSession) { + PdoConnection::configureSessionUsing($configurator); + } + $oldPdo = $this->pdo(); $newPdo = $this->pdo(); $connection = $this->connection($oldPdo); @@ -352,14 +358,19 @@ public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigur $this->assertSame($newPdo, $connection->getPdo()); $this->assertSame(1, $reconnects); - $this->assertSame(2, $configurator->applyCalls); + $this->assertSame($configureSession ? 2 : 0, $configurator->applyCalls); $this->assertFalse(TestSessionConnection::sessionStateIsUnknownForTest($newPdo)); } - public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownReadSessionRecoveryKeepsTheReadRoute(bool $configureSession): void { $configurator = $this->configurator(); - PdoConnection::configureSessionUsing($configurator); + + if ($configureSession) { + PdoConnection::configureSessionUsing($configurator); + } + $writePdo = $this->pdo(); $oldReadPdo = $this->pdo(); $newReadPdo = $this->pdo(); @@ -373,7 +384,7 @@ public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void $this->assertSame($newReadPdo, $connection->getReadPdo()); $this->assertSame($writePdo, $connection->getRawPdo()); - $this->assertSame(2, $configurator->applyCalls); + $this->assertSame($configureSession ? 2 : 0, $configurator->applyCalls); } public function testUnknownReadFallbackRecoveryUsesTheReplacementWritePdo(): void @@ -440,9 +451,13 @@ public function testReentrantReconnectorCannotRecursivelyReplaceAnUnknownSession } } - public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void + #[DataProvider('sessionConfigurationProvider')] + public function testUnknownSessionInsideTransactionFailsWithoutReconnect(bool $configureSession): void { - PdoConnection::configureSessionUsing($this->configurator()); + if ($configureSession) { + PdoConnection::configureSessionUsing($this->configurator()); + } + $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -464,6 +479,17 @@ public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void $this->assertSame(0, $reconnects); } + /** + * Provide session configurator registration states. + */ + public static function sessionConfigurationProvider(): array + { + return [ + 'configured' => [true], + 'unconfigured' => [false], + ]; + } + public function testUnknownSessionWithoutAReconnectorPreservesTheExistingFailure(): void { PdoConnection::configureSessionUsing($this->configurator()); From acbc23b98599f563d9f2bee54aaefcdc00dc856c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:13:29 +0000 Subject: [PATCH 11/23] Recover database queue reservation failures without losing valid jobs Port Laravel framework PRs #58978 and #59718 from the 13.x source at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58978 https://github.com/laravel/framework/pull/59718 Fail records that cannot be reserved so one invalid job cannot block the queue indefinitely. Recover only after the reservation transaction has unwound to its original depth. Exclude concurrency failures, lost connections and coroutine cancellation, and clear the recovery candidate after successful marshalling so commit or completion-listener failures do not delete a valid job. Preserve the original reservation error when ordinary failure handling also throws, while propagating cancellation. Widen attempts to unsignedSmallInteger in the generated jobs migration and Testbench skeleton. The upstream DatabaseJob record typing and Controller formatting changes are already present. Correct an adjacent transaction defect: nested concurrency failures lost their SQLSTATE and PDO errorInfo when wrapped in DeadlockException. That prevented outer transactions from retrying and misclassified valid queued jobs as invalid. Preserve the driver metadata while retaining the existing exception constructor arguments and defaults, adding string-code support. Add focused coverage for blocked queues, nested recovery, transient errors, cancellation, commit-listener failures, failed physical rollback and nested transaction retry with preserved error metadata. All recovery state is local to the invocation; no extra queries run on successful reservations. Validation: changed test files, focused queue/database ParaTest suites, Testbench package-mode suite, full source and type-fixture PHPStan, formatting and whitespace checks pass. Independent review also verified the full database package and the original nested-failure reproduction. --- .../src/Concerns/ManagesTransactions.php | 2 +- src/database/src/DeadlockException.php | 15 ++ src/queue/src/Console/stubs/jobs.stub | 2 +- src/queue/src/DatabaseQueue.php | 48 +++- ..._01_000006_testbench_create_jobs_table.php | 2 +- tests/Database/DatabaseConnectionTest.php | 40 ++++ .../Sqlite/DatabaseQueueReservationTest.php | 220 ++++++++++++++++++ tests/Queue/QueueDatabaseQueueUnitTest.php | 93 ++++++++ 8 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index a2cee70856..9e013620a3 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -123,7 +123,7 @@ protected function handleTransactionException(Throwable $e, int $currentAttempt, $exception = new DeadlockException( $e->getMessage(), - is_int($e->getCode()) ? $e->getCode() : 0, + $e->getCode(), $e ); diff --git a/src/database/src/DeadlockException.php b/src/database/src/DeadlockException.php index 9321145734..9b5fb136b1 100644 --- a/src/database/src/DeadlockException.php +++ b/src/database/src/DeadlockException.php @@ -5,7 +5,22 @@ namespace Hypervel\Database; use PDOException; +use Throwable; class DeadlockException extends PDOException { + /** + * Create a new deadlock exception instance. + */ + public function __construct(string $message = '', int|string $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + + // Losing driver metadata makes nested concurrency failures unrecognizable to error detectors. + $this->code = $code; + + if ($previous instanceof PDOException) { + $this->errorInfo = $previous->errorInfo; + } + } } diff --git a/src/queue/src/Console/stubs/jobs.stub b/src/queue/src/Console/stubs/jobs.stub index bd85a99990..718b75b406 100644 --- a/src/queue/src/Console/stubs/jobs.stub +++ b/src/queue/src/Console/stubs/jobs.stub @@ -17,7 +17,7 @@ return new class extends Migration $table->id(); $table->string('queue')->index(); $table->jsonb('payload'); - $table->unsignedTinyInteger('attempts'); + $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); $table->unsignedInteger('created_at'); diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 946c7bb6f7..2829f5905c 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -12,6 +12,8 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseTransactionsManager; +use Hypervel\Database\DetectsConcurrencyErrors; +use Hypervel\Database\DetectsLostConnections; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; use Hypervel\Queue\Concerns\InsertsDatabaseRows; @@ -25,6 +27,8 @@ class DatabaseQueue extends Queue implements QueueContract, ClearableQueue { + use DetectsConcurrencyErrors; + use DetectsLostConnections; use InsertsDatabaseRows; public const int DEFAULT_RETRY_AFTER = 60; @@ -478,12 +482,48 @@ protected function buildDatabaseRecord(?string $queue, string $payload, int $ava public function pop(?string $queue = null): ?Job { $queue = $this->getQueue($queue); + $database = $this->getDatabase(); + $transactionLevel = $database->transactionLevel(); + /** @var null|DatabaseJobRecord $jobRecord */ + $jobRecord = null; - return $this->getDatabase()->transaction(function () use ($queue) { - if ($job = $this->getNextAvailableJob($queue)) { - return $this->marshalJob($queue, $job); + try { + return $database->transaction(function () use ($queue, &$jobRecord) { + if ($jobRecord = $this->getNextAvailableJob($queue)) { + $job = $this->marshalJob($queue, $jobRecord); + + // A commit or completion callback failure does not make this job invalid. + $jobRecord = null; + + return $job; + } + }); + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + // Recovery requires our transaction to have unwound. Transient database + // failures leave the job available for another reservation attempt. + if ($jobRecord !== null + && $database->transactionLevel() === $transactionLevel + && ! $this->causedByConcurrencyError($exception) + && ! $this->causedByLostConnection($exception)) { + try { + (new DatabaseJob( + $this->container, + $this, + $jobRecord, + $this->connectionName, + $queue + ))->fail($exception); + } catch (CanceledException $cancellation) { + throw $cancellation; + } catch (Throwable) { + // Preserve the original reservation failure if failing the job also fails. + } } - }); + + throw $exception; + } } /** diff --git a/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php b/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php index 7c9b8f9b7b..585f9fab8c 100644 --- a/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php +++ b/src/testbench/hypervel/migrations/0001_01_01_000006_testbench_create_jobs_table.php @@ -16,7 +16,7 @@ public function up(): void $table->id(); $table->string('queue')->index(); $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); + $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); $table->unsignedInteger('created_at'); diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index db96ee9e81..d427bd06c5 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -36,6 +36,7 @@ use Mockery as m; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -668,6 +669,45 @@ public function testTransactionMethodRetriesOnDeadlock() }, 3); } + #[TestWith(['40001'])] + #[TestWith(['55P03'])] + public function testTransactionRetriesNestedConcurrencyFailuresWithDriverMetadata(string $sqlState): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $previous = new PDOExceptionStub('Concurrent update could not complete.', $sqlState); + $previous->errorInfo = [$sqlState, 7, $previous->getMessage()]; + $failure = new QueryException('test', 'update records set value = 1', [], $previous); + $attempts = 0; + $wrappedExceptions = 0; + + $result = $connection->transaction(function () use ($connection, $failure, &$attempts, &$wrappedExceptions): string { + ++$attempts; + + try { + return $connection->transaction(static function () use ($failure, $attempts): string { + if ($attempts === 1) { + throw $failure; + } + + return 'success'; + }); + } catch (DeadlockException $exception) { + ++$wrappedExceptions; + + $this->assertSame($failure->getCode(), $exception->getCode()); + $this->assertSame($failure->errorInfo, $exception->errorInfo); + $this->assertSame($failure, $exception->getPrevious()); + + throw $exception; + } + }, 2); + + $this->assertSame('success', $result); + $this->assertSame(2, $attempts); + $this->assertSame(1, $wrappedExceptions); + $this->assertSame(0, $connection->transactionLevel()); + } + public function testTransactionMethodRollsbackAndThrows() { $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['inTransaction', 'beginTransaction', 'commit', 'rollBack'])->getMock(); diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php new file mode 100644 index 0000000000..d499ff7a28 --- /dev/null +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -0,0 +1,220 @@ +createQueue(); + $database = $queue->getDatabase(); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $failedId = $queue->pushRaw($payload); + $nextId = $queue->pushRaw($payload); + $database->table('jobs')->where('id', $failedId)->update(['attempts' => 65535]); + $failed = null; + $events->listen(JobFailed::class, static function (JobFailed $event) use (&$failed): void { + $failed = $event; + }); + + if ($transactionLevel === 1) { + $database->beginTransaction(); + } + + try { + try { + $queue->pop(); + $this->fail('Expected the attempts constraint to reject the reservation.'); + } catch (QueryException $exception) { + $this->assertSame($exception, $failed?->exception); + } + + $this->assertSame((string) $failedId, $failed->job->getJobId()); + $this->assertSame('database', $failed->connectionName); + $this->assertFalse($database->table('jobs')->where('id', $failedId)->exists()); + $this->assertSame((string) $nextId, $queue->pop()?->getJobId()); + $this->assertSame($transactionLevel, $database->transactionLevel()); + } finally { + if ($transactionLevel === 1) { + $database->rollBack(); + } + } + } + + public function testNestedConcurrencyFailureDoesNotFailTheJob(): void + { + $pdo = new PDO('sqlite::memory:'); + [$queue, $events] = $this->createQueue($pdo); + $database = $queue->getDatabase(); + $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $previous = new class extends PDOException { + /** + * Create a driver exception identified only by its SQLSTATE. + */ + public function __construct() + { + parent::__construct('Could not serialize access due to concurrent update.'); + + $this->code = '40001'; + } + }; + $failure = new QueryException('database', 'update jobs', [], $previous); + $database->beforeExecuting(static function (string $query) use ($failure): void { + if (str_starts_with($query, 'update ')) { + throw $failure; + } + }); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $database->beginTransaction(); + + try { + try { + $queue->pop(); + $this->fail('Expected the nested concurrency failure.'); + } catch (DeadlockException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertFalse($failed); + $this->assertSame(1, $database->transactionLevel()); + $this->assertSame(1, (int) $pdo->query('select count(*) from jobs')->fetchColumn()); + } finally { + $database->rollBack(); + } + } + + public function testCommittedListenerFailureKeepsTheReservedJob(): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $failure = new RuntimeException('Committed listener failed.'); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $events->listen(TransactionCommitted::class, static function () use ($failure): never { + throw $failure; + }); + + try { + $queue->pop(); + $this->fail('Expected the committed listener failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertNotNull($record->reserved_at); + $this->assertSame(1, $record->attempts); + $this->assertFalse($failed); + } + + public function testFailedRollbackDoesNotFailTheJobInTheOpenTransaction(): void + { + $pdo = new ReservationRollbackFailingPdo('sqlite::memory:'); + [$queue, $events] = $this->createQueue($pdo); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $database->table('jobs')->where('id', $id)->update(['attempts' => 65535]); + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $pdo->failRollback = true; + + try { + try { + $queue->pop(); + $this->fail('Expected the original reservation failure.'); + } catch (QueryException $exception) { + $this->assertStringContainsString('CHECK constraint failed', $exception->getMessage()); + } + + $this->assertSame(1, $database->transactionLevel()); + $this->assertFalse($failed); + $this->assertSame(1, (int) $pdo->query('select count(*) from jobs')->fetchColumn()); + } finally { + $pdo->failRollback = false; + $database->rollBack(); + } + } + + /** + * Create a queue backed by an isolated SQLite database with an enforced attempts limit. + * + * @return array{DatabaseQueue, Dispatcher} + */ + private function createQueue(?PDO $pdo = null): array + { + $database = new PdoConnection($pdo ?? new PDO('sqlite::memory:')); + $database->setQueryGrammar(new SQLiteGrammar($database)); + + // SQLite ignores integer widths, so enforce the migration's unsignedSmallInteger ceiling explicitly. + $database->statement('create table jobs ( + id integer primary key autoincrement, + queue text not null, + payload text not null, + attempts integer not null check (attempts <= 65535), + reserved_at integer, + available_at integer not null, + created_at integer not null + )'); + + $container = new Container; + $events = new Dispatcher($container); + $container->instance(DispatcherContract::class, $events); + $database->setEventDispatcher($events); + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($database); + $queue = new DatabaseQueue($resolver, null, 'jobs'); + $queue->setContainer($container); + $queue->setConnectionName('database'); + + return [$queue, $events]; + } +} + +class ReservationRollbackFailingPdo extends PDO +{ + public bool $failRollback = false; + + /** + * Fail the physical rollback when testing reservation cleanup. + */ + public function rollBack(): bool + { + if ($this->failRollback) { + throw new RuntimeException('Physical rollback failed.'); + } + + return parent::rollBack(); + } +} diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 85cd257779..b966709542 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -11,6 +11,7 @@ use Hypervel\Bus\DispatchLockContext; use Hypervel\Container\Container; use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; @@ -22,6 +23,7 @@ use Hypervel\Events\Dispatcher; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\DatabaseQueue; +use Hypervel\Queue\Events\JobFailed; use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; @@ -34,11 +36,13 @@ use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; +use PDOException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use Throwable; use TypeError; class QueueDatabaseQueueUnitTest extends TestCase @@ -105,6 +109,65 @@ public function testLockForPoppingUsesAConservativeFallbackForNonPdoConnections( $this->assertTrue($queue->lockForPopping()); } + #[DataProvider('transientReservationFailureProvider')] + public function testTransientReservationFailuresDoNotFailTheJob(Throwable $failure): void + { + [$queue, $events] = $this->createFailingReservationQueue($failure); + $queue->shouldReceive('deleteReserved')->never(); + $events->shouldReceive('dispatch')->never(); + + try { + $queue->pop(); + $this->fail('Expected the reservation failure.'); + } catch (Throwable $exception) { + $this->assertSame($failure, $exception); + } + } + + /** + * Provide reservation failures that do not indicate an invalid job. + */ + public static function transientReservationFailureProvider(): array + { + return [ + 'concurrency' => [new PDOException('deadlock detected', 40001)], + 'lost connection' => [new PDOException('server has gone away')], + 'cancellation' => [new CanceledException('Reservation canceled.')], + ]; + } + + #[DataProvider('reservationCleanupFailureProvider')] + public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure): void + { + $failure = new RuntimeException('Reservation failed.'); + [$queue, $events] = $this->createFailingReservationQueue($failure); + $queue->shouldReceive('deleteReserved')->once()->with('default', '1')->andThrow($cleanupFailure); + + if ($cleanupFailure instanceof CanceledException) { + $events->shouldReceive('dispatch')->never(); + } else { + $events->shouldReceive('hasListeners')->once()->with(JobFailed::class)->andReturn(false); + } + + try { + $queue->pop(); + $this->fail('Expected the reservation or cancellation failure.'); + } catch (Throwable $exception) { + $this->assertSame($cleanupFailure instanceof CanceledException ? $cleanupFailure : $failure, $exception); + } + } + + /** + * Provide failures while deleting a job that could not be reserved. + */ + public static function reservationCleanupFailureProvider(): array + { + return [ + 'ordinary failure' => [new RuntimeException('Deletion failed.')], + 'cancellation' => [new CanceledException('Deletion canceled.')], + ]; + } + #[DataProvider('pushJobsDataProvider')] public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameStartsWith, $jobStartsWith) { @@ -955,6 +1018,36 @@ public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void } } + /** + * Create a queue whose selected job cannot be reserved. + */ + private function createFailingReservationQueue(Throwable $failure): array + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + $connection->shouldReceive('transactionLevel')->andReturn(0); + $connection->shouldReceive('transaction')->once()->andReturnUsing(static fn (Closure $callback) => $callback()); + + $container = new Container; + $events = m::mock(DispatcherContract::class); + $container->instance(DispatcherContract::class, $events); + $queue = m::mock(DatabaseQueue::class, [$resolver, null, 'jobs']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $queue->setContainer($container); + $queue->setConnectionName('database'); + $record = new DatabaseJobRecord((object) [ + 'id' => 1, + 'payload' => json_encode(['job' => stdClass::class, 'data' => []]), + 'attempts' => 255, + ]); + $queue->shouldReceive('getNextAvailableJob')->once()->with('default')->andReturn($record); + $queue->shouldReceive('marshalJob')->once()->with('default', $record)->andThrow($failure); + + return [$queue, $events]; + } + private function createInspectionQueue(): array { $resolver = m::mock(ConnectionResolverInterface::class); From 54864aef4c59fd1ac100fb902066a508c4eaf815 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:36:26 +0000 Subject: [PATCH 12/23] Port queue forwarding and preserve queue identity across storage operations Port https://github.com/laravel/framework/pull/61188 from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2, including all applicable upstream tests and current forwarding documentation. Add Queue::forward with enum-aware boot-time registration. Resolve forwarded connections after dynamic listener, broadcast and notification queue selection; retain explicit connection precedence and class-route fallback. Adapt storage boundaries for Database, Redis, Beanstalkd and SQS, including FIFO validation. Failover applies only its own connection-scoped forwards before delegation, leaving omitted defaults and unscoped forwarding to each storage driver. Correct upstream repeated forwarding during database reservation/release and Redis global size calculations. Jobs retain logical queue names while storage operations resolve them once. Redis totals and inspection use discovered physical keys directly, preserving pinned pooled connections, Cluster hash tags and pre-forward backlogs. Global totals no longer call per-queue size overrides; allQueueNames remains the discovery extension point. Correct Horizon clearing to purge the forwarded destination on the selected connection, preserving records for live jobs on separate storage. Add optional connection filtering to the existing purge operation and its repository contract. Retain one-argument queue-wide purge. Non-Horizon clearable drivers do not purge Horizon records, and unsupported drivers fail before any purge. When connection aliases share physical storage, clearing removes all queued jobs but leaves other aliases' dashboard records until expiry/trim; document and test this deliberate tradeoff without adding draining or temporary-key machinery. Regenerate affected facades and document forwarding, default-queue semantics, worker queue lists, draining existing backlogs and clear behavior. Add focused regressions for dynamic selection, zero identifiers, failover delegation, reservation/release chains, physical totals and Horizon metadata isolation. Validation: full source/type PHPStan, formatting and facade lint; affected ParaTest packages; SQLite reservation tests; Redis and Horizon integration tests on standalone Redis and Redis Cluster. Final review corrections and affected tests pass, including both separate and shared physical queue storage. --- src/broadcasting/src/BroadcastManager.php | 6 +- src/docs/horizon.md | 8 ++ src/docs/queues.md | 25 ++++ src/events/src/Dispatcher.php | 10 +- src/horizon/src/Console/ClearCommand.php | 23 +++- src/horizon/src/Contracts/JobRepository.php | 5 + src/horizon/src/LuaScripts.php | 9 +- src/horizon/src/RedisQueue.php | 3 + .../src/Repositories/RedisJobRepository.php | 5 +- src/notifications/src/NotificationSender.php | 6 +- src/queue/src/BeanstalkdQueue.php | 2 +- src/queue/src/DatabaseQueue.php | 5 +- src/queue/src/FailoverQueue.php | 32 +++-- src/queue/src/Queue.php | 10 ++ src/queue/src/QueueManager.php | 13 ++ src/queue/src/QueueRoutes.php | 84 +++++++++++- src/queue/src/RedisQueue.php | 55 ++++++-- src/queue/src/SqsQueue.php | 2 +- src/support/src/Facades/Broadcast.php | 2 +- src/support/src/Facades/Bus.php | 2 +- src/support/src/Facades/Event.php | 2 +- src/support/src/Facades/Notification.php | 2 +- src/support/src/Facades/Queue.php | 3 +- .../Queue/Concerns/ResolvesQueueRoutes.php | 5 +- tests/Bus/BusDispatcherTest.php | 46 +++++++ tests/Events/QueuedEventsTest.php | 50 +++++++ .../Broadcasting/BroadcastManagerTest.php | 57 ++++++++ .../Horizon/Feature/ClearCommandTest.php | 94 ++++++++++++- .../Horizon/Feature/QueueProcessingTest.php | 34 +++++ .../Feature/RedisJobRepositoryTest.php | 22 ++- .../Mail/SendingQueuedMailTest.php | 26 ++++ .../Sqlite/DatabaseQueueReservationTest.php | 52 +++++++ .../Queue/Redis/RedisQueueTest.php | 54 ++++++++ .../Notifications/NotificationSenderTest.php | 40 ++++++ tests/Queue/FailoverQueueTest.php | 39 ++++++ tests/Queue/QueueRedisQueueTest.php | 42 +++--- tests/Queue/QueueRoutesTest.php | 128 ++++++++++++++++++ tests/Queue/QueueSqsQueueTest.php | 37 +++++ 38 files changed, 960 insertions(+), 80 deletions(-) diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 4453b17a5c..91fc614e17 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -217,8 +217,7 @@ public function queue(mixed $event): void if (is_null($queue)) { $queue = $this->getAttributeValue($event, QueueAttribute::class, 'queue') - ?? $this->resolveQueueFromQueueRoute($event) - ?? null; + ?? $this->resolveQueueFromQueueRoute($event); } $broadcastEvent = $event instanceof ShouldBeUnique @@ -233,8 +232,7 @@ public function queue(mixed $event): void ->connection( $event->connection ?? $this->getAttributeValue($event, ConnectionAttribute::class, 'connection') - ?? $this->resolveConnectionFromQueueRoute($event) - ?? null + ?? $this->resolveConnectionFromQueueRoute($event, $queue) ) ->pushOn($queue, $broadcastEvent); diff --git a/src/docs/horizon.md b/src/docs/horizon.md index eb51a56d32..d3a0db2576 100644 --- a/src/docs/horizon.md +++ b/src/docs/horizon.md @@ -853,3 +853,11 @@ You may provide the `queue` option to delete jobs from a specific queue: ```shell php artisan horizon:clear --queue=emails ``` + +To clear a queue on a specific connection, pass the connection name to the command: + +```shell +php artisan horizon:clear redis --queue=emails +``` + +If multiple connections share the same Redis queue, clearing it removes all jobs from that queue. Horizon removes dashboard records for the selected connection; records for the other connections remain until they expire and are trimmed. diff --git a/src/docs/queues.md b/src/docs/queues.md index da6f0f0387..d0f02f4637 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -1688,6 +1688,31 @@ Queue::route([ > [!NOTE] > Queue routing can still be overridden by the job on a per-job basis. +You may use the `forward` method to forward jobs from one queue to another queue and / or connection. This is useful when you need to change queue infrastructure without modifying individual jobs or dispatch locations. Register forwarding in a service provider's `boot` method: + +```php +Queue::forward('reports', 'reports.fifo', 'sqs'); +Queue::forward('payments', connection: 'sqs'); +Queue::forward('updates', 'notifications'); +``` + +You may also forward multiple queues at once by passing an array: + +```php +Queue::forward([ + 'reports' => 'reports.fifo', + 'emails' => 'emails.fifo', +], connection: 'sqs'); +``` + +An explicit connection configured on a job takes precedence over a forwarded connection. + +A forward scoped to a `failover` connection requires an explicit queue name; otherwise, each child connection uses its own default queue. + +After forwarding queues, update your worker queue lists to avoid listing multiple names that resolve to the same queue. Before forwarding a queue to a different name, drain its existing jobs. Workers using the forwarding configuration will consume the destination queue instead. + +Clearing a forwarded queue clears its destination, including jobs sent through other queue names that forward to the same destination. + ### Specifying Max Job Attempts / Timeout Values diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index ed28af727a..9a0ecabdd4 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -965,10 +965,6 @@ protected function queueHandler(string $class, string $method, array $arguments) $connectionName = (string) enum_value($connectionName); } - $connection = $this->resolveQueue()->connection( - $connectionName ?? $this->resolveConnectionFromQueueRoute($listener) ?? null - ); - $queue = method_exists($listener, 'viaQueue') ? (isset($arguments[0]) ? $listener->viaQueue($arguments[0]) : $listener->viaQueue()) : $this->getAttributeValue($listener, QueueAttribute::class, 'queue'); @@ -978,13 +974,17 @@ protected function queueHandler(string $class, string $method, array $arguments) : $this->getAttributeValue($listener, Delay::class, 'delay'); if (is_null($queue)) { - $queue = $this->resolveQueueFromQueueRoute($listener) ?? null; + $queue = $this->resolveQueueFromQueueRoute($listener); } if ($queue instanceof UnitEnum) { $queue = (string) enum_value($queue); } + $connection = $this->resolveQueue()->connection( + $connectionName ?? $this->resolveConnectionFromQueueRoute($listener, $queue) + ); + if ($debounceFor !== null) { $debounce = (new DebounceLock($this->container->make(Cache::class)))->acquireForDispatch( $job, diff --git a/src/horizon/src/Console/ClearCommand.php b/src/horizon/src/Console/ClearCommand.php index e03ec1db1d..791181d262 100644 --- a/src/horizon/src/Console/ClearCommand.php +++ b/src/horizon/src/Console/ClearCommand.php @@ -6,8 +6,12 @@ use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; +use Hypervel\Contracts\Queue\ClearableQueue; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Str; +use ReflectionClass; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'horizon:clear')] @@ -44,13 +48,24 @@ public function handle(JobRepository $jobRepository, QueueManager $manager): ?in } $queue = $this->getQueue($connection); + $queueConnection = $manager->connection($connection); - if (method_exists($jobRepository, 'purge')) { - $jobRepository->purge($queue); + if (! $queueConnection instanceof ClearableQueue) { + $this->components->error('Clearing queues is not supported on [' . (new ReflectionClass($queueConnection))->getShortName() . ']'); + + return 1; + } + + if ($queueConnection instanceof RedisQueue) { + // Horizon records the forwarded destination; clear still needs the original + // queue name so the destination is not forwarded a second time. + $jobRepository->purge( + Str::replaceFirst('queues:', '', $queueConnection->getQueue($queue)), + $queueConnection->getConnectionName(), + ); } - /** @phpstan-ignore-next-line */ - $count = $manager->connection($connection)->clear($queue); + $count = $queueConnection->clear($queue); $this->components->info('Cleared ' . $count . ' jobs from the [' . $queue . '] queue.'); diff --git a/src/horizon/src/Contracts/JobRepository.php b/src/horizon/src/Contracts/JobRepository.php index d317570b7e..69c1338a15 100644 --- a/src/horizon/src/Contracts/JobRepository.php +++ b/src/horizon/src/Contracts/JobRepository.php @@ -155,4 +155,9 @@ public function storeRetryReference(string $id, string $retryId): void; * Delete a failed job by ID. */ public function deleteFailed(string $id): int; + + /** + * Delete pending and reserved jobs for a queue, optionally on one connection. + */ + public function purge(string $queue, ?string $connection = null): int; } diff --git a/src/horizon/src/LuaScripts.php b/src/horizon/src/LuaScripts.php index dbc47910ac..2cab139b20 100644 --- a/src/horizon/src/LuaScripts.php +++ b/src/horizon/src/LuaScripts.php @@ -43,12 +43,14 @@ public static function updateMetrics(): string * ARGV[1] - The prefix of the Horizon keys * ARGV[2] - The name of the queue to purge * ARGV[3] - The cursor position + * ARGV[4] - The optional connection name to purge */ public static function purge(): string { return <<<'LUA' local count = 0 local cursor = ARGV[3] + local connection = ARGV[4] -- Iterate over the recent jobs sorted set local scanner = redis.call('zscan', KEYS[1], cursor) @@ -57,11 +59,12 @@ public static function purge(): string for i = 1, #scanner[2], 2 do local jobid = scanner[2][i] local hashkey = ARGV[1] .. jobid - local job = redis.call('hmget', hashkey, 'status', 'queue') + local job = redis.call('hmget', hashkey, 'status', 'queue', 'connection') -- Delete the pending/reserved jobs, that match the queue - -- name, from the sorted sets as well as the job hash - if((job[1] == 'reserved' or job[1] == 'pending') and job[2] == ARGV[2]) then + -- and optional connection, from the sorted sets and job hash. + if((job[1] == 'reserved' or job[1] == 'pending') and job[2] == ARGV[2] + and (connection == nil or job[3] == connection)) then redis.call('zrem', KEYS[1], jobid) redis.call('zrem', KEYS[2], jobid) redis.call('del', hashkey) diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index bc3c498181..984a135053 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -172,6 +172,9 @@ public function pop(?string $queue = null, int $index = 0): ?Job /** * Migrate the delayed jobs that are ready to the regular queue. + * + * @param string $from the formatted Redis source key, already resolved through queue forwarding + * @param string $to the formatted Redis destination key, already resolved through queue forwarding */ #[Override] public function migrateExpiredJobs(string $from, string $to): array diff --git a/src/horizon/src/Repositories/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index 32a27f795d..cfbf0e74de 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -609,9 +609,9 @@ public function deleteFailed(string $id): int } /** - * Delete pending and reserved jobs for a queue. + * Delete pending and reserved jobs for a queue, optionally on one connection. */ - public function purge(string $queue): int + public function purge(string $queue, ?string $connection = null): int { $count = 0; $cursor = 0; @@ -625,6 +625,7 @@ public function purge(string $queue): int config()->string('horizon.prefix'), $queue, $cursor, + ...($connection === null ? [] : [$connection]), ); $count += $result[0]; diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index 78bf2384e9..4a8d5ee690 100644 --- a/src/notifications/src/NotificationSender.php +++ b/src/notifications/src/NotificationSender.php @@ -223,9 +223,7 @@ protected function queueNotification(mixed $notifiables, mixed $notification): v $notification->locale = $this->locale; } - $connection = $this->getAttributeValue($notification, Connection::class, 'connection') - ?? $this->manager->resolveConnectionFromQueueRoute($notification) - ?? null; + $connection = $this->getAttributeValue($notification, Connection::class, 'connection'); if (method_exists($notification, 'viaConnections')) { $connection = $notification->viaConnections()[$channel] ?? $connection; @@ -239,6 +237,8 @@ protected function queueNotification(mixed $notifiables, mixed $notification): v $queue = $notification->viaQueues()[$channel] ?? $queue; } + $connection ??= $this->manager->resolveConnectionFromQueueRoute($notification, $queue); + $delay = method_exists($notification, 'withDelay') ? ($notification->withDelay($notifiable, $channel) ?? null) : $this->getAttributeValue($notification, Delay::class, 'delay'); diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index bdde992b5d..42159fdd27 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -270,7 +270,7 @@ public function deleteMessage(string $queue, int|string $id): void */ public function getQueue(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 2829f5905c..1456ef24a6 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -481,7 +481,8 @@ protected function buildDatabaseRecord(?string $queue, string $payload, int $ava */ public function pop(?string $queue = null): ?Job { - $queue = $this->getQueue($queue); + // Keep the logical name on the job so reservation and release each forward it once. + $queue = $queue === null || $queue === '' ? $this->default : $queue; $database = $this->getDatabase(); $transactionLevel = $database->transactionLevel(); /** @var null|DatabaseJobRecord $jobRecord */ @@ -651,7 +652,7 @@ public function clear(?string $queue): int */ public function getQueue(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 3f99aa415e..7ab12d878a 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -50,7 +50,7 @@ public function __construct( */ public function size(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->size($queue); + return $this->manager->connection($this->connections[0])->size($this->resolveForwardedQueue($queue)); } /** @@ -58,7 +58,7 @@ public function size(?string $queue = null): int */ public function pendingSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->pendingSize($queue); + return $this->manager->connection($this->connections[0])->pendingSize($this->resolveForwardedQueue($queue)); } /** @@ -66,7 +66,7 @@ public function pendingSize(?string $queue = null): int */ public function delayedSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->delayedSize($queue); + return $this->manager->connection($this->connections[0])->delayedSize($this->resolveForwardedQueue($queue)); } /** @@ -74,7 +74,7 @@ public function delayedSize(?string $queue = null): int */ public function reservedSize(?string $queue = null): int { - return $this->manager->connection($this->connections[0])->reservedSize($queue); + return $this->manager->connection($this->connections[0])->reservedSize($this->resolveForwardedQueue($queue)); } /** @@ -116,7 +116,7 @@ public function totalReservedSize(): int public function pendingJobs(?string $queue = null): Collection { // Inspection remains an optional concrete capability, not part of the core Queue contract. - return $this->manager->connection($this->connections[0])->pendingJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->pendingJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -124,7 +124,7 @@ public function pendingJobs(?string $queue = null): Collection */ public function delayedJobs(?string $queue = null): Collection { - return $this->manager->connection($this->connections[0])->delayedJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->delayedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -132,7 +132,7 @@ public function delayedJobs(?string $queue = null): Collection */ public function reservedJobs(?string $queue = null): Collection { - return $this->manager->connection($this->connections[0])->reservedJobs($queue); // @phpstan-ignore method.notFound + return $this->manager->connection($this->connections[0])->reservedJobs($this->resolveForwardedQueue($queue)); // @phpstan-ignore method.notFound } /** @@ -166,7 +166,7 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int { return $this->manager ->connection($this->connections[0]) - ->creationTimeOfOldestPendingJob($queue); + ->creationTimeOfOldestPendingJob($this->resolveForwardedQueue($queue)); } /** @@ -174,6 +174,8 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int */ public function push(object|string $job, mixed $data = '', ?string $queue = null): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); } @@ -182,6 +184,8 @@ public function push(object|string $job, mixed $data = '', ?string $queue = null */ public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args()); } @@ -190,6 +194,8 @@ public function pushRaw(string $payload, ?string $queue = null, array $options = */ public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed { + $queue = $this->resolveForwardedQueue($queue); + return $this->attemptOnAllConnections(__FUNCTION__, func_get_args(), $job); } @@ -198,6 +204,7 @@ public function later(DateInterval|DateTimeInterface|int $delay, object|string $ */ public function pop(?string $queue = null, int $index = 0): ?JobContract { + $queue = $this->resolveForwardedQueue($queue); $connection = $this->manager->connection($this->connections[0]); return $connection instanceof IndexAwareQueue @@ -205,6 +212,15 @@ public function pop(?string $queue = null, int $index = 0): ?JobContract : $connection->pop($queue); } + /** + * Resolve forwards owned by this failover connection. + */ + protected function resolveForwardedQueue(?string $queue): ?string + { + // Unscoped forwards belong to the storage driver; applying them here would forward twice. + return $queue === null ? null : $this->queueRoutes()->forwardedQueueForConnection($queue, $this->connectionName ?? null); + } + /** * Attempt the given method on all connections. * diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 0dbe8934c8..9c861b9355 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -31,6 +31,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Facades\Context; use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes; use Hypervel\Support\Str; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -42,6 +43,7 @@ abstract class Queue { use InteractsWithTime; use ReadsQueueAttributes; + use ResolvesQueueRoutes; /** * The IoC container instance. @@ -662,6 +664,14 @@ protected function raiseJobQueuedEvent(?string $queue, mixed $jobId, object|stri } } + /** + * Get the routed queue name for the given queue. + */ + protected function resolveQueue(string $queue): string + { + return $this->queueRoutes()->forwardedQueue($queue, $this->connectionName ?? null); + } + /** * Get the connection name for the queue. */ diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 2c0d8eb51d..2d27b9dc65 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -169,6 +169,19 @@ public function route(array|string $class, UnitEnum|string|null $queue = null, U $this->queueRoutes()->set($class, $queue, $connection); } + /** + * Forward the given queue to another queue and/or connection. + * + * Boot-only. Forwards persist on the singleton QueueRoutes registry for + * the worker lifetime and affect every subsequent dispatch and queue operation. + * + * @param array|string|UnitEnum $queue + */ + public function forward(array|string|UnitEnum $queue, UnitEnum|string|null $to = null, UnitEnum|string|null $connection = null): void + { + $this->queueRoutes()->forward($queue, $to, $connection); + } + /** * Pause a queue by its connection and name. */ diff --git a/src/queue/src/QueueRoutes.php b/src/queue/src/QueueRoutes.php index ad1a79ec29..923bf8ff6b 100644 --- a/src/queue/src/QueueRoutes.php +++ b/src/queue/src/QueueRoutes.php @@ -4,12 +4,16 @@ namespace Hypervel\Queue; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; +use Hypervel\Support\Traits\ReadsClassAttributes; use UnitEnum; use function Hypervel\Support\enum_value; class QueueRoutes { + use ReadsClassAttributes; + /** * The mapping of class names to their default routes. * @@ -17,20 +21,44 @@ class QueueRoutes */ protected array $routes = []; + /** + * The queues that have been forwarded to another queue and/or connection. + * + * @var array + */ + protected array $forwards = []; + /** * Get the queue connection that a given queueable instance should be routed to. */ - public function getConnection(object $queueable): ?string + public function getConnection(object $queueable, UnitEnum|string|null $queue = null): ?string { $route = $this->getRoute($queueable); - if (is_null($route)) { + if (is_array($route) && $route[0] !== null) { + return $route[0]; + } + + if (empty($this->forwards)) { return null; } - return is_string($route) - ? null - : $route[0]; + return $this->forwardedConnection( + $queue ?? $this->getAttributeValue($queueable, QueueAttribute::class, 'queue') + ?? (is_string($route) ? $route : ($route[1] ?? null)) + ); + } + + /** + * Get the connection the given queue has been forwarded to. + */ + protected function forwardedConnection(UnitEnum|string|null $queue): ?string + { + if (is_null($queue)) { + return null; + } + + return $this->forwards[enum_value($queue)][0] ?? null; } /** @@ -49,6 +77,32 @@ public function getQueue(object $queueable): ?string : $route[1]; } + /** + * Get the queue the given queue has been forwarded to. + */ + public function forwardedQueue(string $queue, ?string $connection = null): string + { + if (! isset($this->forwards[$queue])) { + return $queue; + } + + [$forwardConnection, $forwardQueue] = $this->forwards[$queue]; + + return is_null($forwardConnection) || $forwardConnection === $connection + ? $forwardQueue ?? $queue + : $queue; + } + + /** + * Apply only forwards explicitly scoped to the given connection. + */ + public function forwardedQueueForConnection(string $queue, ?string $connection): string + { + return isset($this->forwards[$queue][0]) + ? $this->forwardedQueue($queue, $connection) + : $queue; + } + /** * Get the route for a given queueable instance. * @@ -98,6 +152,26 @@ public function set(array|string $class, UnitEnum|string|null $queue = null, Uni } } + /** + * Register a forward for the given queue. + * + * Boot-only. Forwards persist on the singleton registry for the worker + * lifetime and affect every subsequent dispatch and queue operation. + * + * @param array|string|UnitEnum $queue + */ + public function forward(UnitEnum|array|string $queue, UnitEnum|string|null $to = null, UnitEnum|string|null $connection = null): void + { + $forwards = is_array($queue) ? $queue : [enum_value($queue) => $to]; + + foreach ($forwards as $from => $destination) { + $this->forwards[$from] = [ + $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection, + $destination instanceof UnitEnum ? (string) enum_value($destination) : $destination, + ]; + } + } + /** * Get all registered queue routes. * diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 5a0ed14146..b63fcef6b0 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -107,7 +107,18 @@ public function reservedSize(?string $queue = null): int public function totalSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->size($name)), + fn (): int => $this->allQueueNames()->sum(function (string $name): int { + // Discovered names identify storage; forwarding them again would count the destination twice. + $queue = $this->formatQueueRedisKey($name); + + return $this->getConnection()->eval( + LuaScripts::size(), + 3, + $queue, + $queue . ':delayed', + $queue . ':reserved', + ); + }), ); } @@ -117,7 +128,7 @@ public function totalSize(): int public function totalPendingSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->pendingSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->llen($this->formatQueueRedisKey($name))), ); } @@ -127,7 +138,7 @@ public function totalPendingSize(): int public function totalDelayedSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->delayedSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->zcard($this->formatQueueRedisKey($name) . ':delayed')), ); } @@ -137,7 +148,7 @@ public function totalDelayedSize(): int public function totalReservedSize(): int { return $this->getConnection()->withPinnedConnection( - fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->reservedSize($name)), + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->getConnection()->zcard($this->formatQueueRedisKey($name) . ':reserved')), ); } @@ -276,9 +287,10 @@ function (RedisConnection $connection) use ($name, $suffix): Collection { */ protected function inspectAllQueues(string $suffix = ''): Collection { + // Scan results already name physical queues, including backlogs left before forwarding was configured. return $this->getConnection()->withConnection( fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection) - ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)), + ->flatMap(fn (string $name): Collection => $this->inspectJobsAtKey($connection, $this->formatQueueRedisKey($name), $name, $suffix)), transform: false, ); } @@ -290,7 +302,17 @@ protected function inspectAllQueues(string $suffix = ''): Collection */ protected function inspectJobsUsing(RedisConnection $connection, string $name, string $suffix): Collection { - $key = $this->getQueueRedisKey($name) . $suffix; + return $this->inspectJobsAtKey($connection, $this->getQueueRedisKey($name), $name, $suffix); + } + + /** + * Inspect a formatted storage key while retaining the requested queue identity. + * + * @return Collection + */ + protected function inspectJobsAtKey(RedisConnection $connection, string $key, string $name, string $suffix): Collection + { + $key .= $suffix; $payloads = $suffix === '' ? $connection->lrange($key, 0, -1) : $connection->zRange($key, 0, -1); @@ -687,23 +709,30 @@ protected function getRandomId(): string */ public function getQueue(?string $queue): string { - return 'queues:' . ($queue === null || $queue === '' ? $this->default : $queue); + return 'queues:' . $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** * Get the cluster-safe Redis key for the given queue. * - * Redis Cluster requires every key passed to a multi-key Lua script to live - * on the same hash slot. Queue payloads keep the logical queue name via - * getQueue(); only storage keys are hash-tagged here. + * Queue names are forwarded once before adding the storage prefix and hash tag. */ protected function getQueueRedisKey(?string $queue = null): string { - $queue = $queue === null || $queue === '' ? $this->default : $queue; + return $this->formatQueueRedisKey($this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue)); + } + /** + * Format a physical queue name as a cluster-safe Redis key. + * + * Redis Cluster requires every key passed to a multi-key Lua script to live + * on the same hash slot. Only storage keys are hash-tagged here. + */ + protected function formatQueueRedisKey(string $queue): string + { return $this->isClusterConnection() && ! RedisConnection::hasHashTag($queue) - ? $this->getQueue('{' . $queue . '}') - : $this->getQueue($queue); + ? 'queues:{' . $queue . '}' + : 'queues:' . $queue; } /** diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index a7c4392c9d..5bd041bd44 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -847,7 +847,7 @@ protected function ensureDelayIsSupported(DateInterval|DateTimeInterface|int|nul */ protected function resolveQueueName(?string $queue): string { - return $queue === null || $queue === '' ? $this->default : $queue; + return $this->resolveQueue($queue === null || $queue === '' ? $this->default : $queue); } /** diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index 084a445e4c..9045689e2a 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -26,7 +26,7 @@ * @method static \Pusher\Pusher pusher(array $config) * @method static void queue(mixed $event) * @method static \Hypervel\Broadcasting\BroadcastManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void routes(array|null $attributes = null) * @method static \Hypervel\Broadcasting\BroadcastManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 3a73874118..806bb0be1d 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -23,7 +23,7 @@ * @method static bool hasCommandHandler(mixed $command) * @method static \Hypervel\Bus\Dispatcher map(array $map) * @method static \Hypervel\Bus\Dispatcher pipeThrough(array $pipes) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Bus\Dispatcher withDispatchingAfterResponses() * @method static \Hypervel\Bus\Dispatcher withoutDispatchingAfterResponses() diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index 7bb4b5b56c..ea769d8217 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -28,7 +28,7 @@ * @method static void mixin(object $mixin, bool $replace = true) * @method static void observe(array|string $events, object|array|string $observer) * @method static void push(string $event, mixed $payload = []) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Events\Dispatcher setQueueResolver(callable $resolver) * @method static \Hypervel\Events\Dispatcher setTransactionManagerResolver(callable $resolver) diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index 73d648a3f0..2d6b67e887 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -25,7 +25,7 @@ * @method static \Hypervel\Notifications\ChannelManager locale(string $locale) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void send(mixed $notifiables, mixed $notification) * @method static void sendNow(mixed $notifiables, mixed $notification, array|null $channels = null) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 1528e78d32..09245b87d4 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -18,6 +18,7 @@ * @method static void exceptionOccurred(mixed $callback) * @method static void extend(string $driver, \Closure $resolver) * @method static void failing(mixed $callback) + * @method static void forward(array|string|\UnitEnum $queue, \UnitEnum|string|null $to = null, \UnitEnum|string|null $connection = null) * @method static \Hypervel\Contracts\Container\Container getApplication() * @method static string getDefaultDriver() * @method static string getName(string|null $connection = null) @@ -31,7 +32,7 @@ * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void purge(string|null $name = null) * @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void resume(string $connection, string $queue) * @method static void resumeAll() diff --git a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php index f24d3db821..b72342dcca 100644 --- a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php +++ b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php @@ -6,15 +6,16 @@ use Hypervel\Container\Container; use Hypervel\Queue\QueueRoutes; +use UnitEnum; trait ResolvesQueueRoutes { /** * Resolve the default connection name for a given queueable instance. */ - public function resolveConnectionFromQueueRoute(object $queueable): ?string + public function resolveConnectionFromQueueRoute(object $queueable, UnitEnum|string|null $queue = null): ?string { - return $this->queueRoutes()->getConnection($queueable); + return $this->queueRoutes()->getConnection($queueable, $queue); } /** diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index 4588fbea8e..89a1ecaa6a 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -134,6 +134,52 @@ public function testCommandsAreDispatchedWithQueueRoute() Container::setInstance(null); } + public function testCommandsAreForwardedToConnectionByQueueName(): void + { + Container::setInstance($container = new Container); + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $mock = m::mock(Queue::class); + $mock->expects('push')->with(m::type(BusDispatcherQueueable::class), '', 'reports'); + + $usedConnection = false; + + $dispatcher = new Dispatcher($container, function (?string $connection) use ($mock, &$usedConnection): Queue { + $usedConnection = $connection; + + return $mock; + }); + + $dispatcher->dispatch((new BusDispatcherQueueable)->onQueue('reports')); + + $this->assertSame('cloud', $usedConnection); + } + + public function testExplicitConnectionWinsOverForwardedQueue(): void + { + Container::setInstance($container = new Container); + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $mock = m::mock(Queue::class); + $mock->expects('push')->with(m::type(BusDispatcherQueueable::class), '', 'reports'); + + $usedConnection = false; + + $dispatcher = new Dispatcher($container, function (?string $connection) use ($mock, &$usedConnection): Queue { + $usedConnection = $connection; + + return $mock; + }); + + $dispatcher->dispatch((new BusDispatcherQueueable)->onConnection('redis')->onQueue('reports')); + + $this->assertSame('redis', $usedConnection); + } + public function testDispatchNowShouldNeverQueue() { $container = new Container; diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index 9869434a3c..9efcb75df2 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -256,6 +256,44 @@ public function testQueueIsSetUsingQueueRoutes(): void $d->dispatch('some.event', ['foo', 'bar']); } + public function testConnectionIsSetUsingForwardedQueue(): void + { + $container = new Container; + $d = new Dispatcher($container); + + $queueRoutes = new QueueRoutes; + $queueRoutes->forward('reports', 'processing', 'cloud'); + $container->instance('queue.routes', $queueRoutes); + + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + $factory->shouldReceive('connection')->once()->with('cloud')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('reports', m::type(CallQueuedListener::class)); + + Container::setInstance($container); + $d->setQueueResolver(fn (): QueueFactory => $factory); + $d->listen('some.event', TestDispatcherForwardedQueue::class . '@handle'); + $d->dispatch('some.event', ['foo', 'bar']); + } + + public function testForwardedConnectionUsesTheDynamicallySelectedQueue(): void + { + Container::setInstance($container = new Container); + $dispatcher = new Dispatcher($container); + $routes = new QueueRoutes; + $routes->forward('my_queue', 'unused', 'wrong-connection'); + $routes->forward('some_other_queue', 'processing', 'cloud'); + $container->instance('queue.routes', $routes); + $factory = m::mock(QueueFactory::class); + $queue = m::mock(Queue::class); + $factory->shouldReceive('connection')->once()->with('cloud')->andReturn($queue); + $queue->shouldReceive('pushOn')->once()->with('some_other_queue', m::type(CallQueuedListener::class)); + + $dispatcher->setQueueResolver(fn (): QueueFactory => $factory); + $dispatcher->listen('some.event', TestDispatcherGetQueue::class . '@handle'); + $dispatcher->dispatch('some.event', ['foo', 'bar']); + } + public function testDelayIsSetByWithDelayDynamically() { $d = new Dispatcher; @@ -1203,6 +1241,18 @@ public function handle() } } +class TestDispatcherForwardedQueue implements ShouldQueue +{ + public string $queue = 'reports'; + + /** + * Handle the queued event. + */ + public function handle(): void + { + } +} + class TestDispatcherShouldBeUnique implements ShouldQueue, ShouldBeUnique { public string $uniqueId = 'unique-listener-id'; diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index e2084e0a63..0953bf7fc3 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -47,6 +47,7 @@ use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Pusher\Pusher; use RuntimeException; @@ -133,6 +134,47 @@ public function testEventsCanBeBroadcastUsingQueueRoutes(): void Queue::assertPushedOn('broadcast-queue', BroadcastEvent::class); } + public function testEventsCanBeBroadcastWhenForwardingQueue(): void + { + Bus::fake(); + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('broadcast-connection')->andReturnSelf(); + Queue::swap($queue); + + Queue::forward('broadcast-queue', 'events', 'broadcast-connection'); + + Broadcast::queue(new TestForwardedEvent); + Bus::assertNotDispatched(BroadcastEvent::class); + Queue::assertPushedOn('broadcast-queue', BroadcastEvent::class); + } + + #[TestWith([null, 'cloud'])] + #[TestWith(['explicit', 'explicit'])] + public function testForwardedConnectionUsesTheBroadcastQueue(?string $connection, string $expectedConnection): void + { + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with($expectedConnection)->andReturnSelf(); + Queue::swap($queue); + Queue::forward('broadcast-queue', 'unused', 'wrong-connection'); + Queue::forward('updates', 'events', 'cloud'); + $event = new class extends TestForwardedEvent { + public ?string $connection = null; + + /** + * Select the queue used to broadcast this event. + */ + public function broadcastQueue(): string + { + return 'updates'; + } + }; + $event->connection = $connection; + + Broadcast::queue($event); + + Queue::assertPushedOn('updates', BroadcastEvent::class); + } + public function testEventsCanBeRescued(): void { Bus::fake(); @@ -828,6 +870,21 @@ public function broadcastOn(): array } } +class TestForwardedEvent implements ShouldBroadcast +{ + public string $queue = 'broadcast-queue'; + + /** + * Get the channels the event should broadcast on. + * + * @return Channel[]|string[] + */ + public function broadcastOn(): array + { + return []; + } +} + class TestEventNow implements ShouldBroadcastNow { /** diff --git a/tests/Integration/Horizon/Feature/ClearCommandTest.php b/tests/Integration/Horizon/Feature/ClearCommandTest.php index 52742cfac8..6008332b13 100644 --- a/tests/Integration/Horizon/Feature/ClearCommandTest.php +++ b/tests/Integration/Horizon/Feature/ClearCommandTest.php @@ -6,11 +6,15 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Queue\ClearableQueue; -use Hypervel\Contracts\Queue\Queue; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Horizon\Console\ClearCommand; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\RedisQueue; use Hypervel\Horizon\Repositories\RedisJobRepository; use Hypervel\Queue\QueueManager; +use Hypervel\Support\Facades\Queue; +use Hypervel\Support\Facades\Redis; +use Hypervel\Tests\Integration\Horizon\Feature\Jobs\BasicJob; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -26,7 +30,16 @@ protected function defineEnvironment(ApplicationContract $app): void $config = $app->make('config'); $config->set('queue.connections.redis.queue', 'default'); - $config->set('queue.connections.secondary.queue', 'secondary-default'); + $config->set('database.redis.secondary', array_replace($config->array('database.redis.default'), [ + 'prefix' => 'horizon_clear_secondary:', + ])); + $config->set('queue.connections.secondary', array_replace($config->array('queue.connections.redis'), [ + 'connection' => 'secondary', + 'queue' => 'secondary-default', + ])); + $config->set('queue.connections.redis-long', array_replace($config->array('queue.connections.redis'), [ + 'retry_after' => 3600, + ])); $config->set('queue.connections.0.queue', 'zero-default'); } @@ -41,10 +54,12 @@ public function testCommandPreservesZeroAndDefaultsEmptyIdentifiers( config()->set('horizon.defaults', $defaults); $jobRepository = m::mock(RedisJobRepository::class); - $jobRepository->shouldReceive('purge')->once()->with($expectedQueue); + $jobRepository->shouldReceive('purge')->once()->with($expectedQueue, $expectedConnection); $this->app->instance(JobRepository::class, $jobRepository); - $resolvedQueue = m::mock(Queue::class, ClearableQueue::class); + $resolvedQueue = m::mock(RedisQueue::class); + $resolvedQueue->shouldReceive('getQueue')->once()->with($expectedQueue)->andReturn('queues:' . $expectedQueue); + $resolvedQueue->shouldReceive('getConnectionName')->once()->andReturn($expectedConnection); $resolvedQueue->shouldReceive('clear')->once()->with($expectedQueue)->andReturn(1); $manager = m::mock(QueueManager::class); @@ -78,4 +93,75 @@ public static function queueIdentifierProvider(): array 'omitted defaults' => ['', '', [], 'redis', 'default'], ]; } + + public function testClearingAForwardedQueueRemovesItsDestinationMetadata(): void + { + // The second forward exposes accidentally resolving the destination twice. + Queue::forward(['reports' => 'processing', 'processing' => 'archive']); + $id = Queue::push(new BasicJob, queue: 'reports'); + $this->assertSame('processing', Redis::connection('horizon')->hget($id, 'queue')); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::size('reports')); + $this->assertSame(0, $this->recentJobs()); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + } + + public function testClearingOneConnectionPreservesAnotherConnectionsJobs(): void + { + $id = Queue::push(new BasicJob, queue: 'reports'); + $otherId = Queue::connection('secondary')->push(new BasicJob, queue: 'reports'); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::size('reports')); + $this->assertSame(1, Queue::connection('secondary')->size('reports')); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + $this->assertSame('pending', Redis::connection('horizon')->hget($otherId, 'status')); + } + + public function testClearingSharedStoragePreservesOtherConnectionRecordsUntilTheyExpire(): void + { + $id = Queue::push(new BasicJob, queue: 'reports'); + $otherId = Queue::connection('redis-long')->push(new BasicJob, queue: 'reports'); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--queue' => 'reports', '--force' => true]) + ->assertExitCode(0); + + $this->assertSame(0, Queue::connection('redis-long')->size('reports')); + $this->assertSame(0, Redis::connection('horizon')->exists($id)); + $this->assertSame('pending', Redis::connection('horizon')->hget($otherId, 'status')); + $this->assertGreaterThan(0, Redis::connection('horizon')->ttl($otherId)); + } + + public function testClearingANonHorizonConnectionDoesNotPurgeHorizonJobs(): void + { + $jobRepository = m::mock(JobRepository::class); + $jobRepository->shouldNotReceive('purge'); + $this->app->instance(JobRepository::class, $jobRepository); + + $resolvedQueue = m::mock(QueueContract::class, ClearableQueue::class); + $resolvedQueue->shouldReceive('clear')->once()->with('default')->andReturn(1); + + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with('redis')->andReturn($resolvedQueue); + $this->app->instance('queue', $manager); + + $this->artisan('horizon:clear', ['connection' => 'redis', '--force' => true]) + ->assertExitCode(0); + } + + public function testUnsupportedConnectionsFailBeforePurgingHorizonJobs(): void + { + $jobRepository = m::mock(JobRepository::class); + $jobRepository->shouldNotReceive('purge'); + $this->app->instance(JobRepository::class, $jobRepository); + + $this->artisan('horizon:clear', ['connection' => 'sync', '--force' => true]) + ->expectsOutputToContain('Clearing queues is not supported on [SyncQueue]') + ->assertExitCode(1); + } } diff --git a/tests/Integration/Horizon/Feature/QueueProcessingTest.php b/tests/Integration/Horizon/Feature/QueueProcessingTest.php index 60df7ec315..4152bdfbca 100644 --- a/tests/Integration/Horizon/Feature/QueueProcessingTest.php +++ b/tests/Integration/Horizon/Feature/QueueProcessingTest.php @@ -7,12 +7,16 @@ use Hypervel\Contracts\Queue\ShouldQueueAfterCommit; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Horizon\Contracts\JobRepository; +use Hypervel\Horizon\Events\JobDeleted; use Hypervel\Horizon\Events\JobPending; use Hypervel\Horizon\Events\JobPushed; +use Hypervel\Horizon\Events\JobReleased; use Hypervel\Horizon\Events\JobReserved; use Hypervel\Horizon\Events\JobsMigrated; +use Hypervel\Horizon\Events\RedisEvent; use Hypervel\Horizon\RedisQueue; use Hypervel\Queue\InvalidPayloadException; +use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\Queue as BaseQueue; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Support\CarbonImmutable; @@ -88,6 +92,36 @@ public function testDirectRawPushDoesNotInheritThePreviousJob(): void $this->assertSame([], $payload['tags']); } + public function testForwardedJobsKeepTheirWorkerQueueAndReportTheirDestination(): void + { + Queue::forward(['default' => 'processing', 'processing' => 'archive']); + $events = []; + + Event::listen([JobPushed::class, JobReserved::class, JobReleased::class, JobDeleted::class], function (RedisEvent $event) use (&$events): void { + $events[] = [$event::class, $event->queue]; + }); + + $id = Queue::push(new Jobs\BasicJob); + $job = Queue::pop(); + $this->assertInstanceOf(RedisJob::class, $job); + $this->assertSame('default', $job->getQueue()); + $this->assertSame('processing', Redis::connection('horizon')->hget($id, 'queue')); + + $job->release(0); + $options = $this->workerOptions(); + $options->maxTries = 2; + $this->worker()->runNextJob('redis', 'default', $options); + + $this->assertSame('completed', Redis::connection('horizon')->hget($id, 'status')); + $this->assertSame([ + [JobPushed::class, 'processing'], + [JobReserved::class, 'processing'], + [JobReleased::class, 'processing'], + [JobReserved::class, 'processing'], + [JobDeleted::class, 'processing'], + ], $events); + } + public function testDirectRawPushPreservesExistingHorizonClassification(): void { /** @var RedisQueue $queue */ diff --git a/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php b/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php index 1763e7ff87..60c43e8cf5 100644 --- a/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php +++ b/tests/Integration/Horizon/Feature/RedisJobRepositoryTest.php @@ -69,7 +69,7 @@ public function testItSavesMicrosecondsAsAFloatAndDisregardsTheLocale() } } - public function testItRemovesRecentJobsWhenQueueIsPurged() + public function testItRemovesRecentJobsWhenQueueIsPurged(): void { $repository = $this->app->make(JobRepository::class); @@ -77,7 +77,7 @@ public function testItRemovesRecentJobsWhenQueueIsPurged() $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '2', 'displayName' => 'second']))); $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '3', 'displayName' => 'third']))); $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '4', 'displayName' => 'fourth']))); - $repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => '5', 'displayName' => 'fifth']))); + $repository->pushed('other', 'email-processing', new JobPayload(json_encode(['id' => '5', 'displayName' => 'fifth']))); $repository->completed(new JobPayload(json_encode(['id' => '1', 'displayName' => 'first']))); $repository->completed(new JobPayload(json_encode(['id' => '2', 'displayName' => 'second']))); @@ -93,6 +93,24 @@ public function testItRemovesRecentJobsWhenQueueIsPurged() $this->assertCount(2, $repository->getJobs(['1', '2', '3', '4', '5'])); } + public function testPurgingOneConnectionPreservesOtherConnectionsAndCompletedJobs(): void + { + $repository = $this->app->make(JobRepository::class); + $payloads = []; + + foreach (['pending' => '0', 'reserved' => '0', 'completed' => '0', 'other' => '1'] as $id => $connection) { + $payloads[$id] = new JobPayload(json_encode(['id' => $id, 'displayName' => $id])); + $repository->pushed($connection, 'email-processing', $payloads[$id]); + } + + $repository->reserved('0', 'email-processing', $payloads['reserved']); + $repository->completed($payloads['completed']); + + $this->assertSame(2, $repository->purge('email-processing', '0')); + $this->assertSame(['completed', 'other'], $repository->getRecent()->pluck('id')->sort()->values()->all()); + $this->assertSame(['other'], $repository->getPending()->pluck('id')->all()); + } + public function testItWillDeleteAFailedJob() { $repository = $this->app->make(JobRepository::class); diff --git a/tests/Integration/Mail/SendingQueuedMailTest.php b/tests/Integration/Mail/SendingQueuedMailTest.php index 3dc80f4318..2ce6d2054e 100644 --- a/tests/Integration/Mail/SendingQueuedMailTest.php +++ b/tests/Integration/Mail/SendingQueuedMailTest.php @@ -53,6 +53,19 @@ public function testMailIsSentWhenRoutingQueue(): void Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); } + public function testMailIsSentWhenForwardingQueue(): void + { + $queue = m::mock(QueueFake::class, [$this->app])->makePartial(); + $queue->shouldReceive('connection')->once()->with('mail-connection')->andReturnSelf(); + Queue::swap($queue); + + Queue::forward('mail-queue', 'main', 'mail-connection'); + + Mail::to('test@mail.com')->queue(new SendingQueuedForwardedMailTestMail); + + Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); + } + public function testMailIsSentWithDelay(): void { Queue::fake(); @@ -82,3 +95,16 @@ public function middleware(): array return [new RateLimited('limiter')]; } } + +class SendingQueuedForwardedMailTestMail extends Mailable +{ + public string $queue = 'mail-queue'; + + /** + * Build the message. + */ + public function build(): static + { + return $this->view('view'); + } +} diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php index d499ff7a28..0772bfa42b 100644 --- a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -6,6 +6,7 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DeadlockException; use Hypervel\Database\Events\TransactionCommitted; @@ -15,6 +16,9 @@ use Hypervel\Events\Dispatcher; use Hypervel\Queue\DatabaseQueue; use Hypervel\Queue\Events\JobFailed; +use Hypervel\Queue\FailoverQueue; +use Hypervel\Queue\QueueManager; +use Hypervel\Queue\QueueRoutes; use Hypervel\Tests\TestCase; use Mockery as m; use PDO; @@ -25,6 +29,54 @@ class DatabaseQueueReservationTest extends TestCase { + #[TestWith([null, 'reports'])] + #[TestWith(['failover', 'processing'])] + public function testFailoverForwardsOnceBeforeStoringOnTheFallbackConnection(?string $connection, string $delegatedQueue): void + { + [$database, $events] = $this->createQueue(); + $routes = new QueueRoutes; + $routes->forward(['reports' => 'processing', 'processing' => 'archive'], connection: $connection); + Container::getInstance()->instance('queue.routes', $routes); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $primary = m::mock(QueueContract::class); + $primary->shouldReceive('pushRaw')->once()->with($payload, $delegatedQueue)->andThrow(new RuntimeException('Primary unavailable.')); + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->once()->with('primary')->andReturn($primary); + $manager->shouldReceive('connection')->once()->with('database')->andReturn($database); + $queue = new FailoverQueue($manager, $events, ['primary', 'database']); + $queue->setConnectionName('failover'); + + $id = $queue->pushRaw($payload, 'reports'); + + $this->assertSame('processing', $database->getDatabase()->table('jobs')->find($id)->queue); + $this->assertSame(1, $database->getDatabase()->table('jobs')->count()); + } + + public function testForwardedQueueReservesAndReleasesUsingTheLogicalName(): void + { + [$queue] = $this->createQueue(); + $routes = new QueueRoutes; + $routes->forward(['reports' => 'processing', 'processing' => 'archive']); + Container::getInstance()->instance('queue.routes', $routes); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []]), 'reports'); + + $this->assertSame('processing', $queue->getDatabase()->table('jobs')->find($id)->queue); + + $job = $queue->pop('reports'); + + $this->assertSame((string) $id, $job?->getJobId()); + $this->assertSame('reports', $job->getQueue()); + $this->assertSame(1, $job->attempts()); + + $job->release(); + + $record = $queue->getDatabase()->table('jobs')->sole(); + $this->assertSame('processing', $record->queue); + $this->assertSame(1, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertSame(2, $queue->pop('reports')?->attempts()); + } + #[TestWith([0])] #[TestWith([1])] public function testFailedReservationDoesNotBlockTheNextJob(int $transactionLevel): void diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index 8322ffa80c..c2cf2163b1 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -878,6 +878,60 @@ public function testTotalSizesPreserveQueueNamesAcrossEveryState(): void $this->assertSame(2, $this->queue->totalReservedSize()); } + public function testGlobalInspectionAndTotalsDoNotForwardPhysicalQueueNames(): void + { + $this->setQueue(); + + foreach (['reports' => 1, 'archive' => 2] as $name => $count) { + for ($index = 0; $index < $count; ++$index) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob($index)); + $this->queue->pop($name); + } + + for ($index = 0; $index < $count; ++$index) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob($index)); + $this->queue->laterOn($name, 60, new RedisQueueIntegrationTestJob($index)); + } + } + + $this->app->make('queue.routes')->forward('reports', 'archive'); + + $this->assertSame(9, $this->queue->totalSize()); + $this->assertSame(3, $this->queue->totalPendingSize()); + $this->assertSame(3, $this->queue->totalDelayedSize()); + $this->assertSame(3, $this->queue->totalReservedSize()); + + foreach (['pendingJobs', 'delayedJobs', 'reservedJobs'] as $method) { + $jobs = $this->queue->{'all' . ucfirst($method)}(); + + $this->assertSame(['archive', 'archive', 'reports'], $jobs->pluck('queue')->sort()->values()->all()); + $this->assertCount(3, $jobs->unique('uuid')); + $this->assertSame(['reports', 'reports'], $this->queue->{$method}('reports')->pluck('queue')->all()); + } + } + + public function testForwardedJobIsReleasedToTheSameDestination(): void + { + $this->setQueue('reports'); + $destinationKey = $this->getQueueRedisKey('processing'); + $otherKey = $this->getQueueRedisKey('archive'); + $this->app->make('queue.routes')->forward(['reports' => 'processing', 'processing' => 'archive']); + + $this->queue->push(new RedisQueueIntegrationTestJob(10)); + $job = $this->queue->pop(); + + $this->assertInstanceOf(RedisJob::class, $job); + $this->assertSame('reports', $job->getQueue()); + $job->release(0); + + $this->assertSame(1, $this->redisConnection()->zcard($destinationKey . ':delayed')); + $this->assertSame(0, $this->redisConnection()->zcard($otherKey . ':delayed')); + $retried = $this->queue->pop(); + $this->assertInstanceOf(RedisJob::class, $retried); + $this->assertSame($job->getJobId(), $retried->getJobId()); + $this->assertSame(2, $retried->attempts()); + } + public function testInvalidInspectedPayloadRetainsItsRedisRemovalMember(): void { $this->setQueue('poison'); diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 7676d99be2..924361fb52 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -6,6 +6,8 @@ use Closure; use Hypervel\Bus\Queueable; +use Hypervel\Config\Repository as Config; +use Hypervel\Container\Container; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\ShouldQueue; @@ -22,8 +24,10 @@ use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; +use Hypervel\Queue\QueueRoutes; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Symfony\Component\Mailer\Exception\HttpTransportException; @@ -398,6 +402,42 @@ public function testItCanSendQueuedNotificationsWithQueueRoute(): void $sender->send($notifiable, new DummyQueuedNotificationWithStringVia); } + #[TestWith([null, 'cloud'])] + #[TestWith(['explicit', 'explicit'])] + public function testForwardedConnectionsUseTheSelectedChannelQueue(?string $connection, string $expectedConnection): void + { + $container = Container::getInstance(); + $container->instance('config', new Config); + $routes = new QueueRoutes; + $routes->forward('dummy', 'unused', 'wrong-connection'); + $routes->forward('admin_notifications', 'notifications', 'cloud'); + $container->instance('queue.routes', $routes); + $notification = new class extends DummyNotificationWithViaQueues { + /** + * Select an explicit connection for the database channel. + */ + public function viaConnections(): array + { + return ['database' => 'database-connection']; + } + }; + $notification->onConnection($connection); + $bus = m::mock(BusDispatcherContract::class); + $bus->shouldReceive('dispatch')->once()->with(m::on( + fn (SendQueuedNotifications $job): bool => $job->channels === ['mail'] + && $job->queue === 'admin_notifications' + && $job->connection === $expectedConnection + )); + $bus->shouldReceive('dispatch')->once()->with(m::on( + fn (SendQueuedNotifications $job): bool => $job->channels === ['database'] + && $job->queue === 'dummy' + && $job->connection === 'database-connection' + )); + + (new NotificationSender(new ChannelManager($container), $bus, m::mock(Dispatcher::class))) + ->send(new AnonymousNotifiable, $notification); + } + public function testItCanSendQueuedNotificationsWithDelayAttribute(): void { $notification = new #[Delay(30)] class extends Notification implements ShouldQueue { diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index 4bfc87fdb7..84af1c6b24 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -28,6 +28,7 @@ use Hypervel\Queue\Events\QueueFailedOver; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; +use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\RedisQueue; use Hypervel\Queue\SyncQueue; use Hypervel\Support\Collection; @@ -44,6 +45,44 @@ class FailoverQueueTest extends TestCase { + #[DataProvider('forwardedQueueOperations')] + public function testConnectionScopedForwardsApplyBeforeDelegating(string $method, array $arguments, array $expectedArguments, Collection|int|string|null $result): void + { + $routes = new QueueRoutes; + $routes->forward(['default' => 'processing', 'reports' => 'processing', 'processing' => 'archive'], connection: 'failover'); + Container::getInstance()->instance('queue.routes', $routes); + $manager = m::mock(QueueManager::class); + $redis = m::mock(RedisQueue::class); + $manager->shouldReceive('connection')->once()->with('redis')->andReturn($redis); + $redis->shouldReceive($method)->once()->with(...$expectedArguments)->andReturn($result); + $queue = new FailoverQueue($manager, m::mock(DispatcherContract::class), ['redis']); + $queue->setConnectionName('failover'); + + $this->assertSame($result, $queue->{$method}(...$arguments)); + } + + /** + * Provide each queue-name boundary owned by the failover driver. + */ + public static function forwardedQueueOperations(): array + { + return [ + 'push' => ['push', ['job', '', 'reports'], ['job', '', 'processing'], 'id'], + 'push without queue' => ['push', ['job'], ['job'], 'id'], + 'pushRaw' => ['pushRaw', ['payload', 'reports'], ['payload', 'processing'], 'id'], + 'later' => ['later', [10, 'job', '', 'reports'], [10, 'job', '', 'processing'], 'id'], + 'pop' => ['pop', ['reports', 2], ['processing', 2], null], + 'size' => ['size', ['reports'], ['processing'], 7], + 'pendingSize' => ['pendingSize', ['reports'], ['processing'], 7], + 'delayedSize' => ['delayedSize', ['reports'], ['processing'], 7], + 'reservedSize' => ['reservedSize', ['reports'], ['processing'], 7], + 'pendingJobs' => ['pendingJobs', ['reports'], ['processing'], new Collection], + 'delayedJobs' => ['delayedJobs', ['reports'], ['processing'], new Collection], + 'reservedJobs' => ['reservedJobs', ['reports'], ['processing'], new Collection], + 'oldest pending' => ['creationTimeOfOldestPendingJob', ['reports'], ['processing'], 7], + ]; + } + public function testPushFailsOverOnException() { $failover = new FailoverQueue($queue = m::mock(QueueManager::class), $events = m::mock(DispatcherContract::class), [ diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index 5df64a76b3..df413108df 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -21,6 +21,7 @@ use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Queue\LuaScripts; use Hypervel\Queue\Queue; +use Hypervel\Queue\QueueRoutes; use Hypervel\Queue\RedisQueue; use Hypervel\Redis\RedisProxy; use Hypervel\Support\CarbonImmutable; @@ -35,8 +36,11 @@ class QueueRedisQueueTest extends TestCase { #[DataProvider('totalSizeMethods')] - public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $totalMethod, string $sizeMethod): void + public function testTotalsCountPhysicalQueuesInsidePinnedConnection(string $totalMethod, string $command, array $firstArguments, array $secondArguments): void { + $routes = new QueueRoutes; + $routes->forward('emails', 'reports:high'); + Container::getInstance()->instance('queue.routes', $routes); $pinned = false; $connection = m::mock(RedisProxy::class); $connection->expects('withPinnedConnection')->andReturnUsing(function (callable $callback) use (&$pinned): int { @@ -48,8 +52,19 @@ public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $to $pinned = false; } }); + $connection->shouldReceive('isCluster')->once()->andReturnFalse(); + $connection->shouldReceive($command)->once()->with(...$firstArguments)->andReturnUsing(function () use (&$pinned): int { + $this->assertTrue($pinned); + + return 5; + }); + $connection->shouldReceive($command)->once()->with(...$secondArguments)->andReturnUsing(function () use (&$pinned): int { + $this->assertTrue($pinned); + + return 7; + }); $redis = m::mock(Redis::class); - $redis->expects('connection')->with(null)->andReturn($connection); + $redis->shouldReceive('connection')->with(null)->andReturn($connection); $queue = m::mock(RedisQueue::class, [$redis, 'default']) ->makePartial() ->shouldAllowMockingProtectedMethods(); @@ -58,28 +73,23 @@ public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $to return new Collection(['emails', 'reports:high']); }); - $queue->shouldReceive($sizeMethod)->twice()->andReturnUsing(function (string $name) use (&$pinned): int { - $this->assertTrue($pinned); - - return match ($name) { - 'emails' => 5, - 'reports:high' => 7, - }; - }); - $this->assertSame(12, $queue->{$totalMethod}()); } /** - * Provide aggregate methods and their per-queue extension points. + * Provide aggregate methods and their physical Redis commands. */ public static function totalSizeMethods(): array { return [ - 'all jobs' => ['totalSize', 'size'], - 'pending jobs' => ['totalPendingSize', 'pendingSize'], - 'delayed jobs' => ['totalDelayedSize', 'delayedSize'], - 'reserved jobs' => ['totalReservedSize', 'reservedSize'], + 'all jobs' => [ + 'totalSize', 'eval', + [LuaScripts::size(), 3, 'queues:emails', 'queues:emails:delayed', 'queues:emails:reserved'], + [LuaScripts::size(), 3, 'queues:reports:high', 'queues:reports:high:delayed', 'queues:reports:high:reserved'], + ], + 'pending jobs' => ['totalPendingSize', 'llen', ['queues:emails'], ['queues:reports:high']], + 'delayed jobs' => ['totalDelayedSize', 'zcard', ['queues:emails:delayed'], ['queues:reports:high:delayed']], + 'reserved jobs' => ['totalReservedSize', 'zcard', ['queues:emails:reserved'], ['queues:reports:high:reserved']], ]; } diff --git a/tests/Queue/QueueRoutesTest.php b/tests/Queue/QueueRoutesTest.php index 798708c6fe..62bf35f65d 100644 --- a/tests/Queue/QueueRoutesTest.php +++ b/tests/Queue/QueueRoutesTest.php @@ -5,8 +5,10 @@ namespace Hypervel\Tests\Queue\QueueRoutesTest; use Hypervel\Foundation\Queue\Queueable; +use Hypervel\Queue\Attributes\Queue as QueueAttribute; use Hypervel\Queue\QueueRoutes; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; class QueueRoutesTest extends TestCase { @@ -83,6 +85,126 @@ public function testStringRouteDefaultsToQueueNotConnection(): void $this->assertNull($defaults->getConnection(new FinanceNotification)); } + public function testForwardRewritesName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports')); + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('other', $defaults->forwardedQueue('other')); + } + + public function testForwardIsScopedToConnection(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'redis')); + $this->assertSame('reports', $defaults->forwardedQueue('reports')); + } + + public function testForwardWithJustConnectionKeepsName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', connection: 'cloud'); + + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'cloud')); + } + + public function testForwardSetsConnectionByQueueName(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('cloud', $defaults->getConnection((new SomeJob)->onQueue('reports'))); + $this->assertNull($defaults->getConnection((new SomeJob)->onQueue('other'))); + $this->assertNull($defaults->getConnection(new SomeJob)); + } + + public function testForwardMatchesQueueAttribute(): void + { + $defaults = new QueueRoutes; + + $defaults->forward('reports', 'audit', 'cloud'); + + $this->assertSame('cloud', $defaults->getConnection(new AttributeForwardedJob)); + } + + public function testForwardAcceptsArray(): void + { + $defaults = new QueueRoutes; + + $defaults->forward([ + 'reports' => 'audit', + 'emails' => 'mail', + ], connection: 'cloud'); + + $this->assertSame('audit', $defaults->forwardedQueue('reports', 'cloud')); + $this->assertSame('mail', $defaults->forwardedQueue('emails', 'cloud')); + $this->assertSame('reports', $defaults->forwardedQueue('reports', 'redis')); + } + + public function testForwardResolvesEnums(): void + { + $defaults = new QueueRoutes; + + $defaults->forward(QueueName::Payments, 'settlements', ConnectionName::Redis); + + $this->assertSame('settlements', $defaults->forwardedQueue('payments', 'redis')); + $this->assertSame('payments', $defaults->forwardedQueue('payments', 'sqs')); + } + + #[TestWith(['reports'])] + #[TestWith([[null, 'reports']])] + public function testForwardedConnectionUsesClassRouteWithoutConnection(array|string $route): void + { + $defaults = new QueueRoutes; + $defaults->set([SomeJob::class => $route]); + $defaults->forward('reports', 'audit', 'cloud'); + $defaults->forward('updates', 'notifications', 'redis'); + + $this->assertSame('cloud', $defaults->getConnection(new SomeJob)); + $this->assertSame('redis', $defaults->getConnection((new SomeJob)->onQueue('updates'))); + $this->assertSame('redis', $defaults->getConnection((new SomeJob)->onQueue('reports'), 'updates')); + + $defaults->set(SomeJob::class, 'reports', 'explicit'); + + $this->assertSame('explicit', $defaults->getConnection(new SomeJob, 'updates')); + } + + public function testForwardNormalizesIntegerAndUnitEnumsWithoutLosingZero(): void + { + $defaults = new QueueRoutes; + $defaults->forward(QueueRouteIntegerIdentifier::Queue, QueueRouteIntegerIdentifier::Zero, QueueRouteIntegerIdentifier::Zero); + + $this->assertSame('0', $defaults->forwardedQueue('1', '0')); + $this->assertSame('0', $defaults->getConnection((new SomeJob)->onQueue(QueueRouteIntegerIdentifier::Queue))); + + $defaults->forward(['0' => QueueName::Payments], connection: QueueRouteUnitIdentifier::Connection); + + $this->assertSame('payments', $defaults->forwardedQueue('0', 'Connection')); + $this->assertSame('Connection', $defaults->getConnection((new SomeJob)->onQueue(QueueRouteIntegerIdentifier::Zero))); + } + + public function testConnectionScopedForwardingLeavesUnscopedForwardsToTheStorageDriver(): void + { + $defaults = new QueueRoutes; + $defaults->forward('reports', 'processing', 'failover'); + $defaults->forward('processing', 'archive'); + + $this->assertSame('processing', $defaults->forwardedQueueForConnection('reports', 'failover')); + $this->assertSame('reports', $defaults->forwardedQueueForConnection('reports', 'redis')); + $this->assertSame('reports', $defaults->forwardedQueueForConnection('reports', null)); + $this->assertSame('processing', $defaults->forwardedQueueForConnection('processing', 'failover')); + $this->assertSame('archive', $defaults->forwardedQueue('processing', 'redis')); + } + public function testEnumsAreResolved(): void { $defaults = new QueueRoutes; @@ -137,6 +259,12 @@ class SomeJob use CustomTrait; } +#[QueueAttribute('reports')] +class AttributeForwardedJob +{ + use Queueable; +} + class BaseNotification { use Queueable; diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index 7303dd6c8f..c0bd57a5c7 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -437,6 +437,43 @@ public function testGetQueueProperlyResolvesFifoUrlWithSuffix() $this->assertEquals($queueUrl, $queue->getQueue('test.fifo')); } + public function testForwardedQueueNameIsUsedWhenPushing(): void + { + Container::setInstance($container = new Container); + $routes = new QueueRoutes; + $routes->forward('jobs', 'processing', 'sqs'); + $container->instance('queue.routes', $routes); + + $queue = new SqsQueue($this->sqs, 'default', $this->prefix); + $queue->setConnectionName('sqs'); + + $this->sqs->expects('sendMessage')->with([ + 'QueueUrl' => $this->prefix . 'processing', + 'MessageBody' => 'payload', + ])->andReturn($this->mockedSendMessageResponseModel); + + $queue->pushRaw('payload', 'jobs'); + } + + public function testForwardedFifoQueueControlsOptionsAndDelayValidation(): void + { + $routes = new QueueRoutes; + $routes->forward(['jobs' => 'processing.fifo', 'processing.fifo' => 'archive'], connection: 'sqs'); + Container::getInstance()->instance('queue.routes', $routes); + $queue = new SqsQueue($this->sqs, 'default', $this->prefix); + $queue->setConnectionName('sqs'); + + $this->assertSame($this->prefix . 'processing.fifo', $queue->getQueue('jobs')); + $options = $queue->getQueueableOptions('job', 'jobs', 'payload'); + $this->assertSame('processing.fifo', $options['MessageGroupId']); + $this->assertArrayHasKey('MessageDeduplicationId', $options); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('SQS FIFO queues do not support per-message delays.'); + + $queue->later(10, 'job', '', 'jobs'); + } + public function testGetQueueEnsuresTheQueueIsOnlySuffixedOnce() { $queue = new SqsQueue($this->sqs, "{$this->queueName}-staging", $this->prefix, $suffix = '-staging'); From da0037d449b3a7497c6a6891383d079a9a8d44aa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:35:54 +0000 Subject: [PATCH 13/23] Preserve valid database jobs when query observers fail Limit destructive reservation recovery to QueryException. Before-query callbacks, query-executed listeners and duration handlers can throw after a valid job is selected; treating every exception as an invalid record deleted that job after rollback. Keep transaction-state, concurrency and lost-connection checks for actual query failures. Cancellation now naturally bypasses recovery, while cleanup still preserves the original query error or propagates cancellation. No additional successful-path work or shared state is introduced. Add SQLite regressions for all three observer phases and use QueryException fixtures so the existing transient-error and cleanup tests continue exercising recovery. Preserve the overflow recovery regression. Corrects an upstream defect in https://github.com/laravel/framework/pull/58978, compared against Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Verified affected queue/database suites, full PHPStan source/type checks, and formatting. --- src/queue/src/DatabaseQueue.php | 6 +-- .../Sqlite/DatabaseQueueReservationTest.php | 54 +++++++++++++++++++ tests/Queue/QueueDatabaseQueueUnitTest.php | 7 +-- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 1456ef24a6..81baeca107 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -16,6 +16,7 @@ use Hypervel\Database\DetectsLostConnections; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; use Hypervel\Queue\Concerns\InsertsDatabaseRows; use Hypervel\Queue\Jobs\DatabaseJob; use Hypervel\Queue\Jobs\DatabaseJobRecord; @@ -499,11 +500,10 @@ public function pop(?string $queue = null): ?Job return $job; } }); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable $exception) { + } catch (QueryException $exception) { // Recovery requires our transaction to have unwound. Transient database // failures leave the job available for another reservation attempt. + // Non-query callback failures do not establish an invalid job record. if ($jobRecord !== null && $database->transactionLevel() === $transactionLevel && ! $this->causedByConcurrencyError($exception) diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php index 0772bfa42b..d35d7ae690 100644 --- a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DeadlockException; +use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\TransactionCommitted; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Grammars\SQLiteGrammar; @@ -161,6 +162,59 @@ public function __construct() } } + #[TestWith(['before'])] + #[TestWith(['executed'])] + #[TestWith(['duration'])] + public function testQueryObserverFailureKeepsTheJobAvailable(string $observer): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); + $failure = new RuntimeException('Query observer failed.'); + $callback = static function (string $query) use ($failure): void { + if (str_starts_with($query, 'update ')) { + throw $failure; + } + }; + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + + if ($observer === 'before') { + $database->beforeExecuting($callback); + } elseif ($observer === 'executed') { + $events->listen(QueryExecuted::class, static function (QueryExecuted $event) use ($callback): void { + $callback($event->sql); + }); + } else { + // A negative threshold fires even at zero measured duration. Selection runs + // first, so re-arm the one-shot handler for the reservation update. + $database->whenQueryingForLongerThan(-1, static function (PdoConnection $connection, QueryExecuted $event) use ($callback): void { + $callback($event->sql); + }); + + $database->beforeExecuting(static function (string $query) use ($database): void { + if (str_starts_with($query, 'update ')) { + $database->allowQueryDurationHandlersToRunAgain(); + } + }); + } + + try { + $queue->pop(); + $this->fail('Expected the query observer failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertSame(0, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertFalse($failed); + } + public function testCommittedListenerFailureKeepsTheReservedJob(): void { [$queue, $events] = $this->createQueue(); diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index b966709542..95fabd57cf 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -18,6 +18,7 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder; +use Hypervel\Database\QueryException; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Events\Dispatcher; @@ -130,8 +131,8 @@ public function testTransientReservationFailuresDoNotFailTheJob(Throwable $failu public static function transientReservationFailureProvider(): array { return [ - 'concurrency' => [new PDOException('deadlock detected', 40001)], - 'lost connection' => [new PDOException('server has gone away')], + 'concurrency' => [new QueryException('database', 'update jobs', [], new PDOException('deadlock detected', 40001))], + 'lost connection' => [new QueryException('database', 'update jobs', [], new PDOException('server has gone away'))], 'cancellation' => [new CanceledException('Reservation canceled.')], ]; } @@ -139,7 +140,7 @@ public static function transientReservationFailureProvider(): array #[DataProvider('reservationCleanupFailureProvider')] public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure): void { - $failure = new RuntimeException('Reservation failed.'); + $failure = new QueryException('database', 'update jobs', [], new PDOException('Reservation failed.')); [$queue, $events] = $this->createFailingReservationQueue($failure); $queue->shouldReceive('deleteReserved')->once()->with('default', '1')->andThrow($cleanupFailure); From ffc2c40567d524d356348a3902280c14f4949e84 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:13 +0000 Subject: [PATCH 14/23] Clarify caller-selected queues in forwarding resolvers Describe the optional queue argument as the logical queue already selected by the caller. It overrides queueable queue metadata when choosing a forwarded connection; an explicit class-route connection still takes precedence. Document the same contract on QueueRoutes and the shared resolver concern used by dispatchers and mailables. This is a PHPDoc-only clarification with no runtime, signature or generated facade change. Follows the port of https://github.com/laravel/framework/pull/61188. Verified resolver callers and precedence, existing QueueRoutes tests, formatting and source/type analysis. --- src/queue/src/QueueRoutes.php | 2 ++ src/support/src/Queue/Concerns/ResolvesQueueRoutes.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/queue/src/QueueRoutes.php b/src/queue/src/QueueRoutes.php index 923bf8ff6b..78886b1054 100644 --- a/src/queue/src/QueueRoutes.php +++ b/src/queue/src/QueueRoutes.php @@ -30,6 +30,8 @@ class QueueRoutes /** * Get the queue connection that a given queueable instance should be routed to. + * + * @param null|string|UnitEnum $queue the caller-selected queue, overriding the queueable's queue when resolving a forwarded connection */ public function getConnection(object $queueable, UnitEnum|string|null $queue = null): ?string { diff --git a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php index b72342dcca..8ce076f4cb 100644 --- a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php +++ b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php @@ -12,6 +12,8 @@ trait ResolvesQueueRoutes { /** * Resolve the default connection name for a given queueable instance. + * + * @param null|string|UnitEnum $queue the caller-selected queue, overriding the queueable's queue when resolving a forwarded connection */ public function resolveConnectionFromQueueRoute(object $queueable, UnitEnum|string|null $queue = null): ?string { From ae141dca58209979c95747a0d4bda2b808451790 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:36:13 +0000 Subject: [PATCH 15/23] Port missing generator command integration coverage Restore current Laravel tests for terminal PHP extension removal, view-name directory conversion, embedded extension preservation and case-insensitive reserved class names. Keep the separate Hypervel unit tests for generator behavior. Copy from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 and adapt namespaces, Testbench, strict/native types and provider documentation. Preserve every upstream case and assertion. Additionally assert that reserved names create no class file, registering only that exact path for existing published-file cleanup. Relevant upstream history: https://github.com/laravel/framework/pull/48667, https://github.com/laravel/framework/pull/51842, https://github.com/laravel/framework/pull/51847 and https://github.com/laravel/framework/pull/51924. Verified the new test, combined generator and queue-route coverage, console integration tests and formatting. Generated files use the disposable Testbench application and existing cleanup lifecycle. --- .../Console/GeneratorCommandTest.php | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/Integration/Console/GeneratorCommandTest.php diff --git a/tests/Integration/Console/GeneratorCommandTest.php b/tests/Integration/Console/GeneratorCommandTest.php new file mode 100644 index 0000000000..9cca6d7942 --- /dev/null +++ b/tests/Integration/Console/GeneratorCommandTest.php @@ -0,0 +1,79 @@ +artisan('make:command', ['name' => 'FooCommand.php']) + ->assertExitCode(0); + + $this->assertFilenameExists('app/Console/Commands/FooCommand.php'); + + $this->assertFileContains([ + 'class FooCommand extends Command', + ], 'app/Console/Commands/FooCommand.php'); + } + + public function testItChopsPhpExtensionFromMakeViewCommands(): void + { + $this->artisan('make:view', ['name' => 'foo.php']) + ->assertExitCode(0); + + $this->assertFilenameExists('resources/views/foo/php.blade.php'); + } + + public function testItOnlyChopsPhpExtensionFromFilename(): void + { + $this->artisan('make:test', ['name' => 'fixtures.php/SomeTest']) + ->assertExitCode(0); + + $this->assertFilenameExists('tests/Feature/fixtures.php/SomeTest.php'); + + $this->assertFileContains([ + 'class SomeTest extends TestCase', + ], 'tests/Feature/fixtures.php/SomeTest.php'); + } + + #[DataProvider('reservedNamesDataProvider')] + public function testItCannotGenerateClassUsingReservedName(string $given): void + { + $path = 'app/Console/Commands/' . $given . '.php'; + $this->files[] = $path; + + $this->artisan('make:command', ['name' => $given]) + ->expectsOutputToContain('The name "' . $given . '" is reserved by PHP.') + ->assertExitCode(0); + + $this->assertFilenameDoesNotExists($path); + } + + /** + * Provide reserved class names. + */ + public static function reservedNamesDataProvider(): Generator + { + yield ['__halt_compiler']; + yield ['__HALT_COMPILER']; + yield ['array']; + yield ['ARRAY']; + yield ['__class__']; + yield ['__CLASS__']; + } +} From e5ad984ee5934e61cf919d5f920ebf97a58acb5e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:59:40 +0000 Subject: [PATCH 16/23] Resolve schema connections from the owning container The schema facade delegates named connections through SchemaProxy so registered blueprint resolvers apply. That proxy previously resolved the database manager from the global container, bypassing the application configured on the facade. Pass the binding container into the proxy and resolve its database manager for each operation. This preserves container swaps, coroutine-local connection selection and fresh builders without retaining pooled connections. Add a regression covering named and default facade calls with a different global container. Verified affected schema, database and facade tests, formatting and full static analysis. --- src/database/src/DatabaseServiceProvider.php | 4 ++-- src/database/src/Schema/SchemaProxy.php | 11 +++++++++-- tests/Database/DatabaseSchemaProxyTest.php | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/database/src/DatabaseServiceProvider.php b/src/database/src/DatabaseServiceProvider.php index c7121881b3..d664bb2aca 100644 --- a/src/database/src/DatabaseServiceProvider.php +++ b/src/database/src/DatabaseServiceProvider.php @@ -128,8 +128,8 @@ protected function registerConnectionServices(): void return $app->make('db')->connection(); }); - $this->app->singleton('db.schema', function () { - return new SchemaProxy; + $this->app->singleton('db.schema', function ($app) { + return new SchemaProxy($app); }); $this->app->singleton('db.transactions', function () { diff --git a/src/database/src/Schema/SchemaProxy.php b/src/database/src/Schema/SchemaProxy.php index ca84dcc660..7e0833d7e4 100644 --- a/src/database/src/Schema/SchemaProxy.php +++ b/src/database/src/Schema/SchemaProxy.php @@ -5,7 +5,7 @@ namespace Hypervel\Database\Schema; use Closure; -use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container; use Hypervel\Database\Connection; /** @@ -18,6 +18,13 @@ class SchemaProxy */ protected ?Closure $resolver = null; + /** + * Create a new schema proxy. + */ + public function __construct(protected Container $app) + { + } + /** * Forward a schema operation to the current connection's builder. */ @@ -34,7 +41,7 @@ public function __call(string $name, array $arguments): mixed */ public function connection(?string $name = null): Builder { - $builder = Container::getInstance() + $builder = $this->app ->make('db') ->connection($name) ->getSchemaBuilder(); diff --git a/tests/Database/DatabaseSchemaProxyTest.php b/tests/Database/DatabaseSchemaProxyTest.php index 96e7e33426..a78bbeebf8 100644 --- a/tests/Database/DatabaseSchemaProxyTest.php +++ b/tests/Database/DatabaseSchemaProxyTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use Closure; +use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Connection; use Hypervel\Database\Schema\Blueprint; @@ -61,6 +62,22 @@ public function testFacadeResolverAppliesToFreshBuildersOnTheSelectedConnection( $this->assertTrue(Schema::connection('secondary')->hasTable('posts')); } + public function testFacadeUsesItsApplicationWhenTheGlobalContainerDiffers(): void + { + $primary = DB::connection('primary'); + $secondary = DB::connection('secondary'); + $container = Container::getInstance(); + + Container::setInstance(new Container); + + try { + $this->assertSame($secondary, Schema::connection('secondary')->getConnection()); + $this->assertSame($primary, Schema::getConnection()); + } finally { + Container::setInstance($container); + } + } + public function testBuilderResolverOverridesRemainLocal(): void { $defaultTables = []; From 1b817e4da4883266196ccfb19d6237acb834cf6f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:59:48 +0000 Subject: [PATCH 17/23] Clarify connection selection when clearing forwarded queues A connection-scoped forward applies only on its configured connection. queue:clear selects its connection before resolving a destination, so using another connection clears the source queue instead. Document that selection next to queue forwarding and retain the warning that clearing a shared destination also removes jobs sent through other logical names. Unscoped forwards continue to apply on every connection. Checked the wording against ClearCommand and QueueRoutes, including scoped and unscoped forwarding. --- src/docs/queues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index d0f02f4637..3da121d80a 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -1711,7 +1711,7 @@ A forward scoped to a `failover` connection requires an explicit queue name; oth After forwarding queues, update your worker queue lists to avoid listing multiple names that resolve to the same queue. Before forwarding a queue to a different name, drain its existing jobs. Workers using the forwarding configuration will consume the destination queue instead. -Clearing a forwarded queue clears its destination, including jobs sent through other queue names that forward to the same destination. +When a forward specifies a connection, pass that connection to `queue:clear`; using another connection clears the source queue instead. Clearing a forwarded queue on the matching connection clears its destination, including jobs sent through other queue names that forward to the same destination. ### Specifying Max Job Attempts / Timeout Values From bd285fbc36ce14b7376fbfd3af460620725599ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:59:57 +0000 Subject: [PATCH 18/23] Explain the logical queue assertion in forwarded mail coverage QueueFake records the logical dispatch while storage drivers apply the destination forward. Explain that distinction beside the assertion so the source queue is not mistaken for missing forwarding coverage. Keep the existing connection-selection expectation and logical-queue assertion intact. Verified the queued mail test file independently. --- tests/Integration/Mail/SendingQueuedMailTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Integration/Mail/SendingQueuedMailTest.php b/tests/Integration/Mail/SendingQueuedMailTest.php index 2ce6d2054e..986598d4fc 100644 --- a/tests/Integration/Mail/SendingQueuedMailTest.php +++ b/tests/Integration/Mail/SendingQueuedMailTest.php @@ -63,6 +63,7 @@ public function testMailIsSentWhenForwardingQueue(): void Mail::to('test@mail.com')->queue(new SendingQueuedForwardedMailTestMail); + // The fake records the logical queue; storage drivers apply the destination forward. Queue::assertPushedOn('mail-queue', SendQueuedMailable::class); } From ce5f3cfe1d82f2b943262e38dbc54a1a6ea5b993 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:39:17 +0000 Subject: [PATCH 19/23] Use native configuration for standalone Capsules Capsule bootstrap installed Fluent as the configuration service, whose string getter returns Stringable. QueueManager requires a native string for the default connection, so resolving that connection could throw a TypeError. Install Config Repository when no config binding exists and preserve any supplied binding unchanged. Database and Queue Capsules sharing a container now use the same typed configuration API. No compatibility branch or cast is added to the queue manager. Cover the native string getter and supplied configuration identity. Verified the Support Capsule tests, composed SQLite Capsules, affected database and queue tests, full source/type analysis, and formatting. --- src/support/src/Traits/CapsuleManagerTrait.php | 4 ++-- tests/Support/SupportCapsuleManagerTraitTest.php | 15 ++++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/support/src/Traits/CapsuleManagerTrait.php b/src/support/src/Traits/CapsuleManagerTrait.php index 3eacd4ce42..349477dcac 100644 --- a/src/support/src/Traits/CapsuleManagerTrait.php +++ b/src/support/src/Traits/CapsuleManagerTrait.php @@ -4,8 +4,8 @@ namespace Hypervel\Support\Traits; +use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; -use Hypervel\Support\Fluent; trait CapsuleManagerTrait { @@ -27,7 +27,7 @@ protected function setupContainer(Container $container): void $this->container = $container; if (! $this->container->bound('config')) { - $this->container->instance('config', new Fluent); + $this->container->instance('config', new Repository); } } diff --git a/tests/Support/SupportCapsuleManagerTraitTest.php b/tests/Support/SupportCapsuleManagerTraitTest.php index f21e135a9b..6c6a784f7d 100644 --- a/tests/Support/SupportCapsuleManagerTraitTest.php +++ b/tests/Support/SupportCapsuleManagerTraitTest.php @@ -6,7 +6,6 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; -use Hypervel\Support\Fluent; use Hypervel\Support\Traits\CapsuleManagerTrait; use Hypervel\Tests\TestCase; use ReflectionClass; @@ -21,20 +20,23 @@ public function testSetupContainerForCapsule(): void $this->setupContainer($app); $this->assertSame($app, $this->getContainer()); - $this->assertInstanceOf(Fluent::class, $app->make('config')); + $config = $app->make('config'); + $this->assertInstanceOf(Repository::class, $config); + $config->set('queue.default', 'default'); + $this->assertSame('default', $config->string('queue.default')); } public function testSetupContainerForCapsuleWhenConfigIsBound(): void { $app = new Container; - $app->instance('config', new Repository([])); + $app->instance('config', $config = new Repository([])); $this->setupContainer($app); $this->assertSame($app, $this->getContainer()); - $this->assertInstanceOf(Repository::class, $app->make('config')); + $this->assertSame($config, $app->make('config')); } - public function testFlushStateClearsGlobalInstance() + public function testFlushStateClearsGlobalInstance(): void { $this->setAsGlobal(); $this->assertSame($this, $this->getStaticInstance()); @@ -44,6 +46,9 @@ public function testFlushStateClearsGlobalInstance() $this->assertNull($this->getStaticInstance()); } + /** + * Get the globally selected Capsule instance. + */ private function getStaticInstance(): ?object { return (new ReflectionClass(static::class))->getStaticPropertyValue('instance'); From 667a9efe4abd686498299425c9bb6c212bdc535a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:39:29 +0000 Subject: [PATCH 20/23] Share queue connector registration with standalone Capsule Queue Capsule constructed a service provider that requires an Application, passing a bare Container instead. The existing comment and analysis suppression hid a real constructor TypeError. Extract connector registration into a shared concern used by the provider and Capsule. Preserve the nine connector hooks, their signatures, lazy construction, and exception reporting. Failover captures the manager receiving the connector registration rather than resolving an unrelated or missing queue binding. Have the provider route binding reuse the container-owned concrete registry, preserving routes registered before the provider. This completes the standalone bootstrap boundary used by the queue forwarding port from https://github.com/laravel/framework/pull/61188. Add real standalone sync dispatch, named connection, and failover ownership coverage. Verified affected queue and database tests, existing lazy connector and exception-reporting coverage, full source/type analysis, and formatting. --- src/queue/src/Capsule/Manager.php | 18 ++- .../src/Concerns/RegistersQueueConnectors.php | 138 ++++++++++++++++++ src/queue/src/QueueServiceProvider.php | 121 ++------------- tests/Queue/QueueCapsuleManagerTest.php | 59 ++++++++ 4 files changed, 219 insertions(+), 117 deletions(-) create mode 100644 src/queue/src/Concerns/RegistersQueueConnectors.php create mode 100644 tests/Queue/QueueCapsuleManagerTest.php diff --git a/src/queue/src/Capsule/Manager.php b/src/queue/src/Capsule/Manager.php index e60d64ea6e..f2894e8593 100644 --- a/src/queue/src/Capsule/Manager.php +++ b/src/queue/src/Capsule/Manager.php @@ -7,9 +7,10 @@ use DateInterval; use DateTimeInterface; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Queue\Queue; +use Hypervel\Queue\Concerns\RegistersQueueConnectors; use Hypervel\Queue\QueueManager; -use Hypervel\Queue\QueueServiceProvider; use Hypervel\Support\Traits\CapsuleManagerTrait; /** @@ -19,6 +20,7 @@ class Manager { use CapsuleManagerTrait; + use RegistersQueueConnectors; /** * The queue manager instance. @@ -60,13 +62,15 @@ protected function setupManager(): void */ protected function registerConnectors(): void { - // Capsule intentionally reuses the provider's connector registration logic with its - // standalone container; this works in practice and only differs from the provider's - // stricter application constructor type. - /** @phpstan-ignore-next-line */ - $provider = new QueueServiceProvider($this->container); + $this->registerDefaultConnectors($this->manager); + } - $provider->registerConnectors($this->manager); + /** + * Get the container used to resolve connector dependencies. + */ + protected function connectorContainer(): ContainerContract + { + return $this->container; } /** diff --git a/src/queue/src/Concerns/RegistersQueueConnectors.php b/src/queue/src/Concerns/RegistersQueueConnectors.php new file mode 100644 index 0000000000..457f8899e2 --- /dev/null +++ b/src/queue/src/Concerns/RegistersQueueConnectors.php @@ -0,0 +1,138 @@ +{"register{$connector}Connector"}($manager); + } + } + + /** + * Get the exception reporter for in-process queue connections. + */ + protected function exceptionReporter(): ?Closure + { + if (! $this->connectorContainer()->has(ExceptionHandler::class)) { + return null; + } + + return fn (Throwable $exception) => $this->connectorContainer()->make(ExceptionHandler::class)->report($exception); + } + + /** + * Register the Null queue connector. + */ + protected function registerNullConnector(QueueManager $manager): void + { + $manager->addConnector('null', fn () => new NullConnector); + } + + /** + * Register the Sync queue connector. + */ + protected function registerSyncConnector(QueueManager $manager): void + { + $manager->addConnector('sync', fn () => new SyncConnector); + } + + /** + * Register the Deferred queue connector. + */ + protected function registerDeferredConnector(QueueManager $manager): void + { + $manager->addConnector('deferred', fn () => new DeferredConnector($this->exceptionReporter())); + } + + /** + * Register the Background queue connector. + */ + protected function registerBackgroundConnector(QueueManager $manager): void + { + $manager->addConnector('background', fn () => new BackgroundConnector($this->exceptionReporter())); + } + + /** + * Register the Failover queue connector. + */ + protected function registerFailoverConnector(QueueManager $manager): void + { + $manager->addConnector('failover', fn () => new FailoverConnector( + $manager, + $this->connectorContainer()->make(EventDispatcher::class), + )); + } + + /** + * Register the database queue connector. + */ + protected function registerDatabaseConnector(QueueManager $manager): void + { + $manager->addConnector('database', function (): DatabaseConnector { + /** @var ConnectionResolverInterface $connections */ + $connections = $this->connectorContainer()->make('db'); + + return new DatabaseConnector($connections); + }); + } + + /** + * Register the Redis queue connector. + */ + protected function registerRedisConnector(QueueManager $manager): void + { + $manager->addConnector('redis', function (): RedisConnector { + /** @var RedisFactory $redis */ + $redis = $this->connectorContainer()->make('redis'); + + return new RedisConnector($redis); + }); + } + + /** + * Register the Beanstalkd queue connector. + */ + protected function registerBeanstalkdConnector(QueueManager $manager): void + { + $manager->addConnector('beanstalkd', fn () => new BeanstalkdConnector); + } + + /** + * Register the Amazon SQS queue connector. + */ + protected function registerSqsConnector(QueueManager $manager): void + { + $manager->addConnector('sqs', fn () => new SqsConnector); + } +} diff --git a/src/queue/src/QueueServiceProvider.php b/src/queue/src/QueueServiceProvider.php index 93cf70d6ae..e91ce62aba 100644 --- a/src/queue/src/QueueServiceProvider.php +++ b/src/queue/src/QueueServiceProvider.php @@ -4,21 +4,10 @@ namespace Hypervel\Queue; -use Closure; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Database\ModelIdentifier; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; -use Hypervel\Contracts\Redis\Factory as RedisFactory; -use Hypervel\Database\ConnectionResolverInterface; -use Hypervel\Queue\Connectors\BackgroundConnector; -use Hypervel\Queue\Connectors\BeanstalkdConnector; -use Hypervel\Queue\Connectors\DatabaseConnector; -use Hypervel\Queue\Connectors\DeferredConnector; -use Hypervel\Queue\Connectors\FailoverConnector; -use Hypervel\Queue\Connectors\NullConnector; -use Hypervel\Queue\Connectors\RedisConnector; -use Hypervel\Queue\Connectors\SqsConnector; -use Hypervel\Queue\Connectors\SyncConnector; +use Hypervel\Queue\Concerns\RegistersQueueConnectors; use Hypervel\Queue\Console\BatchesTableCommand; use Hypervel\Queue\Console\ClearCommand; use Hypervel\Queue\Console\FailedTableCommand; @@ -43,10 +32,10 @@ use Hypervel\Support\ServiceProvider; use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; -use Throwable; class QueueServiceProvider extends ServiceProvider { + use RegistersQueueConnectors; use SerializesAndRestoresModelIdentifiers; /** @@ -160,109 +149,21 @@ protected function registerConnection(): void /** * Register the connectors on the queue manager. + * + * Boot-only. Connectors persist on the supplied manager for the worker + * lifetime and affect every subsequent connection it resolves. */ public function registerConnectors(QueueManager $manager): void { - foreach (['Null', 'Sync', 'Deferred', 'Background', 'Failover', 'Database', 'Redis', 'Beanstalkd', 'Sqs'] as $connector) { - $this->{"register{$connector}Connector"}($manager); - } - } - - /** - * Get the exception reporter for in-process queue connections. - */ - protected function exceptionReporter(): ?Closure - { - if (! $this->app->has(ExceptionHandler::class)) { - return null; - } - - return fn (Throwable $exception) => $this->app->make(ExceptionHandler::class)->report($exception); - } - - /** - * Register the Null queue connector. - */ - protected function registerNullConnector(QueueManager $manager): void - { - $manager->addConnector('null', fn () => new NullConnector); - } - - /** - * Register the Sync queue connector. - */ - protected function registerSyncConnector(QueueManager $manager): void - { - $manager->addConnector('sync', fn () => new SyncConnector); - } - - /** - * Register the Deferred queue connector. - */ - protected function registerDeferredConnector(QueueManager $manager): void - { - $manager->addConnector('deferred', fn () => new DeferredConnector($this->exceptionReporter())); - } - - /** - * Register the Background queue connector. - */ - protected function registerBackgroundConnector(QueueManager $manager): void - { - $manager->addConnector('background', fn () => new BackgroundConnector($this->exceptionReporter())); - } - - /** - * Register the Failover queue connector. - */ - protected function registerFailoverConnector(QueueManager $manager): void - { - $manager->addConnector('failover', fn () => new FailoverConnector( - $this->app->make('queue'), - $this->app->make(EventDispatcher::class), - )); - } - - /** - * Register the database queue connector. - */ - protected function registerDatabaseConnector(QueueManager $manager): void - { - $manager->addConnector('database', function (): DatabaseConnector { - /** @var ConnectionResolverInterface $connections */ - $connections = $this->app->make('db'); - - return new DatabaseConnector($connections); - }); - } - - /** - * Register the Redis queue connector. - */ - protected function registerRedisConnector(QueueManager $manager): void - { - $manager->addConnector('redis', function (): RedisConnector { - /** @var RedisFactory $redis */ - $redis = $this->app->make('redis'); - - return new RedisConnector($redis); - }); - } - - /** - * Register the Beanstalkd queue connector. - */ - protected function registerBeanstalkdConnector(QueueManager $manager): void - { - $manager->addConnector('beanstalkd', fn () => new BeanstalkdConnector); + $this->registerDefaultConnectors($manager); } /** - * Register the Amazon SQS queue connector. + * Get the container used to resolve connector dependencies. */ - protected function registerSqsConnector(QueueManager $manager): void + protected function connectorContainer(): Container { - $manager->addConnector('sqs', fn () => new SqsConnector); + return $this->app; } /** @@ -293,7 +194,7 @@ protected function registerListener(): void */ protected function registerRoutes(): void { - $this->app->singleton('queue.routes', fn () => new QueueRoutes); + $this->app->singleton('queue.routes', fn ($app) => $app->make(QueueRoutes::class)); } /** diff --git a/tests/Queue/QueueCapsuleManagerTest.php b/tests/Queue/QueueCapsuleManagerTest.php new file mode 100644 index 0000000000..3fb59347bf --- /dev/null +++ b/tests/Queue/QueueCapsuleManagerTest.php @@ -0,0 +1,59 @@ +addConnection(['driver' => 'sync']); + $capsule->addConnection(['driver' => 'null'], 'discard'); + $job = new QueueCapsuleTestJob; + $capsule->getContainer()->instance(QueueCapsuleTestJob::class, $job); + + $capsule->getConnection()->push(QueueCapsuleTestJob::class, 'payload', 'emails'); + + $this->assertSame(['payload', 'default', 'emails'], $job->received); + $this->assertInstanceOf(NullQueue::class, $capsule->getConnection('discard')); + } + + public function testFailoverUsesTheCapsulesOwnManager(): void + { + $container = new Container; + $container->instance(DispatcherContract::class, new Dispatcher($container)); + $container->instance('queue', new QueueManager(new Container)); + $capsule = new Manager($container); + $capsule->addConnection(['driver' => 'failover', 'connections' => ['discard']]); + $capsule->addConnection(['driver' => 'null'], 'discard'); + + $queue = $capsule->getConnection(); + + $this->assertSame($capsule->getQueueManager(), $queue->manager); + $this->assertSame(0, $queue->size()); + } +} + +class QueueCapsuleTestJob +{ + public array $received = []; + + /** + * Record the delivered payload and queue identifiers. + */ + public function fire(Job $job, mixed $data): void + { + $this->received = [$data, $job->getConnectionName(), $job->getQueue()]; + } +} From d4abf1b7488c595c5bac3438dc685f829c8c444c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:39:50 +0000 Subject: [PATCH 21/23] Resolve queue routes through their owning container Queue routing consulted the global container even when a manager or dispatcher belonged to another container. Without a provider binding, it created a fresh registry for every lookup, immediately losing route and forwarding registrations. Resolve routes from the existing owner across queue managers, queues, bus and event dispatchers, broadcasting, and notifications. Honor explicit route-service replacements and use the native auto-singleton for unbound registries. Preserve supported container-less queue access and the routing trait default. No per-job shared state, collaborator cache, additional I/O, or new lifecycle mechanism is introduced. Complete the standalone routing behavior associated with https://github.com/laravel/framework/pull/61188. Cover owner/global isolation, replacements, persistent registrations, provider boot, and a real forwarded SQLite queue shared by Database and Queue Capsules. Correct existing reservation fixtures to register routes with their queue owner. Use partial concrete container mocks only where queue tests now require real routing resolution, preserving event counts, payload assertions, cancellation and error behavior, and the assertion that small SQS payloads do not resolve overflow storage. Validated each changed test file, affected Queue/Bus/Events/Notifications/Broadcasting/Capsule/Database tests with ParaTest, real Redis integration, full source/type analysis, and formatting. Final review confirmed the regression assertions fail against the previous behavior. --- src/broadcasting/src/BroadcastManager.php | 8 ++ src/bus/src/Dispatcher.php | 8 ++ src/events/src/Dispatcher.php | 8 ++ src/notifications/src/ChannelManager.php | 9 ++ src/queue/src/Queue.php | 9 ++ src/queue/src/QueueManager.php | 8 ++ .../Queue/Concerns/ResolvesQueueRoutes.php | 14 ++- .../Sqlite/DatabaseQueueReservationTest.php | 5 +- .../Database/Sqlite/QueueCapsuleTest.php | 44 +++++++++ .../Queue/Redis/RedisQueueTest.php | 4 +- tests/Queue/QueueBeanstalkdQueueTest.php | 13 +-- tests/Queue/QueueDatabaseQueueUnitTest.php | 6 +- tests/Queue/QueueRedisQueueTest.php | 22 ++--- tests/Queue/QueueRouteContainerTest.php | 90 +++++++++++++++++++ tests/Queue/QueueSqsQueueTest.php | 60 ++++++------- 15 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php create mode 100644 tests/Queue/QueueRouteContainerTest.php diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 91fc614e17..ca2170ac0f 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -252,6 +252,14 @@ public function queue(mixed $event): void : $push(); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->app; + } + /** * Determine if the broadcastable event must be unique and determine if we can acquire the necessary lock. */ diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 67881ea316..64dbcef26b 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -287,6 +287,14 @@ public function dispatchAfterResponse(mixed $command, mixed $handler = null): vo } } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container; + } + /** * Set the pipes through which commands should be piped before dispatching. * diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 9a0ecabdd4..aedec9c734 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -1124,6 +1124,14 @@ protected function resolveQueue(): QueueFactory return call_user_func($this->queueResolver); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): ContainerContract + { + return $this->container; + } + /** * Set the queue resolver implementation. * diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index 00427716ec..ba0953aa7d 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -6,6 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; +use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Notifications\Dispatcher as DispatcherContract; @@ -71,6 +72,14 @@ public function sendNow(mixed $notifiables, mixed $notification, ?array $channel ))->sendNow($notifiables, $notification, $channels); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container; + } + /** * Get a channel instance. */ diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php index 9c861b9355..9f6b30a6cb 100644 --- a/src/queue/src/Queue.php +++ b/src/queue/src/Queue.php @@ -8,6 +8,7 @@ use DateInterval; use DateTimeInterface; use Hypervel\Bus\DispatchLockContext; +use Hypervel\Container\Container as GlobalContainer; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; @@ -672,6 +673,14 @@ protected function resolveQueue(string $queue): string return $this->queueRoutes()->forwardedQueue($queue, $this->connectionName ?? null); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->container ?? GlobalContainer::getInstance(); + } + /** * Get the connection name for the queue. */ diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 2d27b9dc65..5a48506e29 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -182,6 +182,14 @@ public function forward(array|string|UnitEnum $queue, UnitEnum|string|null $to = $this->queueRoutes()->forward($queue, $to, $connection); } + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): Container + { + return $this->app; + } + /** * Pause a queue by its connection and name. */ diff --git a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php index 8ce076f4cb..4f6f8aac4d 100644 --- a/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php +++ b/src/support/src/Queue/Concerns/ResolvesQueueRoutes.php @@ -5,6 +5,7 @@ namespace Hypervel\Support\Queue\Concerns; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Queue\QueueRoutes; use UnitEnum; @@ -33,10 +34,19 @@ public function resolveQueueFromQueueRoute(object $queueable): ?string */ protected function queueRoutes(): QueueRoutes { - $container = Container::getInstance(); + $container = $this->queueRoutesContainer(); + // Standalone managers must share the container's registry even without a provider binding. return $container->bound('queue.routes') ? $container->make('queue.routes') - : new QueueRoutes; + : $container->make(QueueRoutes::class); + } + + /** + * Get the container that owns the queue routes. + */ + protected function queueRoutesContainer(): ContainerContract + { + return Container::getInstance(); } } diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php index d35d7ae690..ee55b3caa6 100644 --- a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -37,7 +37,7 @@ public function testFailoverForwardsOnceBeforeStoringOnTheFallbackConnection(?st [$database, $events] = $this->createQueue(); $routes = new QueueRoutes; $routes->forward(['reports' => 'processing', 'processing' => 'archive'], connection: $connection); - Container::getInstance()->instance('queue.routes', $routes); + $database->getContainer()->instance('queue.routes', $routes); $payload = json_encode(['job' => stdClass::class, 'data' => []]); $primary = m::mock(QueueContract::class); $primary->shouldReceive('pushRaw')->once()->with($payload, $delegatedQueue)->andThrow(new RuntimeException('Primary unavailable.')); @@ -45,6 +45,7 @@ public function testFailoverForwardsOnceBeforeStoringOnTheFallbackConnection(?st $manager->shouldReceive('connection')->once()->with('primary')->andReturn($primary); $manager->shouldReceive('connection')->once()->with('database')->andReturn($database); $queue = new FailoverQueue($manager, $events, ['primary', 'database']); + $queue->setContainer($database->getContainer()); $queue->setConnectionName('failover'); $id = $queue->pushRaw($payload, 'reports'); @@ -58,7 +59,7 @@ public function testForwardedQueueReservesAndReleasesUsingTheLogicalName(): void [$queue] = $this->createQueue(); $routes = new QueueRoutes; $routes->forward(['reports' => 'processing', 'processing' => 'archive']); - Container::getInstance()->instance('queue.routes', $routes); + $queue->getContainer()->instance('queue.routes', $routes); $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []]), 'reports'); $this->assertSame('processing', $queue->getDatabase()->table('jobs')->find($id)->queue); diff --git a/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php b/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php new file mode 100644 index 0000000000..89f5dc6489 --- /dev/null +++ b/tests/Integration/Queue/Database/Sqlite/QueueCapsuleTest.php @@ -0,0 +1,44 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:']); + $connection = $database->getConnection(); + $connection->getSchemaBuilder()->create('jobs', static function (Blueprint $table): void { + $table->id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + $container->instance('db', $database->getDatabaseManager()); + $queue = new QueueCapsule($container); + $queue->addConnection([ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'reports', + ]); + $queue->forward('reports', 'processing'); + + $id = $queue->getConnection()->pushRaw('{"job":"example","data":[]}'); + + $this->assertSame('processing', $connection->table('jobs')->find($id)->queue); + $this->assertSame(1, $queue->getConnection()->size()); + } +} diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index c2cf2163b1..078c4e0c19 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -487,7 +487,7 @@ public function testPushJobQueueingAndJobQueuedEvents(): void return true; })->andReturnNull()->once(); - $container = m::mock(Container::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('bound')->with('events')->andReturn(true)->times(3); $container->shouldReceive('make')->with('events')->andReturn($events)->times(3); @@ -507,7 +507,7 @@ public function testBulkJobQueuedEvent(): void $events->shouldReceive('dispatch')->with(m::type(JobQueueing::class))->andReturnNull()->times(3); $events->shouldReceive('dispatch')->with(m::type(JobQueued::class))->andReturnNull()->times(3); - $container = m::mock(Container::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('has')->with('db.transactions')->andReturnFalse()->once(); $container->shouldReceive('bound')->with('events')->andReturn(true)->times(9); $container->shouldReceive('make')->with('events')->andReturn($events)->times(9); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 122b4b9406..4122cc4a00 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -4,8 +4,8 @@ namespace Hypervel\Tests\Queue; -use Hypervel\Container\Container as Application; -use Hypervel\Contracts\Container\Container; +use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Events\Dispatcher; use Hypervel\Queue\BeanstalkdQueue; use Hypervel\Queue\Events\JobQueued; @@ -33,7 +33,7 @@ class QueueBeanstalkdQueueTest extends TestCase private $queue; /** - * @var Container + * @var ContainerContract */ private $container; @@ -190,7 +190,7 @@ public function testJobQueuedReceivesTheExactBeanstalkdJobIdentifier(): void $queuedEvent = $event; }); - $container = new Application; + $container = new Container; $container->instance('events', $events); $this->queue->setContainer($container); @@ -269,6 +269,9 @@ public function testDeleteProperlyRemoveJobsOffBeanstalkd() $this->queue->deleteMessage('default', 1); } + /** + * Configure the queue and its container. + */ private function setQueue(string $default, int $timeToRun, int $blockFor = 0): void { $this->queue = new BeanstalkdQueue( @@ -278,7 +281,7 @@ private function setQueue(string $default, int $timeToRun, int $blockFor = 0): v $blockFor ); $this->queue->setConnectionName('beanstalkd'); - $this->container = m::spy(Container::class); + $this->container = m::spy(Container::class)->makePartial(); $this->queue->setContainer($this->container); } } diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 95fabd57cf..233c1950fd 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -181,7 +181,7 @@ public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameS default: 'default', currentTime: 1732502704, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(ConnectionInterface::class)); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) use ($uuid, $displayNameStartsWith, $jobStartsWith) { @@ -231,7 +231,7 @@ public function testDelayedPushNeverRunsBeforeRequestedDeadline(DateInterval|Dat default: 'default', currentTime: 1000, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $resolver->shouldReceive('connection')->andReturn($connection); @@ -275,7 +275,7 @@ public function testPushIncludesBatchIdInPayloadForBatchableJob() default: 'default', currentTime: 1732502704, ); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $resolver->shouldReceive('connection')->andReturn($connection = m::mock(ConnectionInterface::class)); $connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) { diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index df413108df..5e1ddafd1c 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -291,7 +291,7 @@ public function testPushProperlyPushesJobOntoRedis(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -312,7 +312,7 @@ public function testPushProperlyPushesJobOntoRedisWithCustomPayloadHook(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -339,7 +339,7 @@ public function testJobQueueingAndQueuedEventsAreSkippedWhenNoListenersAreRegist $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::mock(Container::class)); + $queue->setContainer($container = m::mock(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -457,7 +457,7 @@ public function testPushRaisesFailedEventWhenRedisThrows(): void $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::mock(Container::class)); + $queue->setContainer($container = m::mock(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -510,7 +510,7 @@ public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook(): vo $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldAllowMockingMethod('evalWithShaCache'); @@ -540,7 +540,7 @@ public function testDelayedPushProperlyPushesJobOntoRedis(): void $uuid = $this->mockUuid(); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -567,7 +567,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis(): void $date = CarbonImmutable::createFromTimestampUTC('1001.100000'); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -593,7 +593,7 @@ public function testDelayedPushWithIntervalNeverRunsBeforeRequestedLifetime(): v $delay = new DateInterval('PT1S'); $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); @@ -696,7 +696,7 @@ public function testPushUsesClusterSafeRedisKeyForLuaScript(): void ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -724,7 +724,7 @@ public function testPushPassesLogicalQueueToPayloadCallbacksOnCluster(): void ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->setContainer(m::spy(Container::class)); + $queue->setContainer(m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $redisProxy = m::mock(RedisProxy::class); @@ -760,7 +760,7 @@ public function testLaterUsesClusterSafeRedisKeyForDelayedSet(): void ->onlyMethods(['availableAt', 'getRandomId']) ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) ->getMock(); - $queue->setContainer($container = m::spy(Container::class)); + $queue->setContainer($container = m::spy(Container::class)->makePartial()); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); $queue->expects($this->once())->method('availableAt')->with(1)->willReturn(2); diff --git a/tests/Queue/QueueRouteContainerTest.php b/tests/Queue/QueueRouteContainerTest.php new file mode 100644 index 0000000000..c8aca43c5c --- /dev/null +++ b/tests/Queue/QueueRouteContainerTest.php @@ -0,0 +1,90 @@ +instance('queue.routes', $foreign = new QueueRoutes); + $foreign->set(stdClass::class, 'foreign-queue', 'foreign-connection'); + $owner = new Container; + $owner->instance('config', new Repository); + $owner->instance('queue.routes', $routes = new QueueRoutes); + $routes->set(stdClass::class, 'owner-queue', 'owner-connection'); + $consumer = $class === NullQueue::class + ? (new NullQueue)->setContainer($owner) + : new $class($owner); + $job = new stdClass; + + $this->assertSame('owner-queue', $consumer->resolveQueueFromQueueRoute($job)); + $this->assertSame('owner-connection', $consumer->resolveConnectionFromQueueRoute($job)); + + $owner->instance('queue.routes', $replacement = new QueueRoutes); + $replacement->set(stdClass::class, 'replacement-queue', 'replacement-connection'); + + $this->assertSame('replacement-queue', $consumer->resolveQueueFromQueueRoute($job)); + $this->assertSame('replacement-connection', $consumer->resolveConnectionFromQueueRoute($job)); + } + + /** + * Provide the framework services that resolve queue routes. + */ + public static function routingConsumers(): array + { + return [ + 'queue manager' => [QueueManager::class], + 'queue' => [NullQueue::class], + 'bus' => [BusDispatcher::class], + 'events' => [Dispatcher::class], + 'broadcasts' => [BroadcastManager::class], + 'notifications' => [ChannelManager::class], + ]; + } + + public function testRoutesPersistWithoutABindingAndRemainLocalToTheirContainer(): void + { + $owner = new Container; + $manager = new QueueManager($owner); + $manager->route(stdClass::class, 'reports'); + $manager->forward('reports', 'processing', 'redis'); + $secondManager = new QueueManager($owner); + $otherManager = new QueueManager(new Container); + $job = new stdClass; + + $this->assertSame('reports', $secondManager->resolveQueueFromQueueRoute($job)); + $this->assertSame('redis', $secondManager->resolveConnectionFromQueueRoute($job)); + $this->assertNull($otherManager->resolveQueueFromQueueRoute($job)); + $this->assertNull($otherManager->resolveConnectionFromQueueRoute($job)); + } + + public function testProviderPreservesRoutesRegisteredBeforeItsBinding(): void + { + $application = new Application; + $manager = new QueueManager($application); + $manager->route(stdClass::class, 'reports'); + + (new QueueServiceProvider($application))->register(); + + $this->assertSame('reports', $manager->resolveQueueFromQueueRoute(new stdClass)); + } +} diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php index c0bd57a5c7..6f85e4eaa0 100644 --- a/tests/Queue/QueueSqsQueueTest.php +++ b/tests/Queue/QueueSqsQueueTest.php @@ -142,18 +142,12 @@ protected function createMockedUuid(string $value): Uuid return Uuid::fromString($value); } + /** + * Create a container spy with real service resolution. + */ protected function createSpyContainer(): Container { - $container = m::spy(Container::class); - - $container->shouldReceive('has') - ->with('queue.routes') - ->andReturn(true); - $container->shouldReceive('make') - ->with('queue.routes') - ->andReturn(new QueueRoutes); - - return $container; + return m::spy(Container::class)->makePartial(); } public function testPopProperlyPopsJobOffOfSqs() @@ -181,7 +175,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoSqs(): void { $now = CarbonImmutable::now(); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($now->addSeconds(5))->willReturn(5); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); @@ -194,7 +188,7 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoSqs(): void public function testDelayedPushProperlyPushesJobOntoSqs() { $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'secondsUntil', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); @@ -207,7 +201,7 @@ public function testDelayedPushProperlyPushesJobOntoSqs() public function testPushProperlyPushesJobOntoSqs() { $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload])->andReturn($this->mockedSendMessageResponseModel); @@ -224,7 +218,7 @@ public function testPushPreservesZeroQueueAndDefaultsEmptyQueue(string $requeste ->onlyMethods(['createPayload', 'getQueue']) ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -246,7 +240,7 @@ public function testLaterPreservesZeroQueueAndDefaultsEmptyQueue(string $request ->onlyMethods(['createPayload', 'getQueue', 'secondsUntil']) ->setConstructorArgs([$this->sqs, $this->queueName, $this->prefix]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $logicalQueue, $this->mockedData, $this->mockedDelay)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($requestedQueue)->willReturn($queueUrl); $queue->expects($this->once())->method('secondsUntil')->with($this->mockedDelay)->willReturn($this->mockedDelay); @@ -495,7 +489,7 @@ public function testPushProperlyPushesJobObjectOntoSqs() $job = new FakeSqsJob; $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload])->andReturn($this->mockedSendMessageResponseModel); @@ -532,7 +526,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFairQueue() $job = (new FakeSqsJob)->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->queueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->queueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->queueName)->willReturn($this->queueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'MessageBody' => $this->mockedPayload, 'MessageGroupId' => $this->mockedMessageGroupId])->andReturn($this->mockedSendMessageResponseModel); @@ -568,7 +562,7 @@ public function testPushProperlyPushesJobStringOntoSqsFifoQueue() Str::createUuidsUsing(fn () => $this->createMockedUuid($this->mockedDeduplicationId)); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($this->mockedJob, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -591,7 +585,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueue() $job = (new FakeSqsJob)->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -615,7 +609,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithMessageGroupM $job->expects($this->once())->method('messageGroup')->willReturn($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -642,7 +636,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithMessageGroupP $job->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -665,7 +659,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithDeduplication $job->onGroup($this->mockedMessageGroupId); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -693,7 +687,7 @@ public function testPushProperlyPushesJobObjectOntoSqsFifoQueueWithDeduplicator( }); $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['createPayload', 'getQueue'])->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account])->getMock(); - $queue->setContainer($container = m::spy(ContainerContract::class)); + $queue->setContainer($container = $this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with($job, $this->fifoQueueName, $this->mockedData)->willReturn($this->mockedPayload); $queue->expects($this->once())->method('getQueue')->with($this->fifoQueueName)->willReturn($this->fifoQueueUrl); $this->sqs->shouldReceive('sendMessage')->once()->with([ @@ -854,7 +848,7 @@ public function testDelayedPushRejectsPositiveDelayForStringJobOnSqsFifoQueue(): ->onlyMethods(['createPayload']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->never())->method('createPayload'); $this->sqs->shouldNotReceive('sendMessage'); @@ -872,7 +866,7 @@ public function testDelayedPushRejectsPositiveDelayForObjectJobOnSqsFifoQueue(): ->onlyMethods(['createPayload']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->never())->method('createPayload'); $this->sqs->shouldNotReceive('sendMessage'); @@ -914,7 +908,7 @@ public function testNonPositiveAndElapsedDelaysRemainImmediateOnSqsFifoQueue(int ->onlyMethods(['createPayload', 'getQueue']) ->setConstructorArgs([$this->sqs, $this->fifoQueueName, $this->account]) ->getMock(); - $queue->setContainer(m::spy(ContainerContract::class)); + $queue->setContainer($this->createSpyContainer()); $queue->expects($this->once())->method('createPayload')->with( $this->mockedJob, $this->fifoQueueName, @@ -963,7 +957,7 @@ public function testPushRawStoresOverflowPayloadAndSendsItsPointer(): void $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -990,8 +984,8 @@ public function testPushRawDoesNotResolveOverflowStorageWhenDisabledOrBelowThres 'data' => 'small', ], JSON_THROW_ON_ERROR); - $container = m::mock(ContainerContract::class); - $container->shouldNotReceive('make'); + $container = m::mock(Container::class)->makePartial(); + $container->shouldNotReceive('make')->with('cache'); $queue = new SqsQueue( $this->sqs, @@ -1024,7 +1018,7 @@ public function testPushRawAlwaysStoresOverflowPayloadWhenConfigured(): void $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1062,7 +1056,7 @@ function (string $candidate, string $stored) use (&$path, $payload): bool { $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1175,7 +1169,7 @@ public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsAmbiguous(): v $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( @@ -1206,7 +1200,7 @@ public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsCanceled(): vo $cache = m::mock(CacheFactory::class); $cache->shouldReceive('store')->once()->with('database')->andReturn($store); - $container = m::mock(ContainerContract::class); + $container = m::mock(Container::class)->makePartial(); $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); $queue = new SqsQueue( From 25a74f01b24e4aa147106049cd26dfc6b48a73a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:02:50 +0000 Subject: [PATCH 22/23] Regenerate queue routing facade annotations Adding the caller-selected queue parameter description made its PHPDoc union authoritative for facade generation. The generated Broadcast, Bus, Event, Notification, and Queue annotations still reflected the native union order, causing FacadeDocblocksTest to fail in both PHP 8.4 and PHP 8.5 CI. Regenerate the five affected method annotations with composer facade. The accepted types and runtime behavior are unchanged; only the union member order differs. Follow-up to the queue forwarding port: https://github.com/laravel/framework/pull/61188. Verified the existing facade consistency regression fails before regeneration and passes afterward. Full formatting and source/type analysis pass; the final generated diff was reviewed. --- src/support/src/Facades/Broadcast.php | 2 +- src/support/src/Facades/Bus.php | 2 +- src/support/src/Facades/Event.php | 2 +- src/support/src/Facades/Notification.php | 2 +- src/support/src/Facades/Queue.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/support/src/Facades/Broadcast.php b/src/support/src/Facades/Broadcast.php index 9045689e2a..604d66cb20 100644 --- a/src/support/src/Facades/Broadcast.php +++ b/src/support/src/Facades/Broadcast.php @@ -26,7 +26,7 @@ * @method static \Pusher\Pusher pusher(array $config) * @method static void queue(mixed $event) * @method static \Hypervel\Broadcasting\BroadcastManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void routes(array|null $attributes = null) * @method static \Hypervel\Broadcasting\BroadcastManager setApplication(\Hypervel\Contracts\Container\Container $app) diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 806bb0be1d..9348156407 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -23,7 +23,7 @@ * @method static bool hasCommandHandler(mixed $command) * @method static \Hypervel\Bus\Dispatcher map(array $map) * @method static \Hypervel\Bus\Dispatcher pipeThrough(array $pipes) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Bus\Dispatcher withDispatchingAfterResponses() * @method static \Hypervel\Bus\Dispatcher withoutDispatchingAfterResponses() diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index ea769d8217..49cef8a8ab 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -28,7 +28,7 @@ * @method static void mixin(object $mixin, bool $replace = true) * @method static void observe(array|string $events, object|array|string $observer) * @method static void push(string $event, mixed $payload = []) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static \Hypervel\Events\Dispatcher setQueueResolver(callable $resolver) * @method static \Hypervel\Events\Dispatcher setTransactionManagerResolver(callable $resolver) diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index 2d6b67e887..66177451d9 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -25,7 +25,7 @@ * @method static \Hypervel\Notifications\ChannelManager locale(string $locale) * @method static void macro(string $name, callable|object $macro) * @method static void mixin(object $mixin, bool $replace = true) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void send(mixed $notifiables, mixed $notification) * @method static void sendNow(mixed $notifiables, mixed $notification, array|null $channels = null) diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 09245b87d4..bba497e529 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -32,7 +32,7 @@ * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void purge(string|null $name = null) * @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver) - * @method static string|null resolveConnectionFromQueueRoute(object $queueable, \UnitEnum|string|null $queue = null) + * @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void resume(string $connection, string $queue) * @method static void resumeAll() From 5d0406c999b1379519739010246f9002b4319d6f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:55:47 +0000 Subject: [PATCH 23/23] Preserve queued jobs when observer queries fail Database reservation recovery could mistake a query issued by an observer for a failed reservation update and delete an otherwise valid job after rollback. Restrict recovery to exceptions matching the held database connection name, reservation SQL and prepared bindings. Compile the expected update with the existing query grammar using the already-mutated job record. This adds no SQL execution, shared state or work to successful reservations. Preserve transient-error, transaction-depth, cancellation and cleanup behavior; document the predicate alongside the reservation override boundary. Extend SQLite regressions across before-query, executed-query and duration observers, including identical SQL with different job bindings. Name the storage fixture distinctly from the queue and resolver identities. Keep the cleanup unit test focused on cleanup failure. Validation: changed test files, complete queue and database package suites, full source/type analysis, formatting and diff checks pass. All four new regression cases fail without the predicate. Corrects recovery ported from Laravel framework PRs: https://github.com/laravel/framework/pull/58978 https://github.com/laravel/framework/pull/59718 --- src/queue/src/DatabaseQueue.php | 28 ++++++- src/queue/src/Jobs/DatabaseJobRecord.php | 1 + .../Sqlite/DatabaseQueueReservationTest.php | 79 +++++++++++++++++-- tests/Queue/QueueDatabaseQueueUnitTest.php | 1 + 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 81baeca107..dcedb06925 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -504,10 +504,12 @@ public function pop(?string $queue = null): ?Job // Recovery requires our transaction to have unwound. Transient database // failures leave the job available for another reservation attempt. // Non-query callback failures do not establish an invalid job record. + // Observers can run their own failing SQL, so match the reservation update. if ($jobRecord !== null && $database->transactionLevel() === $transactionLevel && ! $this->causedByConcurrencyError($exception) - && ! $this->causedByLostConnection($exception)) { + && ! $this->causedByLostConnection($exception) + && $this->causedByReservationQuery($exception, $database, $jobRecord)) { try { (new DatabaseJob( $this->container, @@ -609,6 +611,30 @@ protected function markJobAsReserved(DatabaseJobRecord $job): DatabaseJobRecord return $job; } + /** + * Determine whether the exception matches this job's reservation update. + * + * Override this alongside markJobAsReserved when changing its SQL or bindings. + */ + protected function causedByReservationQuery( + QueryException $exception, + ConnectionInterface $database, + DatabaseJobRecord $jobRecord + ): bool { + if ($exception->getConnectionName() !== $database->getName()) { + return false; + } + + $query = $database->table($this->table)->where('id', $jobRecord->id); + $values = ['reserved_at' => $jobRecord->reserved_at, 'attempts' => $jobRecord->attempts]; + $grammar = $query->getGrammar(); + + return $exception->getSql() === $grammar->compileUpdate($query, $values) + && $exception->getBindings() === $database->prepareBindings($query->cleanBindings( + $grammar->prepareBindingsForUpdate($query->getRawBindings(), $values) + )); + } + /** * Delete a reserved job from the queue. * diff --git a/src/queue/src/Jobs/DatabaseJobRecord.php b/src/queue/src/Jobs/DatabaseJobRecord.php index 853aeb95a1..727f11f800 100644 --- a/src/queue/src/Jobs/DatabaseJobRecord.php +++ b/src/queue/src/Jobs/DatabaseJobRecord.php @@ -11,6 +11,7 @@ * @property int $id * @property string $payload * @property int $attempts + * @property null|int $reserved_at */ class DatabaseJobRecord { diff --git a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php index ee55b3caa6..bf829eb1df 100644 --- a/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php +++ b/tests/Integration/Queue/Database/Sqlite/DatabaseQueueReservationTest.php @@ -163,19 +163,34 @@ public function __construct() } } - #[TestWith(['before'])] - #[TestWith(['executed'])] - #[TestWith(['duration'])] - public function testQueryObserverFailureKeepsTheJobAvailable(string $observer): void + #[TestWith(['before', false])] + #[TestWith(['executed', false])] + #[TestWith(['duration', false])] + #[TestWith(['before', true])] + #[TestWith(['executed', true])] + #[TestWith(['duration', true])] + public function testQueryObserverFailureKeepsTheJobAvailable(string $observer, bool $queryFailure): void { [$queue, $events] = $this->createQueue(); $database = $queue->getDatabase(); $id = $queue->pushRaw(json_encode(['job' => stdClass::class, 'data' => []])); - $failure = new RuntimeException('Query observer failed.'); - $callback = static function (string $query) use ($failure): void { - if (str_starts_with($query, 'update ')) { + $failure = $queryFailure ? null : new RuntimeException('Query observer failed.'); + $callback = static function (string $query) use ($database, $queryFailure, &$failure): void { + if (! str_starts_with($query, 'update ')) { + return; + } + + if (! $queryFailure) { throw $failure; } + + try { + $database->statement('insert into missing_query_audit (message) values (?)', ['query observed']); + } catch (QueryException $exception) { + $failure = $exception; + + throw $exception; + } }; $failed = false; $events->listen(JobFailed::class, static function () use (&$failed): void { @@ -216,6 +231,53 @@ public function testQueryObserverFailureKeepsTheJobAvailable(string $observer): $this->assertFalse($failed); } + public function testObserverFailureUpdatingAnotherJobKeepsTheJobAvailable(): void + { + [$queue, $events] = $this->createQueue(); + $database = $queue->getDatabase(); + $payload = json_encode(['job' => stdClass::class, 'data' => []]); + $id = $queue->pushRaw($payload); + $otherId = $queue->pushRaw($payload); + $failure = null; + $reservationSql = null; + $failed = false; + $events->listen(JobFailed::class, static function () use (&$failed): void { + $failed = true; + }); + $events->listen(QueryExecuted::class, static function (QueryExecuted $event) use ($database, $otherId, &$failure, &$reservationSql): void { + if ($reservationSql !== null || ! str_starts_with($event->sql, 'update ')) { + return; + } + + $reservationSql = $event->sql; + + try { + $database->table('jobs')->where('id', $otherId)->update([ + 'reserved_at' => 1, + 'attempts' => 65536, + ]); + } catch (QueryException $exception) { + $failure = $exception; + + throw $exception; + } + }); + + try { + $queue->pop(); + $this->fail('Expected the observer update to fail.'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception); + $this->assertSame($reservationSql, $exception->getSql()); + } + + $record = $database->table('jobs')->find($id); + $this->assertNotNull($record); + $this->assertSame(0, $record->attempts); + $this->assertNull($record->reserved_at); + $this->assertFalse($failed); + } + public function testCommittedListenerFailureKeepsTheReservedJob(): void { [$queue, $events] = $this->createQueue(); @@ -281,7 +343,8 @@ public function testFailedRollbackDoesNotFailTheJobInTheOpenTransaction(): void */ private function createQueue(?PDO $pdo = null): array { - $database = new PdoConnection($pdo ?? new PDO('sqlite::memory:')); + // Distinguish the storage connection from the queue name and default resolver key. + $database = new PdoConnection($pdo ?? new PDO('sqlite::memory:'), config: ['name' => 'queue-storage']); $database->setQueryGrammar(new SQLiteGrammar($database)); // SQLite ignores integer widths, so enforce the migration's unsignedSmallInteger ceiling explicitly. diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 233c1950fd..96cf86c9cf 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -142,6 +142,7 @@ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation( { $failure = new QueryException('database', 'update jobs', [], new PDOException('Reservation failed.')); [$queue, $events] = $this->createFailingReservationQueue($failure); + $queue->shouldReceive('causedByReservationQuery')->once()->andReturn(true); $queue->shouldReceive('deleteReserved')->once()->with('default', '1')->andThrow($cleanupFailure); if ($cleanupFailure instanceof CanceledException) {