Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f09df67
Roll back every test transaction level and add WithoutMigrationEvents
binaryfire Nov 3, 2026
8deb1d0
Remove dead guards around test method reflection
binaryfire Nov 3, 2026
aa6cb14
Cover GMT conversion of the down --retry datetime
binaryfire Nov 3, 2026
4384549
Read bus job attributes through ReadsClassAttributes directly
binaryfire Nov 3, 2026
f84cc3f
Add size methods to the array rule builder
binaryfire Nov 3, 2026
058e9a5
Fix distinct:ignore_case with malformed UTF-8 and trailing newlines
binaryfire Nov 3, 2026
724f440
Report reconnect driver failures as query exceptions
binaryfire Nov 3, 2026
f8af988
Cover unencodable JSON in JSON paths and object casts
binaryfire Nov 3, 2026
15cd12a
Correct the AsFluent cast's write type
binaryfire Nov 3, 2026
de1daaf
Fail schedule:interrupt when interrupting is disabled
binaryfire Nov 3, 2026
840b107
Cast non-integer failed job ids to strings
binaryfire Nov 3, 2026
9e16d11
Cover transactions manager rollback after a failed commit
binaryfire Nov 3, 2026
258c604
Cover models with a disabled primary key
binaryfire Nov 3, 2026
a498ac4
Add restoreOrCreate() and createOrRestore() to through relations
binaryfire Nov 3, 2026
fd53bfa
Support exit code and exception fakes when starting processes
binaryfire Nov 3, 2026
9a6a11e
Cover install:broadcasting with each React entry file
binaryfire Nov 3, 2026
75e8797
Assert the Paginator::links() return type
binaryfire Nov 3, 2026
ded74f2
Fix updating JSON paths with collections
binaryfire Nov 3, 2026
368928d
Merge updated 0.4 into upstream sync
binaryfire Nov 3, 2026
2ee00ce
Add strict typed getters to InteractsWithData
binaryfire Nov 3, 2026
a08fb53
Return filtered numbers from cache getters and the page resolver
binaryfire Nov 3, 2026
96fdbf2
Merge updated 0.4 into upstream sync
binaryfire Nov 3, 2026
08ffcbf
Flush Redis cache tags one tag at a time
binaryfire Nov 3, 2026
4a31de9
Name the query-count test cases after their method
binaryfire Nov 3, 2026
4d7982d
Create the SQLite test database file only for SQLite runs
binaryfire Nov 3, 2026
5b65f6d
Qualify PostgreSQL index drops and renames with the table schema
binaryfire Nov 3, 2026
ed570c7
Cover reconnecting after a connection lost during rollback
binaryfire Nov 3, 2026
e1e080e
Add missing PostgreSQL full text languages
binaryfire Nov 3, 2026
9ff770d
Merge remote-tracking branch 'origin/0.4' into upstream-sync-2026-11-03
binaryfire Nov 3, 2026
fbc8766
Move the SQLite test database file fix to the next batch
binaryfire Nov 3, 2026
c4c3331
Create the SQLite test database file only for SQLite runs
binaryfire Nov 3, 2026
da1b0e1
Add title docblocks to new test helper methods
binaryfire Nov 3, 2026
ba502e5
Describe install:broadcasting's Echo setup for non-React apps
binaryfire Nov 3, 2026
7431126
Read assigned cast objects back in the cast's own form
binaryfire Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/databases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ jobs:
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: bin/run-database-tests.sh mysql
run: |
bin/run-database-tests.sh mysql
vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php

mysql_84:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -104,7 +106,9 @@ jobs:
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: bin/run-database-tests.sh mysql
run: |
bin/run-database-tests.sh mysql
vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php

mysql_97:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -152,7 +156,9 @@ jobs:
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: bin/run-database-tests.sh mysql
run: |
bin/run-database-tests.sh mysql
vendor/bin/phpunit --no-progress tests/Integration/Auth/ApiAuthenticationWithEloquentTest.php

