Skip to content

feat: port Cache::funnel from Laravel#381

Merged
binaryfire merged 21 commits into0.4from
feat/cache-funnel
May 2, 2026
Merged

feat: port Cache::funnel from Laravel#381
binaryfire merged 21 commits into0.4from
feat/cache-funnel

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

This PR ports Laravel's cache funnel feature to Hypervel.

What changed

  • Added Cache::funnel(...) for limiting how many processes can run a block of work at the same time.
  • Added the cache limiter classes and Hypervel\Cache\Limiters\LimiterTimeoutException.
  • Added funnel support to the cache repository and facade.
  • Added tests for the supported cache stores.
  • Kept unsupported stores, such as swoole, stack, and session, failing through the normal lock unsupported path.
  • Moved shared Redis lock and limiter Lua scripts into the Redis package so Redis locking code can reuse the same scripts.

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:

  • one Lua script checks all slots and claims the first free slot
  • owner values are packed before the Lua script runs, so phpredis serializer and compression settings still work
  • release still uses the normal Redis lock release path
  • releaseAfter(0) works as a permanent slot and is still released after the callback finishes
  • limit(0) and negative limits return immediately without calling Redis

This 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:

  • the ported Laravel cache funnel behavior
  • Redis funnel behavior under coroutine concurrency
  • Redis serializer and compression combinations
  • zero and negative limits
  • releaseAfter(0)
  • DateInterval and DateTimeInterface release times
  • memoized and failover cache stores
  • direct Redis concurrency limiter regressions after the Lua script move

binaryfire added 21 commits May 2, 2026 17:35
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.
@binaryfire binaryfire merged commit e0de049 into 0.4 May 2, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant