Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@
"mockery/mockery": "^1.6.15",
"opis/json-schema": "^2.6.0",
"pda/pheanstalk": "^8.0.2",
"phpstan/phpstan": "^2.2.11",
"phpstan/phpstan": "^2.2.14",
"phpunit/phpunit": "^13.0.3",
"pusher/pusher-php-server": "^7.2",
"resend/resend-php": "^1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/cache/src/AnyModeTaggedCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ public function forget(UnitEnum|string $key): bool
*
* @throws BadMethodCallException always - tags are for writing and flushing only
*/
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $ttl): bool
{
throw new BadMethodCallException(
'Cannot touch items via tags in any mode. Re-put the item through tags() to change '
Expand Down
15 changes: 6 additions & 9 deletions src/cache/src/Redis/AllTaggedCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,24 +206,21 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $
}

/**
* Set the expiration of a cached item; null TTL will retain the item forever.
* Set the expiration of a cached item.
*/
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $ttl): bool
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
$value = $this->getRaw($key);

if (is_null($value)) {
return false;
}
$seconds = $this->getSeconds($ttl);

if (is_null($ttl)) {
return $this->forever($key, $value);
if ($seconds <= 0) {
return $this->forget($key);
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment on lines +217 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Tagged memberships remain orphaned

When an all-mode Redis cache item is touched with a zero, negative, or past expiration, this branch calls the inherited forget(). That deletes only the namespaced value and leaves its tag ZSET memberships behind. Finite memberships remain stale until their original expiration, while forever memberships use a score of -1 and remain until a global orphan-pruning pass. This can accumulate stale tag entries and retain Redis memory, so this path must remove both the value and its all-mode memberships.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the existing all-mode deletion contract. A nonpositive touch() delegates to forget(), just as nonpositive put() and putMany() do. The value is deleted immediately; tag memberships are cleaned up later.

The retained metadata does consume space until a tag flush or pruning pass. cache:prune-stale-tags checks for missing values regardless of their expiration score, so it also removes orphaned memberships from forever entries. The cache documentation explains this behavior and recommends scheduling pruning.

We’re keeping that shared cleanup model. Adding eager cleanup only to touch() would make it inconsistent with the other deletion paths and require additional handling for concurrent writes across Redis Cluster slots.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. Since touch() intentionally follows the same nonpositive-expiration path as put() and putMany(), eager membership cleanup here would be inconsistent with the existing all-mode deletion contract. The scheduled prune path checks cache-key existence independently of the sorted-set score, so it does remove orphaned forever memberships as described. I’m withdrawing this finding.

}

return $this->store->allTagOps()->touch()->execute(
$this->itemKey($key),
$this->getSeconds($ttl),
$seconds,
$this->tags->tagIds()
);
}
Expand Down
20 changes: 10 additions & 10 deletions src/cache/src/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -765,20 +765,19 @@ public function flexibleNullable(UnitEnum|string $key, array $ttl, mixed $callba
}

/**
* Set the expiration of a cached item; null TTL will retain the item forever.
* Set the expiration of a cached item.
*/
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $ttl): bool
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
$value = $this->getRaw($key);

if (is_null($value)) {
return false;
$seconds = $this->getSeconds($ttl);

if ($seconds <= 0) {
return $this->forget($key);
}

return is_null($ttl)
? $this->forever($key, $value)
: $this->store->touch($this->itemKey($key), $this->getSeconds($ttl));
return $this->store->touch($this->itemKey($key), $seconds);
}