mariadb_1011:
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions src/bus/src/DebounceLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Coroutine\Coroutine;
use Hypervel\Queue\Attributes\DebounceFor;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Str;
use Hypervel\Support\Traits\ReadsClassAttributes;
use Throwable;

class DebounceLock
{
use ReadsQueueAttributes;
use ReadsClassAttributes;

/**
* Create a new debounce lock manager instance.
Expand Down
4 changes: 2 additions & 2 deletions src/bus/src/Dispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
use Hypervel\Queue\Attributes\Connection;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\Attributes\Queue as QueueAttribute;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Queue\InteractsWithQueue;
use Hypervel\Queue\Jobs\SyncJob;
use Hypervel\Support\Collection;
use Hypervel\Support\Queue\Concerns\ResolvesQueueRoutes;
use Hypervel\Support\Traits\ReadsClassAttributes;
use RuntimeException;

class Dispatcher implements QueueingDispatcher
{
use ReadsQueueAttributes;
use ReadsClassAttributes;
use ResolvesQueueRoutes;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/bus/src/UniqueLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
use Hypervel\Cache\Repository;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Queue\Attributes\UniqueFor;
use Hypervel\Support\Traits\ReadsClassAttributes;

class UniqueLock
{
use ReadsQueueAttributes;
use ReadsClassAttributes;

/**
* Create a new unique lock manager instance.
Expand Down
57 changes: 22 additions & 35 deletions src/cache/src/Redis/Operations/AllTag/Flush.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,51 +30,38 @@ public function execute(array $tagIds): void
$prefix = $this->context->prefix();
$isCluster = $this->context->isCluster();

$tagKeys = array_map(fn (string $tagId): string => $prefix . $tagId, $tagIds);
$entries = $this->getEntries->execute($tagIds);

foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) {
$members = array_values($chunk->all());

if ($members === []) {
continue;
}

$keys = array_map(fn (string $key): string => $prefix . $key, $members);

$this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKeys, $isCluster): void {
if ($isCluster) {
// Remove memberships first so a racing writer can leave only
// orphaned metadata for Prune, never an unindexed live value.
foreach ($tagKeys as $tagKey) {
// Flush one tag at a time so each chunk is only removed from the tag it was scanned
// from. Removing every chunk from every requested tag multiplies the work by the tag
// count and blocks Redis when many tags are flushed together.
foreach ($tagIds as $tagId) {
$tagKey = $prefix . $tagId;

foreach ($this->getEntries->execute([$tagId])->chunk(self::CHUNK_SIZE) as $chunk) {
$members = array_values($chunk->all());
$keys = array_map(fn (string $key): string => $prefix . $key, $members);

$this->context->withConnection(function (RedisConnection $connection) use ($keys, $members, $tagKey, $isCluster): void {
if ($isCluster) {
// Remove memberships first so a racing writer can leave only
// orphaned metadata for Prune, never an unindexed live value.
$connection->zrem($tagKey, ...$members);
$connection->del(...$keys);
} else {
$connection->evalWithShaCache($this->flushChunkScript(), [$tagKey, ...$keys], $members);
}

$connection->del(...$keys);
} else {
$connection->evalWithShaCache(
$this->flushChunkScript(),
[...$tagKeys, ...$keys],
[count($tagKeys), ...$members],
);
}
});
});
}
}
}

/**
* Atomically delete scanned values and memberships, preserving concurrent additions.
* Atomically delete scanned values and their memberships, preserving concurrent additions.
*/
protected function flushChunkScript(): string
{
return <<<'LUA'
local tagCount = tonumber(ARGV[1])

redis.call('DEL', unpack(KEYS, tagCount + 1, #KEYS))

for i = 1, tagCount do
redis.call('ZREM', KEYS[i], unpack(ARGV, 2, #ARGV))
end
redis.call('DEL', unpack(KEYS, 2, #KEYS))
redis.call('ZREM', KEYS[1], unpack(ARGV))

return 1
LUA;
Expand Down
45 changes: 20 additions & 25 deletions src/cache/src/Redis/Operations/AnyTag/Flush.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,27 @@ public function execute(array $tags): bool
}

$tags = array_map(strval(...), $tags);
$tagKeys = array_map($this->context->tagHashKey(...), $tags);
$isCluster = $this->context->isCluster();

$buffer = [];

// Flush one tag at a time so each chunk is only removed from the tag it was scanned
// from. Removing every chunk from every requested tag multiplies the work by the tag
// count and blocks Redis when many tags are flushed together.
foreach ($tags as $tag) {
$tagKey = $this->context->tagHashKey($tag);
$buffer = [];

foreach ($this->getTaggedKeys->execute($tag) as $key) {
$buffer[$key] = true;
$buffer[] = $key;

if (count($buffer) === self::CHUNK_SIZE) {
$this->flushChunk(array_keys($buffer), $tagKeys, $isCluster);
$this->flushChunk($buffer, $tagKey, $isCluster);
$buffer = [];
}
}
}

if ($buffer !== []) {
$this->flushChunk(array_keys($buffer), $tagKeys, $isCluster);
if ($buffer !== []) {
$this->flushChunk($buffer, $tagKey, $isCluster);
}
}

$this->context->withConnection(
Expand All @@ -71,23 +74,19 @@ public function execute(array $tags): bool
* Delete a bounded chunk of values and their scanned memberships.
*
* @param array<int, int|string> $keys Cache keys without the store prefix
* @param array<int, string> $tagKeys
*/
private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void
private function flushChunk(array $keys, string $tagKey, bool $isCluster): void
{
$keys = array_map(strval(...), $keys);
$prefix = $this->context->prefix();
$reverseIndexKeys = array_map($this->context->reverseIndexKey(...), $keys);
$valueKeys = array_map(fn (string $key): string => $prefix . $key, $keys);

$this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKeys, $reverseIndexKeys, $valueKeys, $isCluster): void {
$this->context->withConnection(function (RedisConnection $connection) use ($keys, $tagKey, $reverseIndexKeys, $valueKeys, $isCluster): void {
if ($isCluster) {
// Remove memberships before values so racing writers leave only
// orphaned metadata for Prune, never unindexed live values.
foreach ($tagKeys as $tagKey) {
$connection->hdel($tagKey, ...$keys);
}

$connection->hdel($tagKey, ...$keys);
$connection->del(...$reverseIndexKeys);
$connection->unlink(...$valueKeys);

Expand All @@ -96,8 +95,8 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void

$connection->evalWithShaCache(
$this->flushChunkScript(),
[...$tagKeys, ...$reverseIndexKeys, ...$valueKeys],
[count($tagKeys), count($keys), ...$keys],
[$tagKey, ...$reverseIndexKeys, ...$valueKeys],
[count($keys), ...$keys],
);
});
}
Expand All @@ -108,15 +107,11 @@ private function flushChunk(array $keys, array $tagKeys, bool $isCluster): void
protected function flushChunkScript(): string
{
return <<<'LUA'
local tagCount = tonumber(ARGV[1])
local keyCount = tonumber(ARGV[2])

for i = 1, tagCount do
redis.call('HDEL', KEYS[i], unpack(ARGV, 3, #ARGV))
end
local keyCount = tonumber(ARGV[1])

redis.call('DEL', unpack(KEYS, tagCount + 1, tagCount + keyCount))
redis.call('UNLINK', unpack(KEYS, tagCount + keyCount + 1, #KEYS))
redis.call('HDEL', KEYS[1], unpack(ARGV, 2, #ARGV))
redis.call('DEL', unpack(KEYS, 2, keyCount + 1))
redis.call('UNLINK', unpack(KEYS, keyCount + 2, #KEYS))

return 1
LUA;
Expand Down
10 changes: 6 additions & 4 deletions src/cache/src/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,9 @@ public function integer(UnitEnum|string $key, callable|int|null $default = null)
return $value;
}

if (filter_var($value, FILTER_VALIDATE_INT) !== false) {
return (int) $value;
// Return the filtered value: a numeric Stringable passes the filter, but casting it to int fails.
if (($integer = filter_var($value, FILTER_VALIDATE_INT)) !== false) {
return $integer;
}

throw new InvalidArgumentException(
Expand All @@ -259,8 +260,9 @@ public function float(UnitEnum|string $key, callable|float|null $default = null)
return $value;
}

if (filter_var($value, FILTER_VALIDATE_FLOAT) !== false) {
return (float) $value;
// Return the filtered value: a numeric Stringable passes the filter, but casting it to float fails.
if (($float = filter_var($value, FILTER_VALIDATE_FLOAT)) !== false) {
return $float;
}

throw new InvalidArgumentException(
Expand Down
7 changes: 7 additions & 0 deletions src/console/src/Commands/ScheduleInterruptCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Hypervel\Console\Commands;

use Hypervel\Console\Command;
use Hypervel\Console\Scheduling\Schedule;
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Support\Facades\Date;
use Symfony\Component\Console\Attribute\AsCommand;
Expand Down Expand Up @@ -36,6 +37,12 @@ public function __construct(
*/
public function handle(): int
{
if (! Schedule::$interruptible) {
$this->components->error('Schedule interrupting is currently disabled.');

return self::FAILURE;
}

$this->cache->forever('hypervel:schedule:interrupt', Date::now()->getTimestampMs());

$this->components->info('Broadcasting schedule interrupt signal.');
Expand Down
48 changes: 43 additions & 5 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -572,14 +572,16 @@ protected function run(string $query, array $bindings, Closure $callback): mixed
$beforeExecutingCallback($query, $bindings, $this);
}

$this->reconnectIfMissingConnection();

$start = hrtime(true) / 1e9;

// Here we will run this query. If an exception occurs we'll determine if it was
// caused by a connection that has been lost. If that is the cause, we'll try
// to re-establish connection and re-run the query with a fresh connection.
try {
if (! $this->hasDriverResources()) {
$this->reconnectForQuery($query, $bindings);
}

try {
$result = $this->runQueryCallback($query, $bindings, $callback);
} catch (QueryException $e) {
Expand Down Expand Up @@ -648,8 +650,6 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac
$beforeExecutingCallback($query, $bindings, $this);
}

$this->reconnectIfMissingConnection();

$start = hrtime(true) / 1e9;
$hasYielded = false;

Expand Down Expand Up @@ -680,6 +680,10 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac
};

try {
if (! $this->hasDriverResources()) {
$this->reconnectForQuery($query, $bindings);
}

try {
yield from $execute($query, $bindings);
} catch (QueryException $exception) {
Expand Down Expand Up @@ -795,6 +799,14 @@ protected function isDataTypeError(Exception $exception): bool
return false;
}

/**
* Determine if the given exception was thrown by the database driver.
*/
protected function isDriverError(Exception $exception): bool
{
return false;
}

/**
* Extract the index and columns that caused a unique constraint violation.
*
Expand Down Expand Up @@ -933,7 +945,7 @@ protected function tryAgainIfCausedByLostConnection(QueryException $e, string $q
$this->reconnecting = true;

try {
$this->reconnect();
$this->reconnectForQuery($query, $bindings);
} finally {
$this->reconnecting = false;
}
Expand Down Expand Up @@ -968,6 +980,32 @@ public function reconnectIfMissingConnection(): void
}
}

/**
* Reconnect for the given query, reporting driver failures as query failures.
*
* Laravel reconnects lazily, so a failed connection surfaces from the query callback
* as a QueryException. Driver resources are established eagerly here instead.
*
* @throws QueryException
*/
protected function reconnectForQuery(string $query, array $bindings): void
{
try {
$this->reconnect();
} catch (Exception $exception) {
// QueryException extends PDOException, so check it before the driver classification.
if ($exception instanceof QueryException
|| Coroutine::causedByCancellation($exception)
|| ! $this->isDriverError($exception)) {
throw $exception;
}

++$this->errorCount;

throw $this->newQueryException($query, $bindings, $exception);
}
}

/**
* Refresh the driver resources from a fresh connection.
*
Expand Down
Loading
Loading