Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
803 changes: 803 additions & 0 deletions docs/plans/2026-07-03-swoole-store-interval-cache-refresh.md

Large diffs are not rendered by default.

1,403 changes: 1,403 additions & 0 deletions docs/plans/2026-07-03-swoole-store-row-concurrency-and-locks.md

Large diffs are not rendered by default.

1,128 changes: 1,128 additions & 0 deletions docs/plans/2026-07-04-swoole-store-review-follow-up-hardening.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/boost/docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,14 @@ use Hypervel\Cache\SwooleStore;
'eviction_policy' => SwooleStore::EVICTION_POLICY_LRU,
'eviction_proportion' => 0.05,
'eviction_interval' => 10000, // milliseconds
'interval_refresh_interval' => 1000, // milliseconds
],
```

The available eviction policy constants are `EVICTION_POLICY_LRU`, `EVICTION_POLICY_LFU`, `EVICTION_POLICY_TTL`, and `EVICTION_POLICY_NOEVICTION`.

The `eviction_interval` option controls how often stale records are pruned and memory-pressure eviction runs. The `interval_refresh_interval` option controls how often registered interval caches are checked and refreshed by the elected Swoole worker.

The table itself is configured in the `swoole_tables` section of your `config/cache.php` file:

```php
Expand Down Expand Up @@ -765,7 +768,7 @@ You may clear all atomic locks in the cache using the `flushLocks` method:
Cache::flushLocks();
```

The `flushLocks` method is supported by the `redis`, `database`, `file`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`.
The `flushLocks` method is supported by the `redis`, `database`, `file`, `swoole`, and `array` cache drivers. Redis, database, and file stores only support flushing locks when lock storage is configured separately from regular cache storage. If lock storage is shared with regular cache storage, Hypervel will throw a `RuntimeException`. If a store does not support flushing locks, Hypervel will throw a `BadMethodCallException`.

> [!WARNING]
> The `flushLocks` method removes every lock in the lock store, regardless of which application or process owns the lock. Use it carefully in shared environments.
Expand Down
2 changes: 1 addition & 1 deletion src/boost/docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,7 @@ class SendShipmentNotification implements ShouldQueue, ShouldBeEncrypted
### Unique Event Listeners

> [!WARNING]
> Unique listeners require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). The `redis`, `database`, `file`, and `array` cache drivers support atomic locks.
> Unique listeners require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). The `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks.

Sometimes, you may want to ensure that only one instance of a specific listener is on the queue at any point in time. You may do so by implementing the `ShouldBeUnique` interface on your listener class:

Expand Down
4 changes: 3 additions & 1 deletion src/boost/docs/octane.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ Cache::store('octane')->put('framework', 'Laravel', 30);
<a name="cache-intervals"></a>
### Cache Intervals

In addition to the typical methods provided by Laravel's cache system, the Octane cache driver features interval based caches. These caches are automatically refreshed at the specified interval and should be registered within the `boot` method of one of your application's service providers. For example, the following cache will be refreshed every five seconds:
In addition to the typical methods provided by Hypervel's cache system, the Octane cache driver features interval based caches. These caches are automatically refreshed at the specified interval and should be registered within the `boot` method of one of your application's service providers. For example, the following cache will be refreshed every five seconds:

```php
use Hypervel\Support\Str;
Expand All @@ -564,6 +564,8 @@ Cache::store('octane')->interval('random', function () {
}, seconds: 5);
```

Interval cache metadata is shared through Swoole tables and refreshed by the Swoole manager process. The first manager refresh tick seeds the shared cache value, so workers that did not register the interval locally may briefly read `null` until that first tick runs. The registering worker can resolve the interval immediately through its local fallback.

<a name="tables"></a>
## Tables

Expand Down
4 changes: 2 additions & 2 deletions src/boost/docs/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ If a job receives a collection or array of Eloquent models instead of a single m
### Unique Jobs

> [!WARNING]
> Unique jobs require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, and `array` cache drivers support atomic locks.
> Unique jobs require a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks.

> [!WARNING]
> Unique job constraints do not apply to jobs within batches.
Expand Down Expand Up @@ -749,7 +749,7 @@ public function middleware(): array
```

