feat: port Cache::funnel from Laravel#381
Merged
binaryfire merged 21 commits into0.4from May 2, 2026
Merged
Conversation
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.
LuaScripts moved to Hypervel\Redis. Add explicit use statement so RedisLock continues to find releaseLock() and refreshLock(); the two call sites are unchanged.
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.
Contents moved to Hypervel\Redis\LuaScripts in a previous commit. Sole consumer (Hypervel\Cache\RedisLock) now imports from the new location.
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.
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.
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.
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.
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).
Lets Cache::funnel('x')->limit(...)->...->then(...) typecheck through
the facade.
…it 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.
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.
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.
Runs the abstract CacheFunnelTestCase suite against the array store.
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.
Runs the abstract CacheFunnelTestCase suite against the file store.
…ests
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().
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.
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).
…s serializers
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR ports Laravel's cache funnel feature to Hypervel.
What changed
Cache::funnel(...)for limiting how many processes can run a block of work at the same time.Hypervel\Cache\Limiters\LimiterTimeoutException.swoole,stack, andsession, failing through the normal lock unsupported path.Redis optimization
Laravel's generic cache funnel checks each slot through the cache lock API. On Redis, that can mean up to one Redis call per slot on each retry. For example, a funnel with a limit of 50 could need up to 50 Redis calls just to find that all slots are full.
Hypervel keeps the same public API, but uses a Redis-specific path when the cache store is Redis:
releaseAfter(0)works as a permanent slot and is still released after the callback finisheslimit(0)and negative limits return immediately without calling RedisThis gives
Cache::funnel()on Redis the same basic Redis call pattern as the existing Redis concurrency limiter: one Redis call to acquire and one Redis call to release.Tests
The test coverage includes:
releaseAfter(0)DateIntervalandDateTimeInterfacerelease times