From 8aad1b7c9e1125452d9a74cf941da10af188f97f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:35:26 +0000 Subject: [PATCH 01/21] feat(redis): add shared LuaScripts class for lock and limiter primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds the Redis Lua scripts used across packages: releaseLock(), refreshLock(), and acquireConcurrencySlot(). Moved from Hypervel\Cache\LuaScripts (which was misnamed for its all-Redis contents) and extended with the slot-acquire script needed by both the cache-tier and redis-tier concurrency limiters. Lock primitives belong in the redis package because the cache package already depends on redis (not vice versa) — this puts them on the correct side of the dependency arrow and lets both packages reference a single source of truth without duplication. The acquireConcurrencySlot script branches on ARGV[2] to support releaseAfter(0) → permanent slot (plain SET, no EX), matching RedisLock::acquire()'s seconds<=0 semantic. --- src/redis/src/LuaScripts.php | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/redis/src/LuaScripts.php diff --git a/src/redis/src/LuaScripts.php b/src/redis/src/LuaScripts.php new file mode 100644 index 000000000..3830faf26 --- /dev/null +++ b/src/redis/src/LuaScripts.php @@ -0,0 +1,78 @@ + 0 then + redis.call('set', KEYS[index], ARGV[3], "EX", ARGV[2]) + else + redis.call('set', KEYS[index], ARGV[3]) + end + return ARGV[1]..index + end +end +LUA; + } +} From 96332b199431efd857dfd329669a39dfbad99414 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:35:33 +0000 Subject: [PATCH 02/21] refactor(cache): import LuaScripts from new redis package location LuaScripts moved to Hypervel\Redis. Add explicit use statement so RedisLock continues to find releaseLock() and refreshLock(); the two call sites are unchanged. --- src/cache/src/RedisLock.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cache/src/RedisLock.php b/src/cache/src/RedisLock.php index c9bf0cc2d..cefa2cfbe 100644 --- a/src/cache/src/RedisLock.php +++ b/src/cache/src/RedisLock.php @@ -5,6 +5,7 @@ namespace Hypervel\Cache; use Hypervel\Contracts\Cache\RefreshableLock; +use Hypervel\Redis\LuaScripts; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use InvalidArgumentException; From 384881af0b973db37ddc95686040edf6bc928d32 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:35:45 +0000 Subject: [PATCH 03/21] refactor(redis): use shared LuaScripts and precompute slot names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the inline lockScript() and releaseScript() heredoc methods in favour of the shared Hypervel\Redis\LuaScripts. Eliminates the existing duplicate of releaseLock between this class and what is now LuaScripts::releaseLock(). Also precomputes the slot-name array once in the constructor instead of rebuilding it on every retry inside acquire(). Guards against maxLocks<1 — PHP's range(1, 0) returns [1, 0] and range(1, -1) returns [1, 0, -1], which would have produced phantom slots when the caller passed limit(0) or a negative value. Adds an explicit empty-slot short-circuit in acquire() so the Lua eval is never called with zero KEYS (which would error inside Lua on unpack({})). The fix to range() is an intentional improvement over upstream Laravel, which has the same latent bug at the equivalent site. --- src/redis/src/Limiters/ConcurrencyLimiter.php | 66 ++++++------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 439e25422..77ad98541 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiter.php +++ b/src/redis/src/Limiters/ConcurrencyLimiter.php @@ -5,6 +5,7 @@ namespace Hypervel\Redis\Limiters; use Hypervel\Contracts\Redis\LimiterTimeoutException; +use Hypervel\Redis\LuaScripts; use Hypervel\Redis\RedisProxy; use Hypervel\Support\Sleep; use Hypervel\Support\Str; @@ -12,6 +13,13 @@ class ConcurrencyLimiter { + /** + * Precomputed slot names. Built once in the constructor. + * + * @var list + */ + protected array $slots; + /** * Create a new concurrency limiter instance. * @@ -26,6 +34,9 @@ public function __construct( protected int $maxLocks, protected int $releaseAfter ) { + $this->slots = $maxLocks < 1 + ? [] + : array_map(fn (int $i): string => $name . $i, range(1, $maxLocks)); } /** @@ -50,7 +61,7 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250 if (is_callable($callback)) { try { - return tap($callback(), function () use ($slot, $id) { + return tap($callback(), function () use ($slot, $id): void { $this->release($slot, $id); }); } catch (Throwable $exception) { @@ -70,59 +81,24 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250 */ protected function acquire(string $id): mixed { - $slots = array_map(function ($i) { - return $this->name . $i; - }, range(1, $this->maxLocks)); + // Without slots there's nothing to claim. Calling eval with zero KEYS + // would error inside Lua via unpack({}) → redis.call('mget') with no args. + if ($this->slots === []) { + return false; + } return $this->redis->eval(...array_merge( - [$this->lockScript(), count($slots)], - array_merge($slots, [$this->name, $this->releaseAfter, $id]) + [LuaScripts::acquireConcurrencySlot(), count($this->slots)], + $this->slots, + [$this->name, $this->releaseAfter, $id], )); } - /** - * Get the Lua script for acquiring a lock. - * - * KEYS - The keys that represent available slots - * ARGV[1] - The limiter name - * ARGV[2] - The number of seconds the slot should be reserved - * ARGV[3] - The unique identifier for this lock - */ - protected function lockScript(): string - { - return <<<'LUA' -for index, value in pairs(redis.call('mget', unpack(KEYS))) do - if not value then - redis.call('set', KEYS[index], ARGV[3], "EX", ARGV[2]) - return ARGV[1]..index - end -end -LUA; - } - /** * Release the lock. */ protected function release(string $key, string $id): void { - $this->redis->eval($this->releaseScript(), 1, $key, $id); - } - - /** - * Get the Lua script to atomically release a lock. - * - * KEYS[1] - The name of the lock - * ARGV[1] - The unique identifier for this lock - */ - protected function releaseScript(): string - { - return <<<'LUA' -if redis.call('get', KEYS[1]) == ARGV[1] -then - return redis.call('del', KEYS[1]) -else - return 0 -end -LUA; + $this->redis->eval(LuaScripts::releaseLock(), 1, $key, $id); } } From 559a494bf4c1ccb7c0042ea25508f640048313cd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:35:51 +0000 Subject: [PATCH 04/21] chore(cache): remove old LuaScripts (moved to redis package) Contents moved to Hypervel\Redis\LuaScripts in a previous commit. Sole consumer (Hypervel\Cache\RedisLock) now imports from the new location. --- src/cache/src/LuaScripts.php | 43 ------------------------------------ 1 file changed, 43 deletions(-) delete mode 100644 src/cache/src/LuaScripts.php diff --git a/src/cache/src/LuaScripts.php b/src/cache/src/LuaScripts.php deleted file mode 100644 index 05c9f66bf..000000000 --- a/src/cache/src/LuaScripts.php +++ /dev/null @@ -1,43 +0,0 @@ - Date: Sat, 2 May 2026 17:35:59 +0000 Subject: [PATCH 05/21] feat(cache): add LimiterTimeoutException for funnel Thrown by ConcurrencyLimiter::block() when no slot can be obtained within the configured timeout. Plain Exception subclass, matches the upstream Laravel shape. Note: this is a separate exception from Hypervel\Contracts\Redis\ LimiterTimeoutException, which is thrown by the redis-direct ConcurrencyLimiter (Redis::funnel()). Cache::funnel() and Redis::funnel() each throw their own. --- src/cache/src/Limiters/LimiterTimeoutException.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/cache/src/Limiters/LimiterTimeoutException.php diff --git a/src/cache/src/Limiters/LimiterTimeoutException.php b/src/cache/src/Limiters/LimiterTimeoutException.php new file mode 100644 index 000000000..470b8af80 --- /dev/null +++ b/src/cache/src/Limiters/LimiterTimeoutException.php @@ -0,0 +1,11 @@ + Date: Sat, 2 May 2026 17:36:07 +0000 Subject: [PATCH 06/21] feat(cache): add base ConcurrencyLimiter for funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic per-slot lock-acquire loop, ported from Laravel's Illuminate\Cache\Limiters\ConcurrencyLimiter. Acquires one of N named slot locks via the configured cache store's LockProvider, holds the slot for the duration of the user callback, releases it on completion or exception. Slot names are precomputed once in the constructor and reused across retries inside acquire(). Guards maxLocks<1 with an empty slot array so PHP's descending-range behavior cannot leak phantom slots — range(1, 0) returns [1, 0] and range(1, -1) returns [1, 0, -1], which would otherwise allow callbacks to run when the caller specified limit(0) or a negative limit. --- src/cache/src/Limiters/ConcurrencyLimiter.php | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/cache/src/Limiters/ConcurrencyLimiter.php diff --git a/src/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php new file mode 100644 index 000000000..e87b1fd76 --- /dev/null +++ b/src/cache/src/Limiters/ConcurrencyLimiter.php @@ -0,0 +1,99 @@ + + */ + protected array $slots; + + /** + * Create a new concurrency limiter instance. + * + * @param LockProvider $store the cache store instance + * @param string $name the name of the limiter + * @param int $maxLocks the allowed number of concurrent locks + * @param int $releaseAfter the number of seconds a slot should be maintained + */ + public function __construct( + protected LockProvider $store, + protected string $name, + protected int $maxLocks, + protected int $releaseAfter, + ) { + $this->slots = $maxLocks < 1 + ? [] + : array_map(fn (int $i): string => $name . $i, range(1, $maxLocks)); + } + + /** + * Attempt to acquire the lock for the given number of seconds. + * + * @throws LimiterTimeoutException + * @throws Throwable + */ + public function block(int $timeout, ?callable $callback = null, int $sleep = 250): mixed + { + $starting = time(); + + $id = Str::random(20); + + while (! $slot = $this->acquire($id)) { + if (time() - $timeout >= $starting) { + throw new LimiterTimeoutException; + } + + Sleep::usleep($sleep * 1000); + } + + if (is_callable($callback)) { + try { + return tap($callback(), function () use ($slot): void { + $this->release($slot); + }); + } catch (Throwable $exception) { + $this->release($slot); + + throw $exception; + } + } + + return true; + } + + /** + * Attempt to acquire a slot lock. + */ + protected function acquire(string $id): bool|Lock + { + foreach ($this->slots as $slotName) { + $lock = $this->store->lock($slotName, $this->releaseAfter, $id); + + if ($lock->acquire()) { + return $lock; + } + } + + return false; + } + + /** + * Release the slot lock. + */ + protected function release(Lock $lock): void + { + $lock->release(); + } +} From e705a17a4abca8cea93b5582a555b4f31dbfdca0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:36:25 +0000 Subject: [PATCH 07/21] feat(cache): add Redis fast-path ConcurrencyLimiter for funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hypervel-specific subclass of the base ConcurrencyLimiter. Replaces the per-slot lock-acquire loop with a single Lua script that scans all slots and atomically claims the first free one — cuts Redis round trips per acquire attempt from O(maxLocks) to O(1). Two correctness invariants documented in acquire(): 1. The Lua script writes to prefixed slot keys but returns the UNPREFIXED slot name, so RedisStore::restoreLock() prepends the prefix exactly once when constructing the Lock object. 2. The owner ID is pre-packed via $connection->pack() before being passed into Lua. phpredis does NOT auto-serialize eval() ARGV (regular commands like set() do). RedisLock::release() later packs $this->owner before its owner-check Lua, so the value Redis stores at acquire time must already be in packed form. Without this, release() would silently fail (raw vs packed mismatch) and slots would leak until releaseAfter. Uses withConnection() to keep both pack() and eval() on the same checked-out pool connection, avoiding two pool roundtrips per attempt. Also precomputes prefixed slot keys once in the constructor and short-circuits acquire() when prefixedSlots is empty (limit<1) so the Lua eval is never called with zero KEYS. --- .../src/Limiters/RedisConcurrencyLimiter.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/cache/src/Limiters/RedisConcurrencyLimiter.php diff --git a/src/cache/src/Limiters/RedisConcurrencyLimiter.php b/src/cache/src/Limiters/RedisConcurrencyLimiter.php new file mode 100644 index 000000000..60b15c498 --- /dev/null +++ b/src/cache/src/Limiters/RedisConcurrencyLimiter.php @@ -0,0 +1,87 @@ + + */ + protected array $prefixedSlots; + + /** + * Create a new Redis-optimized concurrency limiter instance. + */ + public function __construct( + RedisStore $store, + string $name, + int $maxLocks, + int $releaseAfter, + ) { + parent::__construct($store, $name, $maxLocks, $releaseAfter); + + $prefix = $store->getPrefix(); + $this->prefixedSlots = array_map( + fn (string $slot): string => $prefix . $slot, + $this->slots, + ); + } + + /** + * Atomically claim a free slot via a single Lua script. + * + * Two correctness invariants: + * + * 1. The Lua script writes the prefixed slot key to Redis and returns the + * UNPREFIXED slot name (e.g. "my-funnel1") so RedisStore::restoreLock() + * prepends the prefix exactly once when constructing the Lock object. + * + * 2. The owner ID must be pre-packed via $connection->pack() before being + * passed into Lua. phpredis does NOT auto-serialize eval() ARGV (regular + * commands like set() do). RedisLock::release() later packs $this->owner + * before its owner-check Lua, so the value Redis stores at acquire time + * must already be in packed form. If we passed raw $id here, Redis would + * store the raw string, but release would compare against a packed value + * — silent mismatch, slot leaks until TTL. We pass the RAW $id to + * restoreLock() so the returned RedisLock's owner field is raw; release + * will pack it consistently with what we stored. + * + * Using withConnection() also keeps both pack() and eval() on the same + * checked-out pool connection, avoiding two pool roundtrips per attempt. + */ + protected function acquire(string $id): bool|Lock + { + // Without slots there's nothing to claim. Calling eval with zero KEYS + // would error inside Lua via unpack({}) → redis.call('mget') with no args. + if ($this->prefixedSlots === []) { + return false; + } + + /** @var RedisStore $store */ + $store = $this->store; + + return $store->lockConnection()->withConnection(function (RedisConnection $connection) use ($id, $store): bool|Lock { + $packedOwner = $connection->pack([$id])[0]; + + $result = $connection->eval(...array_merge( + [LuaScripts::acquireConcurrencySlot(), count($this->prefixedSlots)], + $this->prefixedSlots, + [$this->name, $this->releaseAfter, $packedOwner], + )); + + return is_string($result) ? $store->restoreLock($result, $id) : false; + }); + } +} From f5ab52d432cdeaad59e23c2223508fc096503374 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:36:33 +0000 Subject: [PATCH 08/21] feat(cache): add ConcurrencyLimiterBuilder for funnel Fluent builder returned by Repository::funnel(). Configurable via limit(), releaseAfter() (DateInterval/DateTimeInterface/int), block(), sleep(); executes via then(callback, ?failure). createLimiter() dispatches based on the underlying store: returns RedisConcurrencyLimiter (the Lua-script fast path) when the store is a RedisStore, otherwise the base ConcurrencyLimiter. The @var Store&LockProvider narrowing is safe because Repository::funnel() already verified LockProvider before constructing the builder. --- .../Limiters/ConcurrencyLimiterBuilder.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/cache/src/Limiters/ConcurrencyLimiterBuilder.php diff --git a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php new file mode 100644 index 000000000..4d59789a3 --- /dev/null +++ b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php @@ -0,0 +1,125 @@ +maxLocks = $maxLocks; + + return $this; + } + + /** + * Set the number of seconds until the lock will be released. + */ + public function releaseAfter(DateInterval|DateTimeInterface|int $releaseAfter): static + { + $this->releaseAfter = $this->secondsUntil($releaseAfter); + + return $this; + } + + /** + * Set the number of seconds to block until a lock is available. + */ + public function block(int $timeout): static + { + $this->timeout = $timeout; + + return $this; + } + + /** + * The number of milliseconds to wait between lock acquisition attempts. + */ + public function sleep(int $sleep): static + { + $this->sleep = $sleep; + + return $this; + } + + /** + * Execute the given callback if a lock is obtained, otherwise call the failure callback. + * + * @throws LimiterTimeoutException + */ + public function then(callable $callback, ?callable $failure = null): mixed + { + try { + return $this->createLimiter()->block($this->timeout, $callback, $this->sleep); + } catch (LimiterTimeoutException $e) { + if ($failure !== null) { + return $failure($e); + } + + throw $e; + } + } + + /** + * Create the concurrency limiter instance. + */ + protected function createLimiter(): ConcurrencyLimiter + { + // Type is guaranteed by the LockProvider check in Repository::funnel(), + // but Repository::getStore() returns Store so phpstan needs help narrowing. + /** @var LockProvider&Store $store */ + $store = $this->connection->getStore(); + + if ($store instanceof RedisStore) { + return new RedisConcurrencyLimiter($store, $this->name, $this->maxLocks, $this->releaseAfter); + } + + return new ConcurrencyLimiter($store, $this->name, $this->maxLocks, $this->releaseAfter); + } +} From 824abe993b16761e88de78a1a3e7d3344bd0dedb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:36:44 +0000 Subject: [PATCH 09/21] feat(cache): add funnel() method to Repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache::funnel(\$name) returns a ConcurrencyLimiterBuilder that caps the number of simultaneous in-flight executions of a callback across the worker fleet (and any other process sharing the cache backend). Distinct from withoutOverlapping (mutex with N=1) and from RateLimiter (X-per-time-window). Throws BadMethodCallException when the cache store is not a LockProvider — swoole, stack, and session stores reject. Works for array, database, file, redis, null, failover, and memoized (when the inner store supports locks). --- src/cache/src/Repository.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index e04957a35..4fd954f85 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -26,7 +26,9 @@ use Hypervel\Cache\Events\RetrievingManyKeys; use Hypervel\Cache\Events\WritingKey; use Hypervel\Cache\Events\WritingManyKeys; +use Hypervel\Cache\Limiters\ConcurrencyLimiterBuilder; use Hypervel\Contracts\Cache\CanFlushLocks; +use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\LockTimeoutException; use Hypervel\Contracts\Cache\RawReadable; use Hypervel\Contracts\Cache\Repository as CacheContract; @@ -692,6 +694,18 @@ public function withoutOverlapping(UnitEnum|string $key, callable $callback, int return $this->store->lock(enum_value($key), $lockFor, $owner)->block($waitFor, $callback); // @phpstan-ignore method.notFound (lock() is on LockProvider, not Store contract) } + /** + * Funnel a callback for a maximum number of simultaneous executions. + */ + public function funnel(UnitEnum|string $name): ConcurrencyLimiterBuilder + { + if (! $this->store instanceof LockProvider) { + throw new BadMethodCallException('This cache store does not support locks.'); + } + + return new ConcurrencyLimiterBuilder($this, enum_value($name)); + } + /** * Remove an item from the cache. */ From 6fef5c0e40f3b357a5aced30ceeeef37e39354ed Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:36:49 +0000 Subject: [PATCH 10/21] feat(support): add funnel() @method annotation to Cache facade Lets Cache::funnel('x')->limit(...)->...->then(...) typecheck through the facade. --- src/support/src/Facades/Cache.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index 33f4907c9..fc4529488 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -60,6 +60,7 @@ * @method static mixed flexible(\UnitEnum|string $key, array $ttl, callable $callback, null|array $lock = null, bool $alwaysDefer = false) * @method static mixed flexibleNullable(\UnitEnum|string $key, array $ttl, callable $callback, null|array $lock = null, bool $alwaysDefer = false) * @method static mixed withoutOverlapping(\UnitEnum|string $key, callable $callback, int $lockFor = 0, int $waitFor = 10, string|null $owner = null) + * @method static \Hypervel\Cache\Limiters\ConcurrencyLimiterBuilder funnel(\UnitEnum|string $name) * @method static bool flushLocks() * @method static bool supportsTags() * @method static bool supportsFlushingLocks() From 94e91ad667fe45e928a71c57c1800866ce173893 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:36:59 +0000 Subject: [PATCH 11/21] test(cache): port ConcurrencyLimiterTest with funnel coverage and limit edge cases Ports Laravel's tests/Cache/ConcurrencyLimiterTest.php (12 methods) and adds Hypervel-specific extras: - testReleaseAfterAcceptsDateInterval / testReleaseAfterAcceptsDateTime cover the Hypervel-typed releaseAfter() signature (DateInterval|DateTimeInterface|int) - testFunnelWithZeroLimitDoesNotRunCallback / testFunnelWithNegativeLimitDoesNotRunCallback regress the range() guard in the base ConcurrencyLimiter constructor Replaces createMock(Store::class) with createStub() to silence PHPUnit 13's "no expectations on mock object" notice. Replaces usleep(1.2 * 1000000) with usleep(1_200_000) for declare(strict_types=1) compatibility. --- tests/Cache/ConcurrencyLimiterTest.php | 382 +++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 tests/Cache/ConcurrencyLimiterTest.php diff --git a/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php new file mode 100644 index 000000000..068954106 --- /dev/null +++ b/tests/Cache/ConcurrencyLimiterTest.php @@ -0,0 +1,382 @@ +repository = new Repository(new ArrayStore); + } + + public function testItLocksTasksWhenNoSlotAvailable() + { + $store = []; + + foreach (range(1, 2) as $i) { + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 2, 5))->block(2, function () use (&$store, $i) { + $store[] = $i; + }); + } + + try { + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 2, 5))->block(0, function () use (&$store) { + $store[] = 3; + }); + } catch (Throwable $e) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + } + + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'other_key', 2, 5))->block(2, function () use (&$store) { + $store[] = 4; + }); + + $this->assertEquals([1, 2, 4], $store); + } + + public function testItReleasesLockAfterTaskFinishes() + { + $store = []; + + foreach (range(1, 4) as $i) { + (new ConcurrencyLimiter($this->repository->getStore(), 'key', 2, 5))->block(2, function () use (&$store, $i) { + $store[] = $i; + }); + } + + $this->assertEquals([1, 2, 3, 4], $store); + } + + public function testItReleasesLockIfTaskTookTooLong() + { + $store = []; + + $lock = new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 1, 1); + + $lock->block(2, function () use (&$store) { + $store[] = 1; + }); + + try { + $lock->block(0, function () use (&$store) { + $store[] = 2; + }); + } catch (Throwable $e) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + } + + usleep(1_200_000); + + $lock->block(0, function () use (&$store) { + $store[] = 3; + }); + + $this->assertEquals([1, 3], $store); + } + + public function testItFailsImmediatelyOrRetriesForAWhileBasedOnAGivenTimeout() + { + $store = []; + + $lock = new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 1, 2); + + $lock->block(2, function () use (&$store) { + $store[] = 1; + }); + + try { + $lock->block(0, function () use (&$store) { + $store[] = 2; + }); + } catch (Throwable $e) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + } + + $lock->block(3, function () use (&$store) { + $store[] = 3; + }); + + $this->assertEquals([1, 3], $store); + } + + public function testItFailsAfterRetryTimeout() + { + $store = []; + + $lock = new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'key', 1, 10); + + $lock->block(2, function () use (&$store) { + $store[] = 1; + }); + + try { + $lock->block(2, function () use (&$store) { + $store[] = 2; + }); + } catch (Throwable $e) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + } + + $this->assertEquals([1], $store); + } + + public function testItReleasesIfErrorIsThrown() + { + $store = []; + + $lock = new ConcurrencyLimiter($this->repository->getStore(), 'key', 1, 5); + + try { + $lock->block(1, function () { + throw new Error; + }); + } catch (Error) { + } + + $lock = new ConcurrencyLimiter($this->repository->getStore(), 'key', 1, 5); + $lock->block(1, function () use (&$store) { + $store[] = 1; + }); + + $this->assertEquals([1], $store); + } + + public function testFunnelMethodOnRepository() + { + $store = []; + + $result = $this->repository->funnel('test-funnel') + ->limit(2) + ->releaseAfter(5) + ->block(2) + ->then(function () use (&$store) { + $store[] = 1; + + return 'ok'; + }); + + $this->assertEquals([1], $store); + $this->assertSame('ok', $result); + } + + public function testFunnelMethodAcceptsBackedEnum() + { + $store = []; + + $result = $this->repository->funnel(ConcurrencyLimiterBackedEnum::TestFunnel) + ->limit(2) + ->releaseAfter(5) + ->block(2) + ->then(function () use (&$store) { + $store[] = 1; + + return 'ok'; + }); + + $this->assertEquals([1], $store); + $this->assertSame('ok', $result); + } + + public function testFunnelMethodAcceptsUnitEnum() + { + $store = []; + + $result = $this->repository->funnel(ConcurrencyLimiterUnitEnum::TestFunnel) + ->limit(2) + ->releaseAfter(5) + ->block(2) + ->then(function () use (&$store) { + $store[] = 1; + + return 'ok'; + }); + + $this->assertEquals([1], $store); + $this->assertSame('ok', $result); + } + + public function testFunnelBackedEnumSharesKeyWithStringEquivalent() + { + // Fill all slots using the backed enum's string value + foreach (range(1, 2) as $i) { + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'test-funnel', 2, 5))->block(2, function () { + }); + } + + // Try to acquire via the BackedEnum — should conflict with the string key + $result = $this->repository->funnel(ConcurrencyLimiterBackedEnum::TestFunnel) + ->limit(2) + ->releaseAfter(5) + ->block(0) + ->then( + function () { + return 'success'; + }, + function () { + return 'failed'; + } + ); + + $this->assertSame('failed', $result); + } + + public function testFunnelThrowsExceptionWhenStoreDoesNotSupportLocks() + { + $store = $this->createStub(Store::class); + $repository = new Repository($store); + + $this->assertNotInstanceOf(LockProvider::class, $store); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('This cache store does not support locks.'); + + $repository->funnel('test'); + } + + public function testFunnelWithFailureCallback() + { + $store = []; + + // Fill all slots without releasing + foreach (range(1, 2) as $i) { + (new ConcurrencyLimiterMockThatDoesntRelease($this->repository->getStore(), 'funnel-key', 2, 5))->block(2, function () use (&$store, $i) { + $store[] = $i; + }); + } + + // Try to acquire when all slots are full + $result = $this->repository->funnel('funnel-key') + ->limit(2) + ->releaseAfter(5) + ->block(0) + ->then( + function () use (&$store) { + $store[] = 'success'; + }, + function ($e) use (&$store) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + $store[] = 'failed'; + + return 'failure-result'; + } + ); + + $this->assertEquals([1, 2, 'failed'], $store); + $this->assertSame('failure-result', $result); + } + + public function testFunnelWithZeroLimitDoesNotRunCallback() + { + $called = false; + + $result = $this->repository->funnel('zero') + ->limit(0) + ->releaseAfter(5) + ->block(0) + ->then( + function () use (&$called) { + $called = true; + + return 'should-not-run'; + }, + fn () => 'failed', + ); + + $this->assertFalse($called); + $this->assertSame('failed', $result); + } + + public function testFunnelWithNegativeLimitDoesNotRunCallback() + { + $called = false; + + $result = $this->repository->funnel('neg') + ->limit(-1) + ->releaseAfter(5) + ->block(0) + ->then( + function () use (&$called) { + $called = true; + + return 'should-not-run'; + }, + fn () => 'failed', + ); + + $this->assertFalse($called); + $this->assertSame('failed', $result); + } + + public function testReleaseAfterAcceptsDateInterval() + { + $store = []; + + $result = $this->repository->funnel('test') + ->limit(2) + ->releaseAfter(new DateInterval('PT5S')) + ->block(2) + ->then(function () use (&$store) { + $store[] = 1; + + return 'ok'; + }); + + $this->assertEquals([1], $store); + $this->assertSame('ok', $result); + } + + public function testReleaseAfterAcceptsDateTime() + { + $store = []; + + $result = $this->repository->funnel('test') + ->limit(2) + ->releaseAfter((new DateTimeImmutable)->modify('+5 seconds')) + ->block(2) + ->then(function () use (&$store) { + $store[] = 1; + + return 'ok'; + }); + + $this->assertEquals([1], $store); + $this->assertSame('ok', $result); + } +} + +class ConcurrencyLimiterMockThatDoesntRelease extends ConcurrencyLimiter +{ + protected function release(Lock $lock): void + { + } +} + +enum ConcurrencyLimiterBackedEnum: string +{ + case TestFunnel = 'test-funnel'; +} + +enum ConcurrencyLimiterUnitEnum +{ + case TestFunnel; +} From dfab1c6ad51d68121c2dc0258980ba654220490d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:37:06 +0000 Subject: [PATCH 12/21] test(cache): verify swoole/stack/session stores reject funnel Static-fact assertions via is_subclass_of() that the three Hypervel-only Store implementations don't implement LockProvider, so Repository::funnel() will reject them. Cheaper than instantiating each (their constructors have non-trivial dependencies); the runtime throw path is already covered by ConcurrencyLimiterTest's testFunnelThrowsExceptionWhenStoreDoesNotSupportLocks. --- tests/Cache/FunnelUnsupportedStoresTest.php | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/Cache/FunnelUnsupportedStoresTest.php diff --git a/tests/Cache/FunnelUnsupportedStoresTest.php b/tests/Cache/FunnelUnsupportedStoresTest.php new file mode 100644 index 000000000..dfcd2772b --- /dev/null +++ b/tests/Cache/FunnelUnsupportedStoresTest.php @@ -0,0 +1,29 @@ +assertFalse(is_subclass_of(SwooleStore::class, LockProvider::class)); + } + + public function testStackStoreDoesNotImplementLockProvider() + { + $this->assertFalse(is_subclass_of(StackStore::class, LockProvider::class)); + } + + public function testSessionStoreDoesNotImplementLockProvider() + { + $this->assertFalse(is_subclass_of(SessionStore::class, LockProvider::class)); + } +} From f76c37e1f918b58fc0e1ebc1cde403da72c34aaf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:37:13 +0000 Subject: [PATCH 13/21] test(cache): port CacheFunnelTestCase abstract base Six driver-agnostic funnel tests (basic happy path, releases lock after callback, releases on exception, timeout without failure callback, failure callback receives exception, independent keys), ported from Laravel. Driver-specific subclasses provide a cache() implementation. Setup/teardown clean up the known lock keys via forceRelease() so inherited subclasses don't leak state between tests. --- .../Integration/Cache/CacheFunnelTestCase.php | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/Integration/Cache/CacheFunnelTestCase.php diff --git a/tests/Integration/Cache/CacheFunnelTestCase.php b/tests/Integration/Cache/CacheFunnelTestCase.php new file mode 100644 index 000000000..867580124 --- /dev/null +++ b/tests/Integration/Cache/CacheFunnelTestCase.php @@ -0,0 +1,138 @@ +releaseFunnelLocks(); + } catch (Throwable) { + } + } + + public function testFunnelBasicHappyPath() + { + $result = $this->cache()->funnel('test') + ->limit(2) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'hello'); + + $this->assertSame('hello', $result); + } + + public function testFunnelReleasesLockAfterCallback() + { + for ($i = 0; $i < 5; ++$i) { + $result = $this->cache()->funnel('test') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'ok'); + + $this->assertSame('ok', $result); + } + } + + public function testFunnelLockReleasedOnException() + { + try { + $this->cache()->funnel('test') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(function () { + throw new Exception('fail'); + }); + } catch (Exception) { + } + + $result = $this->cache()->funnel('test') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'recovered'); + + $this->assertSame('recovered', $result); + } + + public function testFunnelTimeoutExceptionWithoutFailureCallback() + { + $this->cache()->lock('test1', 60)->get(); + $this->cache()->lock('test2', 60)->get(); + + $this->expectException(LimiterTimeoutException::class); + + $this->cache()->funnel('test') + ->limit(2) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'should not run'); + } + + public function testFunnelFailureCallbackReceivesException() + { + $this->cache()->lock('test1', 60)->get(); + $this->cache()->lock('test2', 60)->get(); + + $result = $this->cache()->funnel('test') + ->limit(2) + ->releaseAfter(60) + ->block(0) + ->then( + fn () => 'should not run', + function ($e) { + $this->assertInstanceOf(LimiterTimeoutException::class, $e); + + return 'failed'; + } + ); + + $this->assertSame('failed', $result); + } + + public function testFunnelIndependentKeys() + { + $this->cache()->lock('key-a1', 60)->get(); + + $result = $this->cache()->funnel('key-b') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'key-b-ok'); + + $this->assertSame('key-b-ok', $result); + } + + protected function releaseFunnelLocks(): void + { + $this->cache()->lock('test1')->forceRelease(); + $this->cache()->lock('test2')->forceRelease(); + $this->cache()->lock('key-a1')->forceRelease(); + $this->cache()->lock('key-b1')->forceRelease(); + } + + protected function tearDown(): void + { + try { + $this->releaseFunnelLocks(); + } catch (Throwable) { + } + + parent::tearDown(); + } +} From 0d956d366ade2ec24fcbd20819485a55ecbcace7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:37:19 +0000 Subject: [PATCH 14/21] test(cache): port ArrayCacheFunnelTest Runs the abstract CacheFunnelTestCase suite against the array store. --- .../Integration/Cache/ArrayCacheFunnelTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/Integration/Cache/ArrayCacheFunnelTest.php diff --git a/tests/Integration/Cache/ArrayCacheFunnelTest.php b/tests/Integration/Cache/ArrayCacheFunnelTest.php new file mode 100644 index 000000000..39b0f66fb --- /dev/null +++ b/tests/Integration/Cache/ArrayCacheFunnelTest.php @@ -0,0 +1,18 @@ + Date: Sat, 2 May 2026 17:37:26 +0000 Subject: [PATCH 15/21] test(cache): port DatabaseCacheFunnelTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the abstract CacheFunnelTestCase suite against the database store (SQLite in-memory via testbench), with WithMigration('cache') to set up the cache_locks table and LazilyRefreshDatabase for isolation. No coroutine concurrency test — the generic per-slot loop doesn't need driver-specific coroutine coverage; Redis tests already exercise the concurrent funnel path on the production driver where it matters. --- .../Cache/DatabaseCacheFunnelTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/Integration/Cache/DatabaseCacheFunnelTest.php diff --git a/tests/Integration/Cache/DatabaseCacheFunnelTest.php b/tests/Integration/Cache/DatabaseCacheFunnelTest.php new file mode 100644 index 000000000..80793d2a3 --- /dev/null +++ b/tests/Integration/Cache/DatabaseCacheFunnelTest.php @@ -0,0 +1,21 @@ + Date: Sat, 2 May 2026 17:37:30 +0000 Subject: [PATCH 16/21] test(cache): port FileCacheFunnelTest Runs the abstract CacheFunnelTestCase suite against the file store. --- .../Integration/Cache/FileCacheFunnelTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 tests/Integration/Cache/FileCacheFunnelTest.php diff --git a/tests/Integration/Cache/FileCacheFunnelTest.php b/tests/Integration/Cache/FileCacheFunnelTest.php new file mode 100644 index 000000000..ff5ad8960 --- /dev/null +++ b/tests/Integration/Cache/FileCacheFunnelTest.php @@ -0,0 +1,18 @@ + Date: Sat, 2 May 2026 17:37:45 +0000 Subject: [PATCH 17/21] test(cache): port RedisCacheFunnelTest with coroutine and zero-edge tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the abstract CacheFunnelTestCase suite against the redis store, plus four Hypervel-specific tests: - testCoroutineConcurrencyAllSlotsHeldAllFail: spawns 10 child coroutines via Parallel(5) with all slots pre-held, expects every child to fall through to the failure callback. Validates the Redis fast-path's Lua atomicity under coroutine contention. - testCoroutineConcurrencyLimitMatchesCount: 5 coroutines into limit(5), expects all to acquire and complete cleanly. - testFunnelWithZeroReleaseAfterAcquiresAndReleasesPermanentSlot: regresses the Lua acquireConcurrencySlot script's branch on ARGV[2] — without the branch, releaseAfter(0) would send EX 0 to Redis and error. - testFunnelWithZeroLimitOnRedisDoesNotRunCallback: regresses the empty-prefixedSlots short-circuit in RedisConcurrencyLimiter:: acquire() — without it, eval would be called with zero KEYS and Lua's unpack({}) into mget would error. Skips Laravel's manual setUpRedis()/tearDownRedis() overrides; Hypervel's InteractsWithRedis trait auto-runs setup/teardown via setUpInteractsWithRedis()/tearDownInteractsWithRedis(). --- .../Cache/RedisCacheFunnelTest.php | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/Integration/Cache/RedisCacheFunnelTest.php diff --git a/tests/Integration/Cache/RedisCacheFunnelTest.php b/tests/Integration/Cache/RedisCacheFunnelTest.php new file mode 100644 index 000000000..d962ee880 --- /dev/null +++ b/tests/Integration/Cache/RedisCacheFunnelTest.php @@ -0,0 +1,113 @@ +cache(); + + $lock1 = $cache->lock('test1', 60); + $lock1->get(); + $lock2 = $cache->lock('test2', 60); + $lock2->get(); + + $parallel = new Parallel(5); + for ($i = 0; $i < 10; ++$i) { + $parallel->add( + static fn () => $cache->funnel('test') + ->limit(2)->releaseAfter(60)->block(0) + ->then(fn () => 'success', fn () => 'failed') + ); + } + + $this->assertSame(array_fill(0, 10, 'failed'), $parallel->wait()); + + $lock1->forceRelease(); + $lock2->forceRelease(); + } + + public function testCoroutineConcurrencyLimitMatchesCount() + { + $cache = $this->cache(); + + $parallel = new Parallel(5); + for ($i = 0; $i < 5; ++$i) { + $parallel->add( + static fn () => $cache->funnel('test') + ->limit(5)->releaseAfter(60)->block(2) + ->then(fn () => 'ok') + ); + } + + $results = $parallel->wait(); + $this->assertCount(5, $results); + $this->assertNotContains(null, $results); + foreach ($results as $result) { + $this->assertSame('ok', $result); + } + } + + public function testFunnelWithZeroReleaseAfterAcquiresAndReleasesPermanentSlot() + { + // releaseAfter(0) means no TTL — RedisLock semantic for "permanent". + // The Lua acquire path must SET without EX in that case (EX 0 errors). + // Slot still releases via the explicit RedisLock release after the callback. + $cache = $this->cache(); + $cache->lock('perm1')->forceRelease(); + + $first = $cache->funnel('perm') + ->limit(1) + ->releaseAfter(0) + ->block(0) + ->then(fn () => 'first'); + $this->assertSame('first', $first); + + $second = $cache->funnel('perm') + ->limit(1) + ->releaseAfter(0) + ->block(0) + ->then(fn () => 'second'); + $this->assertSame('second', $second); + + $cache->lock('perm1')->forceRelease(); + } + + public function testFunnelWithZeroLimitOnRedisDoesNotRunCallback() + { + // limit(0) results in zero precomputed slots — the limiter must short-circuit + // before calling Lua eval, otherwise unpack({}) → redis.call('mget') errors. + $called = false; + + $result = $this->cache()->funnel('zero') + ->limit(0) + ->releaseAfter(5) + ->block(0) + ->then( + function () use (&$called) { + $called = true; + + return 'should-not-run'; + }, + fn () => 'failed', + ); + + $this->assertFalse($called); + $this->assertSame('failed', $result); + } +} From a810b08513615a12185da5d78f6989942b49eff7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:37:51 +0000 Subject: [PATCH 18/21] test(cache): cover funnel through memoized cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hypervel-specific. Runs the abstract CacheFunnelTestCase suite via Cache::memo('array'), exercising MemoizedStore's LockProvider delegation to the inner array store. Note: there is no 'memo' driver — MemoizedStore is reached via CacheManager::memo() rather than via the 'driver' config key. --- .../Cache/MemoizedCacheFunnelTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/Integration/Cache/MemoizedCacheFunnelTest.php diff --git a/tests/Integration/Cache/MemoizedCacheFunnelTest.php b/tests/Integration/Cache/MemoizedCacheFunnelTest.php new file mode 100644 index 000000000..31d898bb4 --- /dev/null +++ b/tests/Integration/Cache/MemoizedCacheFunnelTest.php @@ -0,0 +1,16 @@ + Date: Sat, 2 May 2026 17:37:58 +0000 Subject: [PATCH 19/21] test(cache): cover funnel through failover store Hypervel-specific. Runs the abstract CacheFunnelTestCase suite against FailoverStore configured with a single 'array' inner store, exercising the LockProvider delegation through attemptOnAllStores. Overrides cache.stores.failover.stores to ['array'] for the test (the default ['database', 'array'] would pull in database setup, which the DatabaseCacheFunnelTest already covers). --- .../Cache/FailoverCacheFunnelTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/Integration/Cache/FailoverCacheFunnelTest.php diff --git a/tests/Integration/Cache/FailoverCacheFunnelTest.php b/tests/Integration/Cache/FailoverCacheFunnelTest.php new file mode 100644 index 000000000..87a2a8d59 --- /dev/null +++ b/tests/Integration/Cache/FailoverCacheFunnelTest.php @@ -0,0 +1,19 @@ + Date: Sat, 2 May 2026 17:38:12 +0000 Subject: [PATCH 20/21] test(cache): regression coverage for Redis funnel owner-packing across serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors PhpRedisCacheLockTest's 9-method matrix: SERIALIZER_NONE/PHP/ JSON/IGBINARY/MSGPACK and COMPRESSION_LZF/ZSTD/LZ4 plus combined PHP serializer + LZF compression. Each test reconfigures the lock_connection with the given options, then asserts that two consecutive funnel('test')->limit(1)->releaseAfter(60)->block(0)->then(...) calls both succeed. Without the pack() + withConnection() fix in RedisConcurrencyLimiter::acquire(), the second call would throw LimiterTimeoutException — the first call's slot would be stored with a raw owner but RedisLock::release() compares against a packed value, silently failing the release and leaking the slot until releaseAfter. That bug is invisible under SERIALIZER_NONE (the default in most test setups) because pack() is a no-op there; only the serializer-enabled configurations exercise it. Skip guards via defined('Redis::SERIALIZER_IGBINARY') etc. handle phpredis builds that lack those extensions. --- .../Cache/PhpRedisCacheFunnelTest.php | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/Integration/Cache/PhpRedisCacheFunnelTest.php diff --git a/tests/Integration/Cache/PhpRedisCacheFunnelTest.php b/tests/Integration/Cache/PhpRedisCacheFunnelTest.php new file mode 100644 index 000000000..a911c6f18 --- /dev/null +++ b/tests/Integration/Cache/PhpRedisCacheFunnelTest.php @@ -0,0 +1,194 @@ +configureLockConnection([ + 'serializer' => Redis::SERIALIZER_NONE, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithPhpSerialization() + { + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_PHP, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithJsonSerialization() + { + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_JSON, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithIgbinarySerialization() + { + if (! defined('Redis::SERIALIZER_IGBINARY')) { + $this->markTestSkipped('Redis extension is not configured to support the igbinary serializer.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_IGBINARY, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithMsgpackSerialization() + { + if (! defined('Redis::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('Redis extension is not configured to support the msgpack serializer.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_MSGPACK, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithLzfCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_NONE, + 'compression' => Redis::COMPRESSION_LZF, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithZstdCompression() + { + if (! defined('Redis::COMPRESSION_ZSTD')) { + $this->markTestSkipped('Redis extension is not configured to support the zstd compression.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_NONE, + 'compression' => Redis::COMPRESSION_ZSTD, + 'compression_level' => Redis::COMPRESSION_ZSTD_DEFAULT, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithLz4Compression() + { + if (! defined('Redis::COMPRESSION_LZ4')) { + $this->markTestSkipped('Redis extension is not configured to support the lz4 compression.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_NONE, + 'compression' => Redis::COMPRESSION_LZ4, + 'compression_level' => 1, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + public function testFunnelReleasesSlotWithSerializationAndCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_PHP, + 'compression' => Redis::COMPRESSION_LZF, + ]); + + $this->assertFunnelAcquiresAndReleases(); + } + + /** + * Configure a dedicated Redis connection for funnel testing with the given options. + * + * Creates a 'lock-test' Redis connection with the specified serializer/compression + * options, points the cache store's lock_connection to it, and purges the cache + * store so it picks up the new configuration. Identical to PhpRedisCacheLockTest's + * helper since the funnel uses the same lock_connection path. + */ + protected function configureLockConnection(array $options): void + { + $baseConfig = $this->app['config']->get('database.redis.default'); + + $this->app['config']->set('database.redis.lock-test', array_merge($baseConfig, [ + 'options' => $options, + ])); + + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'lock-test'); + + Cache::forgetDriver('redis'); + } + + /** + * Assert that Cache::funnel() acquires a slot, runs the callback, and releases + * the slot cleanly — verified by a second consecutive call under block(0) + * succeeding immediately. + * + * If the owner-packing fix is missing (raw $id stored in Lua, packed compared + * in RedisLock::release()), the second call throws LimiterTimeoutException + * because the slot from the first call was never actually released. + */ + protected function assertFunnelAcquiresAndReleases(): void + { + $repository = Cache::store('redis'); + $repository->lock('test1')->forceRelease(); + + $first = $repository->funnel('test') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'first'); + + $this->assertSame('first', $first); + + $second = $repository->funnel('test') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->then(fn () => 'second'); + + $this->assertSame('second', $second); + + $repository->lock('test1')->forceRelease(); + } +} From d693aadebe2338837b53da644422b4cf0574bc0e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 2 May 2026 17:38:20 +0000 Subject: [PATCH 21/21] test(redis): cover empty-slot short-circuit in direct ConcurrencyLimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regression tests for the limit(0) / limit(-1) edge cases on the redis-direct path (Redis::funnel() and equivalent). Both mock RedisProxy with shouldNotReceive('eval') — without the empty-slots short-circuit in acquire(), Lua would receive zero KEYS and error on redis.call('mget', unpack({})). Each test asserts the expected user-facing behavior — block(0) with no slots throws LimiterTimeoutException — proving the short-circuit both prevents the eval call and routes through the standard timeout path. --- tests/Redis/ConcurrencyLimiterTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 68b9b1883..ca0048ead 100644 --- a/tests/Redis/ConcurrencyLimiterTest.php +++ b/tests/Redis/ConcurrencyLimiterTest.php @@ -140,6 +140,30 @@ public function testAcquirePassesCorrectKeysToLuaScript() $limiter->block(5); } + public function testBlockWithZeroLimitDoesNotCallEvalAndTimesOut() + { + $redis = $this->mockRedis(); + + // limit(0) means no slots — acquire must short-circuit before calling eval, + // otherwise Lua hits redis.call('mget') with no args and errors. + $redis->shouldNotReceive('eval'); + + $this->expectException(LimiterTimeoutException::class); + + (new ConcurrencyLimiter($redis, 'zero', 0, 5))->block(0); + } + + public function testBlockWithNegativeLimitDoesNotCallEvalAndTimesOut() + { + $redis = $this->mockRedis(); + + $redis->shouldNotReceive('eval'); + + $this->expectException(LimiterTimeoutException::class); + + (new ConcurrencyLimiter($redis, 'neg', -1, 5))->block(0); + } + /** * Create a mock RedisProxy. */