Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3763d5c
Add limiter lease and lock hardening plan
binaryfire Jul 9, 2026
37997fb
Add limiter lease contracts
binaryfire Jul 9, 2026
a7196e4
Import support now helper explicitly
binaryfire Jul 9, 2026
393b118
Harden shared lock contracts
binaryfire Jul 9, 2026
b2c09ee
Harden Redis lock refresh semantics
binaryfire Jul 9, 2026
c175631
Harden array lock refresh semantics
binaryfire Jul 9, 2026
e224ab3
Harden database lock refresh semantics
binaryfire Jul 9, 2026
273e093
Make file cache locks refreshable
binaryfire Jul 9, 2026
c8a42eb
Align no-op lock refresh behavior
binaryfire Jul 9, 2026
d2e5323
Add Redis hash tag detection
binaryfire Jul 9, 2026
c756c31
Expose Redis funnel leases
binaryfire Jul 9, 2026
41b189b
Use unified timeout handling for Redis throttles
binaryfire Jul 9, 2026
0443064
Expose cache funnel leases
binaryfire Jul 9, 2026
6566e35
Hash-tag Redis queue storage keys
binaryfire Jul 9, 2026
fb941f6
Port database integration lock tests
binaryfire Jul 9, 2026
3397ed2
Document limiter leases and lock refresh behavior
binaryfire Jul 9, 2026
c3b4c4e
Share Redis integration connection helper
binaryfire Jul 9, 2026
f65f43c
Use raw Redis reads for permanent lease refresh
binaryfire Jul 9, 2026
c6c106a
Filter database lock lifetime reads by expiry
binaryfire Jul 9, 2026
8a5b47f
Prevent testbench route-file leaks
binaryfire Jul 9, 2026
b88edae
Remove no-op email validation test
binaryfire Jul 9, 2026
46d6a21
Document refresh failure handling
binaryfire Jul 9, 2026
15d69b3
Add void return types to touched tests
binaryfire Jul 9, 2026
2297029
Harden throwable-path test assertions
binaryfire Jul 9, 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
1,120 changes: 1,120 additions & 0 deletions docs/plans/2026-07-09-limiter-leases-and-lock-hardening.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

- Re-run the introduction benchmarks against Hypervel 0.4 before publishing externally. The benchmark tables currently preserve the 0.3 results so the comparison is not lost during the docs port, but Hypervel 0.4's decoupled runtime should have fresh measurements before those numbers are treated as current.

## Redis

- Audit transformed Redis command wrapper return types against serializer-configured phpredis connections. For example, `RedisConnection::callGet(): ?string` can receive unserialized non-string values from phpredis when a serializer is enabled under `strict_types`; check the other `call*` wrappers for the same mismatch and update signatures/tests to match real client behavior.

## Collections

## Horizon
Expand Down
53 changes: 47 additions & 6 deletions src/boost/docs/cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,14 @@ Cache::lock('foo', 10)->get(function () {
});
```

You may determine if a lock is currently held by any process using the `isLocked` method:

```php
if (Cache::lock('foo')->isLocked()) {
// The lock currently exists...
}
```

If the lock is not available at the moment you request it, you may instruct Hypervel to wait for a specified number of seconds. If the lock cannot be acquired within the specified time limit, a `Hypervel\Contracts\Cache\LockTimeoutException` will be thrown:

```php
Expand Down Expand Up @@ -763,17 +771,21 @@ Cache::lock('processing')->forceRelease();
<a name="refreshing-locks"></a>
### Refreshing Locks

The `redis`, `database`, and `array` cache lock drivers support atomic TTL refresh and remaining-lifetime inspection via the `Hypervel\Contracts\Cache\RefreshableLock` interface. This is useful for long-running work where you want to extend the lock as you go rather than acquiring a single conservative lock up front:
The `redis`, `database`, `file`, and `array` cache lock drivers support atomic TTL refresh and remaining-lifetime inspection via the `Hypervel\Contracts\Cache\RefreshableLock` interface. This is useful for long-running work where you want to extend the lock as you go rather than acquiring a single conservative lock up front:

```php
use RuntimeException;

$lock = Cache::lock('processing', 60);

if ($lock->get()) {
try {
foreach ($items as $item) {
$this->process($item);

$lock->refresh();
if (! $lock->refresh()) {
throw new RuntimeException('Lost the lock while processing.');
}
}
} finally {
$lock->release();
Expand All @@ -787,7 +799,9 @@ The `refresh` method is atomic. If the lock has expired or has been acquired by
$lock->refresh(120);
```

If the lock was acquired permanently by passing `0` seconds, calling `refresh` without arguments is a no-op that returns `true`. Calling `refresh` with a non-positive explicit TTL will throw an `InvalidArgumentException`.
Calling `refresh` without arguments re-applies the duration used when the lock was acquired. For native-expiry drivers such as Redis, file, and array, a lock acquired with `0` seconds is permanent, so `refresh()` verifies ownership and returns whether this process still owns the lock. Database locks cannot be truly permanent because the database has no native TTL cleanup; a lock acquired with `0` seconds uses the driver's default crash-safety timeout, and `refresh()` extends that timeout again.

Calling `refresh` with a non-positive explicit TTL will throw an `InvalidArgumentException`.

You may inspect the number of seconds remaining before a refreshable lock expires using the `getRemainingLifetime` method. This method returns `null` if the lock does not exist or has no expiration:

Expand All @@ -796,7 +810,7 @@ $remaining = $lock->getRemainingLifetime();
```

> [!NOTE]
> File locks do not support lock refreshing. If your code may receive different lock implementations, check that the lock is an instance of `Hypervel\Contracts\Cache\RefreshableLock` before calling `refresh` or `getRemainingLifetime`.
> If your code may receive different lock implementations, check that the lock is an instance of `Hypervel\Contracts\Cache\RefreshableLock` before calling `refresh` or `getRemainingLifetime`. Non-refreshable lock drivers throw a `RuntimeException` for these methods.

<a name="flushing-locks"></a>
### Flushing Locks
Expand Down Expand Up @@ -855,10 +869,37 @@ Cache::funnel('foo')

The `funnel` key identifies the resource being limited. The `limit` method defines the maximum concurrent executions. The `releaseAfter` method sets a safety timeout in seconds before an acquired slot is automatically released. The `block` method sets how many seconds to wait for an available slot.

If you prefer to handle the timeout via exceptions instead of providing a failure closure, you may omit the second closure. A `Hypervel\Cache\Limiters\LimiterTimeoutException` will be thrown if the lock cannot be acquired within the specified wait time:
For work that cannot fit inside a single callback, acquire a lease and release it explicitly later:

```php
use Hypervel\Contracts\Limiters\RefreshableLease;
use RuntimeException;

$lease = Cache::funnel('podcast-import')
->limit(3)
->releaseAfter(60)
->block(10)
->acquire();

try {
foreach ($chunks as $chunk) {
$this->import($chunk);

if ($lease instanceof RefreshableLease && ! $lease->refresh()) {
throw new RuntimeException('Lost the lease while processing.');
}
}
} finally {
$lease->release();
}
```

A lease holds one slot until `release` succeeds or the `releaseAfter` safety timeout reclaims the slot after a crash. Refreshable leases support the same `refresh` and `getRemainingLifetime` methods as refreshable locks. Store-agnostic code should check `instanceof RefreshableLease` before refreshing, because some lock-backed stores cannot refresh atomically.

If you prefer to handle the timeout via exceptions instead of providing a failure closure, you may omit the second closure. A `Hypervel\Contracts\Limiters\LimiterTimeoutException` will be thrown if the lock cannot be acquired within the specified wait time:

```php
use Hypervel\Cache\Limiters\LimiterTimeoutException;
use Hypervel\Contracts\Limiters\LimiterTimeoutException;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
Cache::funnel('foo')
Expand Down
6 changes: 4 additions & 2 deletions src/boost/docs/queues.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,21 @@ In order to use the `redis` queue driver, you should configure a Redis database
<a name="redis-cluster"></a>
##### Redis Cluster