> [!WARNING]
> The `WithoutOverlapping` middleware requires a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, and `array` cache drivers support atomic locks.
> The `WithoutOverlapping` middleware requires a cache driver that supports [locks](/docs/{{version}}/cache#atomic-locks). Currently, the `redis`, `database`, `file`, `swoole`, and `array` cache drivers support atomic locks.

<a name="sharing-lock-keys"></a>
#### Sharing Lock Keys Across Job Classes
Expand Down
2 changes: 1 addition & 1 deletion src/boost/docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ For more information on Hypervel's cache methods, consult the [cache documentati
## Session Blocking

> [!WARNING]
> To utilize session blocking, your application must be using a cache driver that supports [atomic locks](/docs/{{version}}/cache#atomic-locks). Currently, those cache drivers include the `redis`, `database`, `file`, and `array` drivers. In addition, you may not use the `cookie` session driver.
> To utilize session blocking, your application must be using a cache driver that supports [atomic locks](/docs/{{version}}/cache#atomic-locks). Currently, those cache drivers include the `redis`, `database`, `file`, `swoole`, and `array` drivers. In addition, you may not use the `cookie` session driver.

By default, Hypervel allows requests using the same session to execute concurrently. So, for example, if you use a JavaScript HTTP library to make two HTTP requests to your application, they will both execute at the same time. For many applications, this is not a problem; however, session data loss can occur in a small subset of applications that make concurrent requests to two different application endpoints which both write data to the session.

Expand Down
4 changes: 2 additions & 2 deletions src/cache/src/CacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,9 @@ protected function getSession(): Session
*/
protected function createSwooleDriver(array $config): Repository
{
$cacheTable = $this->app->make(SwooleTableManager::class)->get($config['table']);
$tableState = $this->app->make(SwooleTableManager::class)->get($config['table']);
$store = new SwooleStore(
$cacheTable,
$tableState,
$config['memory_limit_buffer'] ?? 0.05,
$config['eviction_policy'] ?? SwooleStore::EVICTION_POLICY_LRU,
$config['eviction_proportion'] ?? 0.05
Expand Down
8 changes: 4 additions & 4 deletions src/cache/src/CacheServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
use Hypervel\Cache\Console\PruneDbExpiredCommand;
use Hypervel\Cache\Console\PruneStaleTagsCommand;
use Hypervel\Cache\Listeners\CreateSwooleTable;
use Hypervel\Cache\Listeners\CreateTimer;
use Hypervel\Cache\Listeners\CreateSwooleTimers;
use Hypervel\Cache\Redis\Console\BenchmarkCommand;
use Hypervel\Cache\Redis\Console\DoctorCommand;
use Hypervel\Core\Events\AfterWorkerStart;
use Hypervel\Core\Events\BeforeServerStart;
use Hypervel\Core\Events\OnManagerStart;
use Hypervel\Support\ServiceProvider;

class CacheServiceProvider extends ServiceProvider
Expand Down Expand Up @@ -56,8 +56,8 @@ public function boot(): void
$this->app->make(CreateSwooleTable::class)->handle($event);
});

$events->listen(OnManagerStart::class, function (OnManagerStart $event) {
$this->app->make(CreateTimer::class)->handle($event);
$events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event) {
$this->app->make(CreateSwooleTimers::class)->handle($event);
});
}
}
9 changes: 8 additions & 1 deletion src/cache/src/DatabaseStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ public function put(string $key, mixed $value, int $seconds): bool
*/
public function putMany(array $values, int $seconds): bool
{
if (empty($values)) {
return true;
}

$serializedValues = [];

$expiration = $this->getTime() + $seconds;
Expand All @@ -175,7 +179,10 @@ public function putMany(array $values, int $seconds): bool
];
}

return $this->table()->upsert($serializedValues, 'key') > 0;
// Identical-value upserts can report zero affected rows; failures surface as database exceptions.
$this->table()->upsert($serializedValues, 'key');

return true;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/cache/src/LimitedMaxHeap.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,31 @@

namespace Hypervel\Cache;

use InvalidArgumentException;
use SplMaxHeap;

class LimitedMaxHeap extends SplMaxHeap
{
public function __construct(protected int $limit)
{
if ($limit < 1) {
throw new InvalidArgumentException('Heap limit must be at least 1.');
}
}

public function insert(mixed $value): true
{
if ($this->count() < $this->limit) {
parent::insert($value);

return true;
}

if ($this->compare($value, $this->top()) < 0) {
$this->extract();
parent::insert($value);
}

parent::insert($value);

return true;
}
}
57 changes: 57 additions & 0 deletions src/cache/src/Listeners/CreateSwooleTimers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache\Listeners;