/**
Expand Down Expand Up @@ -1372,15 +1371,16 @@ public function manyRaw(array $keys): array
$this->events?->hasListeners(CacheMissed::class)
|| $this->events?->hasListeners(CacheHit::class)
) {
// PHP stores numeric-string keys as integers; event keys must be strings.
foreach ($result as $key => $value) {
// Keep the per-class checks live: an earlier hit listener may register
// a miss listener, or vice versa, before a later result is dispatched.
if (is_null($value)) {
if ($this->events?->hasListeners(CacheMissed::class)) {
$this->event(new CacheMissed($this->getName(), $key));
$this->event(new CacheMissed($this->getName(), (string) $key));
}
} elseif ($this->events?->hasListeners(CacheHit::class)) {
$this->event(new CacheHit($this->getName(), $key, NullSentinel::unwrap($value)));
$this->event(new CacheHit($this->getName(), (string) $key, NullSentinel::unwrap($value)));
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/collections/src/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ public function duplicates(callable|string|null $callback = null, bool $strict =
if ($uniqueItems->isNotEmpty() && $compare($value, $uniqueItems->first())) {
$uniqueItems->shift();
} else {
// @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess)
$duplicates[$key] = $value;
}
}
Expand Down Expand Up @@ -1468,6 +1469,7 @@ public function chunkWhile(callable $callback): static
$chunk = $this->newInstance();
}

// @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess)
$chunk[$key] = $value;
}

Expand Down Expand Up @@ -1795,7 +1797,7 @@ public function zip(Arrayable|iterable ...$items): Collection
* @template TPadValue
*
* @param TPadValue $value
* @return static<int, TPadValue|TValue>
* @return static<int|TKey, TPadValue|TValue>
*/
public function pad(int $size, mixed $value): Collection
{
Expand Down
2 changes: 1 addition & 1 deletion src/collections/src/Enumerable.php
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ public function values(): static;
* @template TPadValue
*
* @param TPadValue $value
* @return static<int, TPadValue|TValue>
* @return static<int|TKey, TPadValue|TValue>
*/
public function pad(int $size, mixed $value): Collection|static;

Expand Down
4 changes: 3 additions & 1 deletion src/collections/src/LazyCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,7 @@ public function chunkWhile(callable $callback): static
$chunk = new Collection;

if ($iterator->valid()) {
// @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess)
$chunk[$iterator->key()] = $iterator->current();

$iterator->next();
Expand All @@ -1481,6 +1482,7 @@ public function chunkWhile(callable $callback): static
$chunk = new Collection;
}

// @phpstan-ignore offsetAssign.dimType (PHPStan 2.2.14 rejects template keys on ArrayAccess)
$chunk[$iterator->key()] = $iterator->current();

$iterator->next();
Expand Down Expand Up @@ -1807,7 +1809,7 @@ public function zip(Arrayable|iterable ...$items): static
* @template TPadValue
*
* @param TPadValue $value
* @return static<int, TPadValue|TValue>
* @return static<int|TKey, TPadValue|TValue>
*/
#[Override]
public function pad(int $size, mixed $value): static
Expand Down
3 changes: 3 additions & 0 deletions src/concurrency/src/ProcessDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Hypervel\Support\Defer\DeferredCallback;
use Hypervel\Support\Facades\Context;
use Laravel\SerializableClosure\SerializableClosure;
use Throwable;

use function Hypervel\Support\defer;

Expand All @@ -30,6 +31,8 @@ public function __construct(

/**
* Run the given tasks concurrently and return an array containing the results.
*
* @throws Throwable
*/
public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = null): array
{
Expand Down
8 changes: 6 additions & 2 deletions src/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ Console for Hypervel

[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/console)

Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Console

## Differences From Laravel

`schedule:run` is a long-running process by default and replaces `schedule:work`. Use `schedule:run --once` in cron entries. See the [scheduling documentation](https://hypervel.org/docs/scheduling#running-the-scheduler).

Scheduled tasks do not support `user()`. Run the scheduler as the required OS user, or use `exec()` with an explicit command to run an individual task as another user.

`schedule:list --timezone` converts next-due timestamps but leaves cron expressions in their real evaluation timezone. Laravel's display-only expression converter cannot faithfully handle ranges, special cron syntax, month boundaries, or daylight-saving transitions. JSON output includes `expression_timezone`, and CLI output labels it when it differs from the requested display timezone.

Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Console
1 change: 1 addition & 0 deletions src/console/src/ConsoleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function register(): void
$this->commands([
ScheduleClearCacheCommand::class,
ScheduleListCommand::class,
// REMOVED: ScheduleWorkCommand; schedule:run already runs continuously in coroutines.
ScheduleRunCommand::class,
ScheduleInterruptCommand::class,
SchedulePauseCommand::class,
Expand Down
2 changes: 2 additions & 0 deletions src/console/src/Scheduling/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ public function callAfterCallbacks(Container $container): void
}
}

// REMOVED: buildCommand(); Artisan tasks run in-process, and exec() uses Symfony Process directly.

/**
* Determine if the given event should run based on the Cron expression.
*/
Expand Down
15 changes: 1 addition & 14 deletions src/console/src/Scheduling/ManagesAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ trait ManagesAttributes
*/
public DateTimeZone|string|null $timezone = null;

/**
* The user the command should run as.
*/
public ?string $user = null;

/**
* The list of environments the command should run under.
*/
Expand Down Expand Up @@ -89,15 +84,7 @@ trait ManagesAttributes
*/
public array $attributes = [];

/**
* Set which user the command should run as.
*/
public function user(string $user): static
{
$this->user = $user;

return $this;
}
// REMOVED: Laravel's user() / $user; coroutine tasks share the scheduler's OS user.

/**
* Limit the environments the command should run in.
Expand Down
4 changes: 0 additions & 4 deletions src/console/src/Scheduling/PendingEventAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,6 @@ public function mergeAttributes(Event $event): void
$event->timezone($this->timezone);
}

if ($this->user !== null) {
$event->user = $this->user;
}

if (! empty($this->environments)) {
$event->environments($this->environments);
}
Expand Down
33 changes: 11 additions & 22 deletions src/console/src/Scheduling/Schedule.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class Schedule
/**
* All of the events on the schedule.
*
* @var array Event[]
* @var list<Event>
*/
protected array $events = [];

Expand Down Expand Up @@ -111,18 +111,10 @@ class Schedule
* Create a new schedule instance.
*
* @param null|DateTimeZone|string $timezone the timezone the date should be evaluated on
*
* @throws RuntimeException
*/
public function __construct(
protected DateTimeZone|string|null $timezone = null
) {
if (! class_exists(Container::class)) {
throw new RuntimeException(
'A container implementation is required to use the scheduler. Please install the hypervel/container package.'
);
}

$container = Container::getInstance();

$this->eventMutex = $container->bound(EventMutex::class)
Expand Down Expand Up @@ -231,12 +223,6 @@ function () use ($job, $queue, $connection) {
protected function dispatchToQueue(object $job, ?string $queue, ?string $connection): void
{
if ($job instanceof Closure) {
if (! class_exists(CallQueuedClosure::class)) {
throw new RuntimeException(
'To enable support for closure jobs, please install the illuminate/queue package.'
);
}

$job = CallQueuedClosure::create($job);
}

Expand Down Expand Up @@ -324,16 +310,19 @@ public function group(Closure $events): void
*/
protected function mergePendingAttributes(Event $event): void
{
if (! empty($this->groupStack)) {
$group = end($this->groupStack);

$group->mergeAttributes($event);
}

if (isset($this->attributes)) {
// Pending attributes already inherit the current group's callbacks.
$this->attributes->mergeAttributes($event);

$this->attributes = null;

return;
}

if (! empty($this->groupStack)) {
$group = end($this->groupStack);

$group->mergeAttributes($event);
}
}

Expand Down Expand Up @@ -412,7 +401,7 @@ public function dueEventsAt(Application $app, DateTimeInterface $time): Collecti
/**
* Get all of the events on the schedule.
*
* @return array Event[]
* @return list<Event>
*/
public function events(): array
{
Expand Down
4 changes: 2 additions & 2 deletions src/contracts/src/Cache/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,9 @@ public function searNullable(UnitEnum|string $key, Closure $callback): mixed;
public function rememberForeverNullable(UnitEnum|string $key, Closure $callback): mixed;

/**
* Set the expiration of a cached item; null TTL will retain the item forever.
* Set the expiration of a cached item.
*/
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl = null): bool;
public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $ttl): bool;

/**
* Remove an item from the cache.
Expand Down
2 changes: 1 addition & 1 deletion src/database/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.24",
"phpstan/phpstan": "^2.2.11"
"phpstan/phpstan": "^2.2.14"
},
"suggest": {
"fakerphp/faker": "Required to use Eloquent model factories (^1.24)."
Expand Down
4 changes: 3 additions & 1 deletion src/database/src/Eloquent/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -803,9 +803,11 @@ public function keys(): BaseCollection
}

/**
* Pad collection to the specified length with a value.
*
* @template TPadValue
*
* @return \Hypervel\Support\Collection<int, TModel|TPadValue>
* @return \Hypervel\Support\Collection<int|TKey, TModel|TPadValue>
*/
#[Override]
public function pad(int $size, mixed $value): BaseCollection
Expand Down
4 changes: 3 additions & 1 deletion src/docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ You may provide an integer number of seconds, a `DateInterval`, or a `DateTimeIn
Cache::touch('key', now()->plus(hours: 2));
```

Providing a zero or negative number of seconds, or an expiration time in the past, removes the item from the cache.

<a name="storing-items-forever"></a>
#### Storing Items Forever

Expand Down Expand Up @@ -726,7 +728,7 @@ Because tags are invalidation indexes in `any` mode, flushing any one tag remove
Cache::tags(['user:42'])->flush();
```

Tag membership is synchronized by tagged writes. Plain `Cache::forget($key)` removes any-mode tag membership for that key, and a finite `Cache::touch($key, $ttl)` keeps the key and tag metadata TTLs in sync. Plain `put`, plain `forever`, and `touch($key, null)` are plain rewrites; they do not add tags or refresh tag metadata for an already-tagged value. To change a tagged value's TTL or tags, write it again through `tags()`.
Tag membership is synchronized by tagged writes. Plain `Cache::forget($key)` removes any-mode tag membership for that key, and `Cache::touch($key, $ttl)` keeps the key and tag metadata TTLs in sync. Plain `put` and `forever` are plain rewrites; they do not add tags or refresh tag metadata for an already-tagged value. To change a tagged value's TTL or tags, write it again through `tags()`.

> [!WARNING]
> In `any` mode, attempting to retrieve, check, pull, forget, touch, or retrieve many cache items through a tagged cache will throw a `BadMethodCallException`. Use the direct `Cache::get`, `Cache::has`, `Cache::pull`, `Cache::forget`, `Cache::touch`, and `Cache::many` methods with the full cache key instead.
Expand Down
8 changes: 8 additions & 0 deletions src/docs/porting-from-laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- [Configuration](#configuration)
- [Other API Differences](#other-api-differences)
- [Scheduling](#scheduling)
- [Maintenance Mode](#maintenance-mode)
- [HTTP Client and Concurrency](#http-client-and-concurrency)
- [CSRF Protection](#csrf-protection)
- [Scout](#scout)
Expand Down Expand Up @@ -474,8 +475,15 @@ Many Laravel APIs have direct Hypervel equivalents under the `Hypervel` namespac

Add `--once` to cron entries that invoke `schedule:run`, or run `schedule:run` as a supervised process. For local development, use `schedule:run` in place of Laravel's `schedule:work`. See the [scheduling documentation](/docs/{{version}}/scheduling#running-the-scheduler).

Replace scheduled task `user()` calls by running the scheduler as the required OS user, or by using `exec()` with an explicit command to run that task as another user. See [Scheduling Shell Commands](/docs/{{version}}/scheduling#scheduling-shell-commands).

Scheduled Artisan commands share the scheduler process instead of starting a fresh process for each invocation. Use `exec('php artisan ...')` for commands that rely on process isolation. See [Scheduling Artisan Commands](/docs/{{version}}/scheduling#scheduling-artisan-commands).

<a name="maintenance-mode"></a>
### Maintenance Mode

Maintenance views prepared with `down --render` are served by running Hypervel workers. To serve a static page while Hypervel is unavailable during deployment, configure your reverse proxy or load balancer. See [Pre-Rendering the Maintenance Mode View](/docs/{{version}}/configuration#pre-rendering-the-maintenance-mode-view).

<a name="http-client-and-concurrency"></a>
### HTTP Client and Concurrency

Expand Down
Loading