If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/latest/operate/rs/databases/durability-ha/clustering), your queue names must contain a [key hash tag](https://redis.io/docs/latest/develop/using-commands/keyspace/#hashtags). This is required in order to ensure all of the Redis keys for a given queue are placed into the same hash slot:
If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/latest/operate/rs/databases/durability-ha/clustering), Hypervel automatically wraps queue storage keys in a [key hash tag](https://redis.io/docs/latest/develop/using-commands/keyspace/#hashtags) when the configured queue name does not already contain one. This keeps the queue, delayed, reserved, and notification keys on the same hash slot for the Redis queue driver's Lua scripts:

```php
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', '{default}'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
```

You may still include your own hash tag, such as `{mail}:high`, when you need several queue names to share a specific Redis Cluster slot. Hypervel leaves explicit hash tags unchanged.

<a name="blocking"></a>
##### Blocking

Expand Down
84 changes: 84 additions & 0 deletions src/boost/docs/redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
- [Holding a Pooled Connection](#holding-a-pooled-connection)
- [Pinned Connections](#pinned-connections)
- [Checking Cluster Connections](#checking-cluster-connections)
- [Concurrency Limiting](#concurrency-limiting)
- [Rate Limiting](#rate-limiting)
- [Deleting Keys by Pattern](#deleting-keys-by-pattern)
- [Transactions](#transactions)
- [Pipelining Commands](#pipelining-commands)
Expand Down Expand Up @@ -226,6 +228,8 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar

The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so the `database` option is ignored for clustered connections.

Hypervel automatically hash-tags Redis queue storage keys and Redis funnel slot keys when a clustered connection is used and the configured queue or funnel name does not already contain a valid Redis Cluster hash tag. This keeps all keys used by Hypervel's multi-key Lua scripts on the same hash slot. You may still provide your own hash tag, such as `{orders}:high`, when you need to control key placement.

<a name="sentinel"></a>
### Sentinel

Expand Down Expand Up @@ -395,6 +399,86 @@ if (Redis::connection('cache')->isCluster()) {
}
```

<a name="concurrency-limiting"></a>
#### Concurrency Limiting

The `Redis::funnel` method limits how many operations may run at the same time for a named resource:

```php
use Hypervel\Support\Facades\Redis;

Redis::funnel('reports')
->limit(3)
->releaseAfter(60)
->block(10)
->then(function () {
// One of three slots is held for this callback...
});
```

The `limit` method defines the number of slots. The `releaseAfter` method defines a crash-safety timeout in seconds, and `block` defines how long to wait for a free slot. If a slot cannot be acquired within the wait time and no failure callback is supplied, Hypervel throws a `Hypervel\Contracts\Limiters\LimiterTimeoutException`:

```php
use Hypervel\Contracts\Limiters\LimiterTimeoutException;

try {
Redis::funnel('reports')
->limit(3)
->releaseAfter(60)
->block(10)
->then(fn () => $this->buildReport());
} catch (LimiterTimeoutException $e) {
// Unable to acquire a slot...
}
```

For work that needs to hold a slot across several operations, acquire a lease and release it when the work is complete:

```php
use RuntimeException;

$lease = Redis::funnel('reports')
->limit(3)
->releaseAfter(60)
->block(10)
->acquire();

try {
foreach ($steps as $step) {
$this->runStep($step);

if (! $lease->refresh()) {
throw new RuntimeException('Lost the lease while processing.');
}
}
} finally {
$lease->release();
}
```

Redis funnel leases are refreshable. Calling `refresh()` extends the slot's `releaseAfter` timeout if the lease still owns it, while `release()` frees the slot immediately. The `owner()` method returns the lease owner token, and `getRemainingLifetime()` returns the number of seconds before the slot expires or `null` when the slot does not exist or has no expiry.

If the process exits without releasing the lease, Redis expires the slot after the `releaseAfter` timeout. A `releaseAfter(0)` lease is permanent: Redis will not expire the slot, `getRemainingLifetime()` returns `null`, and `refresh()` only verifies that the lease still owns the slot. Permanent leases must be released explicitly to reclaim capacity.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<a name="rate-limiting"></a>
#### Rate Limiting

The `Redis::throttle` method limits how many times an operation may run during a time window:

```php
Redis::throttle('api')
->allow(100)
->every(60)
->block(5)
->then(function () {
// At most 100 executions per 60 seconds...
}, function () {
// Could not acquire capacity within five seconds...
});
```

Unlike `funnel`, a throttle does not return a releasable lease. It records an execution in a sliding window and lets Redis expire that record when it falls out of the configured duration.

<a name="deleting-keys-by-pattern"></a>
#### Deleting Keys by Pattern

Expand Down
10 changes: 10 additions & 0 deletions src/cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ The `array` cache store is request-local in Hypervel. Laravel can keep array-sto
Hypervel also provides a `worker-array` cache store for deliberate worker-lifetime in-memory cache data. It is shared by coroutines in the same worker process and is cleared when that worker exits.

`Cache::memo()` stores its per-request memoized repository directly in coroutine context instead of Laravel's scoped container binding. Coroutine teardown provides the request reset boundary in Hypervel without dynamic container bindings.

Hypervel exposes refreshable locks through the `Hypervel\Contracts\Cache\RefreshableLock` capability interface and adds `getRemainingLifetime()` for drivers that can inspect TTLs. Laravel exposes `refresh()` directly on supported lock implementations without a typed capability contract.

Calling `refresh()` with an explicit non-positive TTL throws `InvalidArgumentException` in Hypervel. Laravel's drivers disagree about what `refresh(0)` means, and Hypervel keeps lock TTLs as the crash-safety boundary instead of silently making a lock permanent.

Calling `refresh()` without arguments on a permanent native-expiry lock verifies ownership before returning. Database locks are not truly permanent because the database has no native TTL cleanup, so `refresh()` re-extends the driver's default safety timeout.

`Cache::funnel()->acquire()` returns a caller-held concurrency lease that can be released explicitly after work spanning multiple operations. Laravel only exposes the callback-scoped funnel API.

Cache funnel timeout failures throw `Hypervel\Contracts\Limiters\LimiterTimeoutException`, shared with Redis funnels and Redis throttles. Laravel uses separate cache and Redis limiter timeout exception classes.
2 changes: 2 additions & 0 deletions src/cache/src/AbstractArrayStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Hypervel\Support\InteractsWithTime;
use RuntimeException;

use function Hypervel\Support\now;

abstract class AbstractArrayStore extends TaggableStore implements CanFlushLocks, LockProvider
{
use InteractsWithTime;
Expand Down
41 changes: 17 additions & 24 deletions src/cache/src/ArrayLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ public function acquire(): bool
*/
public function release(): bool
{
if (! $this->exists()) {
return false;
}

if (! $this->isOwnedByCurrentProcess()) {
return false;
}
Expand All @@ -72,20 +68,24 @@ public function forceRelease(): void
$this->store->forgetLockRecord($this->name);
}

/**
* Determine if the current lock exists.
*/
protected function exists(): bool
{
return $this->store->getLockRecord($this->name) !== null;
}

/**
* Return the owner value written into the driver for this lock.
*/
protected function getCurrentOwner(): ?string
{
return $this->store->getLockRecord($this->name)['owner'] ?? null;
$record = $this->store->getLockRecord($this->name);

if ($record === null) {
return null;
}

$expiresAt = $record['expiresAt'];

if ($expiresAt !== null && ! $expiresAt->isFuture()) {
return null;
}

return $record['owner'];
}

/**
Expand All @@ -95,26 +95,19 @@ protected function getCurrentOwner(): ?string
*/
public function refresh(?int $seconds = null): bool
{
// Permanent lock with no explicit TTL requested - nothing to refresh
if ($seconds === null && $this->seconds <= 0) {
return true;
return $this->isOwnedByCurrentProcess();
}

$seconds ??= $this->seconds;

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

$record = $this->store->getLockRecord($this->name);

if ($record === null) {
return false;
}

if (! $this->isOwnedByCurrentProcess()) {
if ($record === null || ! $this->isOwnedByCurrentProcess()) {
return false;
}

Expand All @@ -141,7 +134,7 @@ public function getRemainingLifetime(): ?float
return null;
}

if ($expiresAt->isPast()) {
if (! $expiresAt->isFuture()) {
return null;
}

Expand Down
Loading