use Hypervel\Cache\SwooleStore;
use Hypervel\Cache\SwooleTimer;
use Hypervel\Contracts\Container\Container;
use Hypervel\Core\Events\AfterWorkerStart;

class CreateSwooleTimers extends BaseListener
{
public function __construct(Container $container, protected SwooleTimer $timer)
{
parent::__construct($container);
}

/**
* Create timers for all configured Swoole cache stores.
*/
public function handle(AfterWorkerStart $event): void
{
if (! $this->shouldRegisterTimers($event)) {
return;
}

$this->swooleStores()->each(function (array $config, string $name) {
$this->timer->tick(
$config['eviction_interval'] ?? 10000,
fn () => $this->store($name)->evictRecords(),
);

$this->timer->tick(
$config['interval_refresh_interval'] ?? 1000,
fn () => $this->store($name)->refreshIntervalCaches(),
);
});
}

/**
* Determine if this worker should own Swoole cache timers.
*/
protected function shouldRegisterTimers(AfterWorkerStart $event): bool
{
return $event->workerId === 0 && ! $event->server->taskworker;
}

/**
* Get a Swoole cache store.
*/
protected function store(string $name): SwooleStore
{
/** @var SwooleStore */
return $this->container->make('cache')->store($name)->getStore();
}
}
27 changes: 0 additions & 27 deletions src/cache/src/Listeners/CreateTimer.php

This file was deleted.

9 changes: 4 additions & 5 deletions src/cache/src/RetrievesMultipleKeys.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ public function many(array $keys): array
*/
public function putMany(array $values, int $seconds): bool
{
$manyResult = null;
$result = true;

foreach ($values as $key => $value) {
$result = $this->put((string) $key, $value, $seconds);

$manyResult = is_null($manyResult) ? $result : $result && $manyResult;
// Call put() first so every key is attempted even after an earlier write fails.
$result = $this->put((string) $key, $value, $seconds) && $result;
}

return $manyResult ?: false;
return $result;
}
}
8 changes: 4 additions & 4 deletions src/cache/src/StackStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ public function put(string $key, mixed $value, int $seconds): bool

public function putMany(array $values, int $seconds): bool
{
$result = true;

foreach ($values as $key => $value) {
if (! $this->put($key, $value, $seconds)) {
return false;
}
$result = $this->put((string) $key, $value, $seconds) && $result;
}

return true;
return $result;
}

public function increment(string $key, int $value = 1): bool|int
Expand Down
85 changes: 85 additions & 0 deletions src/cache/src/SwooleLock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache;

use Hypervel\Contracts\Cache\RefreshableLock;
use InvalidArgumentException;

class SwooleLock extends Lock implements RefreshableLock
{
/**
* Create a new lock instance.
*/
public function __construct(
protected SwooleStore $store,
string $name,
int $seconds,
?string $owner = null,
) {
parent::__construct($name, $seconds, $owner);
}

/**
* Attempt to acquire the lock.
*/
public function acquire(): bool
{
return $this->store->acquireLock($this->name, $this->owner, $this->seconds);
}

/**
* Release the lock.
*/
public function release(): bool
{
return $this->store->releaseLock($this->name, $this->owner);
}

/**
* Release this lock in disregard of ownership.
*/
public function forceRelease(): void
{
$this->store->forceReleaseLock($this->name);
}

/**
* Return the owner value written into the driver for this lock.
*/
protected function getCurrentOwner(): ?string
{
return $this->store->getLockOwner($this->name);
}

/**
* Refresh the lock's TTL if still owned by this process.
*
* @throws InvalidArgumentException If an explicit non-positive TTL is provided
*/
public function refresh(?int $seconds = null): bool
{
if ($seconds === null && $this->seconds <= 0) {
return true;
}

$seconds ??= $this->seconds;

if ($seconds <= 0) {
throw new InvalidArgumentException(
'Refresh requires a positive TTL. For a permanent lock, acquire it with seconds=0.'
);
}

return $this->store->refreshLock($this->name, $this->owner, $seconds);
}

/**
* Get the number of seconds until the lock expires.
*/
public function getRemainingLifetime(): ?float
{
return $this->store->getLockRemainingLifetime($this->name);
}
}
Loading