From ed56e102c0d029912d963cda0447a5cd13a2fdef Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:43:25 +0000 Subject: [PATCH 01/41] Add rate limiter package implementation plan --- .../2026-08-04-1543-rate-limiter-package.md | 1029 +++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 docs/plans/2026-08-04-1543-rate-limiter-package.md diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md new file mode 100644 index 000000000..5a275a57f --- /dev/null +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -0,0 +1,1029 @@ +# First-Party `hypervel/rate-limiter` Package Plan + +## Plan status + +This is the implementation plan for replacing Hypervel's cache-bound rate limiter with a dedicated first-party `hypervel/rate-limiter` component. It is a final-codebase plan, not a compatibility or phased-migration plan. The implementation must remove the old rate-limiter implementation and every obsolete alternate path in the same change. There must be one canonical API and no aliases, shims, deprecated wrappers, stale tests, stale documentation, or TODO entries describing code that no longer exists. + +The plan deliberately does not preserve source compatibility. Hypervel 0.4 is a work in progress, and the desired end state is the code that would have been written if this package had existed from the start. + +## Desired outcome + +Create `src/rate-limiter` as a split first-party package with these properties: + +- Canonical namespace: `Hypervel\RateLimiter`. +- Canonical facade: the existing `Hypervel\Support\Facades\RateLimiter`, resolving the new `Hypervel\RateLimiter\RateLimiter` manager. +- No `Hypervel\Cache\RateLimiter` class, no `Hypervel\Cache\RateLimiting` namespace, and no aliases back to either namespace. +- The cache repository is not part of the driver contract. Each driver owns its atomic state transition and native storage representation. +- Fixed-window admission, GCRA-backed leaky-bucket admission, and capped exponential failure backoff are distinct typed policies. There is no generic `strategy` string plus nullable bag of unrelated parameters. +- Redis, Swoole, database, and array are first-party stores. File and generic cache stores are intentionally not supported. +- Redis performs one pooled checkout and one `EVALSHA` in the steady-state admission path, with the existing `evalWithShaCache()` NOSCRIPT fallback on the first execution per Redis node. +- Swoole performs one short striped-lock critical section over native integer columns, with no PHP serialization. +- The database store uses a dedicated `rate_limits` table, not `cache` or `cache_locks`, and performs an atomic transaction with a row lock. +- The public decision result contains everything middleware needs. No caller performs a second read to calculate remaining capacity or retry timing after a consume. +- Store failures fail closed by propagating an exception. The package must never silently bypass a configured limit or fall back to a weaker/local store. +- Route, queue, Fortify, exception-reporting, and Reverb consumers are rewritten to use the new API directly. + +## Explicit non-goals + +- Do not retain Laravel's primitive `tooManyAttempts($key, $maxAttempts)` / `hit($key, $decay)` API merely for parity. That split API cannot express one atomic decision and is the central design problem being removed. +- Do not build a compatibility layer under `Hypervel\Cache`. +- Do not introduce a generic cache-backed driver. +- Do not add a file driver. A correct file implementation would require a dedicated locked state format, and it would add a low-throughput production surface without an unmet use case because the database driver is the portable shared fallback. +- Do not implement token bucket, sliding-log, sliding-window-counter, linear backoff, Fibonacci backoff, reservations, blocking waits, or distributed multi-limit transactions in this change. The type and store boundaries must permit future additions, but speculative algorithms must not produce unused code. +- Do not introduce a process-global service/version registry as part of this package. The related framework capability work is separately recorded in `docs/todo.md`. +- Do not use Redis Functions as an alternate deployment mode. Functions require server-side library lifecycle/permissions and create an operational branch without improving the one-round-trip steady-state contract over cached Lua scripts. + +## Decisions + +### 1. A dedicated package replaces the cache implementation + +The package must be `hypervel/rate-limiter`, not an extension inside `hypervel/cache`. + +The generic cache contract is a poor atomic-algorithm boundary: + +- Laravel/Hypervel's current fixed-window path performs separate timer add, counter add, increment, and sometimes put operations. +- `RedisStore` still serializes/deserializes ordinary values and only has special-case bypasses for counters. +- `SwooleStore` serializes values, takes a row lock, unserializes, increments, and serializes again. +- `DatabaseStore` serializes payloads and implements generic cache semantics rather than an admission decision. +- `FileStore` adds filesystem I/O, payload serialization, and locking concerns. + +A dedicated driver can instead expose exactly one atomic state transition and return the entire decision. + +### 2. There is one canonical namespace and one facade + +Applications import policy classes from `Hypervel\RateLimiter` and may continue using `Hypervel\Support\Facades\RateLimiter`. Constructor injection uses `Hypervel\RateLimiter\RateLimiter`. + +Do not add a `Hypervel\Cache\RateLimiter` alias or wrapper. Two class locations would be more confusing for LLMs because the Laravel-looking location would expose a different API. Add the namespace/API difference to `AGENTS.md` instead. + +### 3. Stores are drivers; policies are typed definitions + +The manager resolves named stores from `rate-limiter.stores`. Each store has a `driver` string, following Laravel's manager/config conventions. The algorithm is selected by the policy object's concrete type: + +- `Limit` is the familiar fixed-window policy. +- `LeakyBucket` is the smoothed admission policy, implemented with GCRA. +- `Unlimited` bypasses storage. +- `Backoff::exponential(...)` returns an `ExponentialBackoff` failure policy. + +Adding an admission algorithm later means adding a typed policy and implementing its state transition in each supported store. That is intentional: atomic algorithms and their storage primitives are coupled. A single strategy DTO would hide that coupling and accumulate irrelevant fields. + +### 4. Backoff is not an admission algorithm + +Exponential backoff is based on failures and success/reset events; fixed window and leaky bucket decide whether a unit of work may be admitted. They therefore use different operations: + +- admission: `consume`, `inspect`, `clear`; +- failure penalty: `inspect`, `recordFailure`, `clear`. + +They may share a store and decision interface, but `recordFailure` must remain explicit. A request must never increase exponential backoff merely because it was attempted. + +Fibonacci is not included. It is occasionally used as a retry-delay sequence, but it is not a common server-side rate-limiting algorithm. For request admission, token bucket, leaky bucket/GCRA, and sliding-window variants are the established alternatives. For distributed client retries, capped exponential backoff with jitter is the common pattern; jitter is not appropriate for the deterministic server-enforced lockout state in this package. + +### 5. No strategy or driver enums + +Do not add enums for driver names or algorithms: + +- driver names are an open extension point via `extend()` and must remain config/env strings; +- `UnitEnum|string` is accepted by `store()`, `for()`, and relevant middleware APIs, preserving convenient application enums without closing extension; +- concrete policy classes already provide compile-time strategy identity; +- a `Strategy` enum would duplicate the class hierarchy and invite invalid parameter combinations. + +An enum should be introduced later only if a genuinely closed domain appears. None is needed in the initial package. + +### 6. Database is a first-class shared fallback with its own table + +The database store is worthwhile for applications that need cross-node correctness but do not operate Redis. It must use a dedicated table because cache-table rows are serialized generic payloads with cache expiration semantics and cannot express an atomic limiter transition efficiently. + +Database is the default store for a fresh application because it is shared across application nodes and does not assume Redis is running. Documentation must clearly recommend Redis for high-throughput distributed limiting and Swoole for deliberate single-node/process-cluster limiting. + +### 7. Redis uses portable Lua now; native `INCREX` is a tracked future optimization + +The initial Redis implementation uses portable Lua for all algorithms. Do not ship runtime command/version branching. + +Verified constraints as of 2026-08-04, including direct `COMMAND INFO INCREX`/execution checks against the official `redis:8.6-alpine`, `redis:8.8-alpine`, and `valkey/valkey:9-alpine` images: + +- Redis 8.8 provides `INCREX`; Redis 8.6 does not. +- Valkey 9 does not provide `INCREX`. +- Valkey PR [#3253](https://github.com/valkey-io/valkey/pull/3253) remains open and adds expiry/existence options to the INCR family; it is related but does not currently provide Redis `INCREX`'s bounded-operation/result semantics. +- phpredis 6.3.0 exposes neither `Redis::increx()` nor `RedisCluster::increx()`. +- `Redis::rawCommand()` bypasses `OPT_PREFIX`; this was verified against Redis 8.8 (`raw-key` remained unprefixed while an EVAL key became `prefix:eval-key`). +- `RedisCluster::rawCommand()` has the different signature `rawCommand($key_or_address, $command, ...$args)`, so a generic standalone raw-command call is not cluster-safe. +- Direct `INCREX` returns the new counter and applied increment but not the remaining TTL required for `Retry-After`/reset metadata. A second command, a pipeline/transaction, or a Lua wrapper would still be needed for the framework's full result. + +A local indicative Redis 8.8 `redis-benchmark` run (300,000 requests, 50 clients, random keyspace) measured approximately 61.6k requests/s for direct `INCREX`, 48.4k for a portable full-result Lua script, and 50.5k for Lua wrapping `INCREX` plus `PTTL`. These figures are not a release benchmark, but they show that the native primitive may eventually be useful while also showing that full framework semantics reduce the direct-command advantage. + +`docs/todo.md` now records the prerequisites and future benchmark. The implementation must also put this focused comment immediately beside the portable fixed-window Redis script: + +```php +// @TODO Re-benchmark a native INCREX implementation when equivalent bounded +// increment-with-expiry semantics are supported by Redis and Valkey and exposed +// by phpredis with prefix-aware Redis Cluster routing. Keep the portable Lua +// path until then; docs/todo.md records the compatibility details. +``` + +Remove the comment and the documentation TODO together when the native implementation actually replaces Lua. + +## Research findings that shape the design + +### Current Laravel (local snapshot `examples/laravel/framework`, commit `2c410561c2`, 2026-07-30) + +- `Illuminate\Cache\RateLimiter` accepts only a cache repository and exposes no strategy parameter. +- `Limit` contains only `key`, `maxAttempts`, `decaySeconds`, `afterCallback`, and `responseCallback`. +- The generic path is fixed-window and split across several cache calls. +- Laravel mutates duplicate keyed limits to fallback keys based on attempts/decay. +- Laravel's Redis throttle middleware is a separate implementation choice rather than a general strategy/store abstraction. + +Conclusion: preserve the approachable `RateLimiter::for(...)`, `Limit::perMinute(...)`, `by`, `after`, and `response` vocabulary, but do not copy the storage architecture. + +### Current Hypervel + +- `src/cache/src/RateLimiter.php` is a Laravel-derived fixed-window cache implementation with a Hypervel scope resolver and xxh128 key hashing. +- The normal route middleware first checks all limits, then records hits, then reads again for response headers. The operation is not one atomic decision. +- `ThrottleRequestsWithRedis` uses `DurationLimiter::acquire()` atomically but stores `$decaysAt` and `$remaining` on a worker-lifetime singleton. Same-key concurrent requests can overwrite one another's header state, and unique keys accumulate indefinitely. +- The Redis middleware ignores `Limit::after()`. +- `RedisConnection::callEvalsha()` loads before each call, but the public `RedisConnection::evalWithShaCache()` already implements the correct SHA/NOSCRIPT fallback. The new package must use the latter and must not add a duplicate script cache. +- `DurationLimiter` is also used by `Redis::throttle()` and concurrency/queue APIs. It is not dead when request middleware stops using it and must not be deleted wholesale. + +Current consumers that must be migrated: + +- `src/routing/src/Middleware/ThrottleRequests.php` and `ThrottleRequestsWithRedis.php`; +- `src/queue/src/Middleware/RateLimited.php`, `RateLimitedWithRedis.php`, `ThrottlesExceptions.php`, and `ThrottlesExceptionsWithRedis.php`; +- `src/fortify/src/LoginRateLimiter.php` and the Fortify provider stub; +- `src/foundation/src/Exceptions/Handler.php`; +- `src/reverb/src/Protocols/Pusher/Server.php`; +- `src/support/src/Facades/RateLimiter.php`; +- `src/cache/src/CacheServiceProvider.php` and `cache.limiter` config; +- all related tests, Boost documentation, facade metadata, and package dependency metadata. + +### Archived Hypervel 0.3 package (`packages/hypervel/_archive/src/rate-limiter`) + +Ideas to retain: + +- a decision object returned from a single call; +- native Redis Lua for atomic admission; +- support for weighted consumption and multiple policies; +- dedicated typed leaky-bucket configuration/state. + +Defects not to port: + +- application-supplied whole-second time inside Lua; +- JSON state encoding; +- fractional admission that checks the old level but can store a level over capacity; +- a timeout race that can return a wrong retry time; +- multi-key scripts that do not account for Redis Cluster slots; +- incomplete SHA execution support; +- validation that permits invalid algorithm state. + +### `examples/ratelimiter` + +Useful ideas are the fluent bucket vocabulary and resolver separation. Do not port its cache read/modify/write algorithm, leak timer calculation, or event-heavy hot path. The implementation is not atomic under concurrency. + +### Requested third-party packages + +- `Oltrematica/laravel-rate-limiter` is primarily configuration/wrapping and does not supply a strong atomic algorithm or reusable driver boundary. +- `milenmk/laravel-rate-limiting` implements linear, Fibonacci, and exponential lockout growth, but reconstructs history with multiple cache calls/O(N) replay and is not concurrency-safe. Its useful lesson is to distinguish failure penalties from ordinary admission. + +### Other references + +- Symfony RateLimiter has useful typed policies, weighted `consume($tokens)`, and rich decision results. Its generic storage-plus-lock model is not suitable for Hypervel's Redis hot path. +- `go-redis/redis_rate` demonstrates the compact GCRA model and one-call result shape used for leaky-bucket semantics. +- Cloudflare's GCRA description supports using a theoretical-arrival-time representation for scalable smooth limiting. +- Generic PHP cache implementations reviewed during research either require external locks or contain read/modify/write races; none provides a better backend boundary than dedicated drivers. + +Authoritative references to retain in implementation notes/tests where relevant: + +- Laravel rate limiting: +- Symfony RateLimiter: +- Redis scripting: +- Redis Functions trade-offs: +- Redis `TIME`: +- Redis `INCREX`: +- Redis Cluster key-slot rules: +- Google retry/backoff guidance: +- OWASP authentication throttling guidance: +- Cloudflare rate-limiting algorithms: + +## Public API + +### Manager and store selection + +`Hypervel\RateLimiter\RateLimiter` extends `MultipleInstanceManager`. It owns named limiter callbacks and resolves per-store `Limiter` instances. The facade delegates unknown methods to the default store through the manager, matching Laravel manager conventions. The package name is `hypervel/rate-limiter`, not `hypervel/rate-limit`: the former names the component being provided, while `RateLimit` is the policy abstraction consumed by that component. + +```php +namespace Hypervel\RateLimiter; + +/** @mixin \Hypervel\RateLimiter\Limiter */ +final class RateLimiter extends MultipleInstanceManager +{ + public function store(UnitEnum|string|null $name = null): Limiter; + + public function getDefaultInstance(): string; + + public function setDefaultInstance(string $name): void; + + public function getInstanceConfig(string $name): array; + + public function for(UnitEnum|string $name, Closure $callback): static; + + public function limiter(UnitEnum|string $name): ?Closure; + + public function resolveKeyScopeUsing(?Closure $resolver): void; + + // Inherited: extend(string $driver, Closure $callback): static. +} +``` + +Examples: + +```php +RateLimiter::consume( + Limit::perMinute(60)->by("user:{$user->id}"), +); + +RateLimiter::store('redis')->consume( + LeakyBucket::perSecond(100)->burst(200)->by("api:{$token}"), +); +``` + +`store()` accepts `UnitEnum|string|null` and normalizes enums through `enum_value()`. Built-in `create*Driver()` methods and custom `extend()` callbacks return a `Contracts\Store`; the manager's protected `resolve()` wraps that store in one `Limiter`. This keeps key resolution and unlimited handling out of drivers while giving third-party drivers a small native-operation contract. A custom creator therefore has the familiar shape `fn (Application $app, array $config): Store` rather than having to construct a framework wrapper. + +Resolved stores capture immutable configuration. `setDefaultInstance()`, `for()`, `resolveKeyScopeUsing()`, `extend()`, `forgetInstance()`, and `purge()` are explicitly boot/test-only under the repository's coroutine rules. The `Limiter` receives a resolver closure owned by the manager so a named-policy key can include the limiter name without mutating the policy object. + +### Fixed-window policy + +Keep Laravel's most recognizable policy name and factories while making the value immutable from the caller's perspective. Fluent modifiers return a new copy rather than mutating a cached definition. + +```php +use Hypervel\RateLimiter\Limit; + +$limit = Limit::perMinute(120) + ->by("uploads:{$user->id}") + ->cost(5) + ->after(fn (Response $response): bool => $response->isSuccessful()) + ->response(fn (Request $request, array $headers): Response => response('Slow down', 429, $headers)); +``` + +Factories: + +- `perSecond(int $maxAttempts, int $decaySeconds = 1)`; +- `perMinute(int $maxAttempts, int $decayMinutes = 1)`; +- `perMinutes(int $decayMinutes, int $maxAttempts)`; +- `perHour(int $maxAttempts, int $decayHours = 1)`; +- `perDay(int $maxAttempts, int $decayDays = 1)`; +- `none(): Unlimited`. + +Modifiers shared by admission policies: + +- `by(Stringable|UnitEnum|string|int $key): static` (normalized once to a string with `enum_value()` for enums); +- `cost(int $cost): static` (positive, and no greater than the policy's capacity); +- `globally(bool $global = true): static` (bypasses Hypervel's named key-scope resolver); +- `after(callable $callback): static`; +- `response(callable $callback): static`. + +Retain Laravel's convenient readable-property shape, but make the properties `public readonly`: `key`, `cost`, `global`, `afterCallback`, and `responseCallback` on `RateLimit`; `maxAttempts` and `decaySeconds` on `Limit`; and `rate`, `periodMicroseconds`, and `burst` on `LeakyBucket`. Each modifier constructs/copies a fully validated new value. Do not expose mutable public state, reflection-based cloning, or a generic `options` array. Internal stores may read these typed properties directly without getter-call overhead. + +`globally()` replaces `GlobalLimit`; do not carry a second class solely to mark scope behavior. + +### Leaky-bucket policy + +```php +use Hypervel\RateLimiter\LeakyBucket; + +RateLimiter::for('api', function (Request $request) { + return LeakyBucket::perSecond(100) + ->burst(200) + ->by($request->user()?->getAuthIdentifier() ?? $request->ip()); +}); +``` + +Factories mirror `Limit`: `perSecond`, `perMinute`, `perMinutes`, `perHour`, and `perDay`. Their first argument is the sustained number of tokens emitted over the period. `burst(int $capacity)` is the total immediately available capacity, not “extra” capacity. It defaults to `1`, producing strict smoothing; callers that want a burst must opt in explicitly. `cost()` cannot exceed `burst()`. + +Document that the backend implementation is GCRA, which provides leaky-bucket behavior with constant state rather than running a leak timer. + +### Decisions + +```php +final readonly class LimitResult implements Decision +{ + public function allowed(): bool; + public function denied(): bool; + public function limit(): int; + public function remaining(): int; + public function retryAfter(): int; + public function resetAfter(): int; +} +``` + +All public durations are integer seconds rounded up from the driver's finer internal precision: + +- `retryAfter()` is `0` when the requested cost was accepted and otherwise is the minimum wait until that cost can be accepted; +- `resetAfter()` is the remaining fixed-window duration or time until a leaky bucket is full; +- `limit()` is fixed-window capacity or leaky-bucket burst capacity; +- `remaining()` is immediately consumable whole-token capacity after the decision. + +For `inspect()`, no consumption occurs, so `remaining()` is the capacity available in the observed state; `allowed()` answers whether this policy's configured cost could be consumed now. For an accepted `consume()`, remaining is measured after the cost is committed. For a denied consume, it is the unchanged current capacity. This distinction must be identical across stores and explicit in result tests. + +The common `Decision` contract contains `allowed()`, `denied()`, and `retryAfter()` so middleware can handle admission and backoff results without a loose array. + +### Limiter operations + +```php +final class Limiter +{ + public function getStore(): Contracts\Store; + + public function consume(RateLimit $limit, UnitEnum|string|null $limiterName = null): LimitResult; + + public function inspect(RateLimit|Backoff $policy, UnitEnum|string|null $limiterName = null): LimitResult|BackoffResult; + + public function attempt(RateLimit $limit, Closure $callback, UnitEnum|string|null $limiterName = null): mixed; + + public function recordFailure(Backoff $backoff, UnitEnum|string|null $limiterName = null): BackoffResult; + + public function clear(RateLimit|Backoff $policy, UnitEnum|string|null $limiterName = null): bool; +} +``` + +`RateLimit` is the abstract admission-policy base implemented by `Limit`, `LeakyBucket`, and `Unlimited`; `Backoff` is a separate failure-policy base. `consume()` is the normal one-call atomic operation. `inspect()` never mutates state. `attempt()` atomically consumes before invoking the callback and returns `false` on denial; if a callback returns `null`, it returns `true`, preserving Laravel's convenient semantics. If the callback throws, the accepted token remains consumed. Code that should charge only on failure or on a response predicate must use `inspect()` followed by the appropriate explicit operation. + +The optional `limiterName` is only identity context for a policy obtained from `RateLimiter::for()`. Routing and queue middleware must pass it; direct calls omit it. It is deliberately not a mutable hidden field on a policy and not the selected store name. This closes the collision between two named limiters that return otherwise identical policies while keeping direct policy use terse. + +### Exponential backoff + +```php +use Hypervel\RateLimiter\Backoff; + +$backoff = Backoff::exponential( + after: 5, + initialDelay: 1, + maxDelay: 300, + resetAfter: 3600, +)->by("login:{$username}:{$ip}"); + +if (RateLimiter::inspect($backoff)->denied()) { + // Return the result's retryAfter(). +} + +try { + authenticate(); + RateLimiter::clear($backoff); +} catch (AuthenticationException $exception) { + $result = RateLimiter::recordFailure($backoff); + throw $exception; +} +``` + +`Backoff::exponential(...)` returns a typed `ExponentialBackoff`. The fifth failure in the example creates the initial one-second block. Each subsequent failure after the block is eligible doubles the delay, capped at `maxDelay`. `resetAfter` resets failure history after inactivity and must be at least `maxDelay`. A success calls `clear()`. + +As with admission policies, expose validated `public readonly` fields (`key`, `after`, `initialDelay`, `maxDelay`, and `resetAfter`) and make `by()` return a new value. Keep integer seconds at the public boundary and convert once to the driver's internal microseconds. + +`BackoffResult` exposes `allowed/denied`, `failures`, and `retryAfter`. Do not add jitter: server-enforced lockout state should be deterministic. Client retry code may apply jitter independently. + +### Named limiter resolution + +Keep `RateLimiter::for()` and the existing optional named-key scope resolver. Policy objects are immutable, and named resolution must not rewrite duplicate objects. + +The physical identity includes: + +1. a package-domain/version segment and the configured rate-limiter prefix; +2. named limiter name when present; +3. optional resolved Hypervel scope for a named limiter unless `globally()` was selected; +4. caller key from `by()` (an empty key intentionally means a shared/global policy); +5. policy type and canonical parameters. + +Each segment is domain-tagged and length-prefixed before the entire identity is hashed with `xxh128`. Domain tags distinguish, for example, a limiter name from a caller key; length prefixes keep arbitrary normalized strings injective. Keys are normalized to strings first, so equivalent `1`, `'1'`, a stringable `'1'`, and an enum value `'1'` intentionally identify the same bucket. The configured prefix is a pre-hash application namespace, so every driver still receives the same fixed 32-character lowercase hexadecimal key. Redis's connection-level `OPT_PREFIX` and a database connection's table prefix remain outside this digest and are applied exactly once by those components. + +Policy callbacks and request cost are excluded from the policy fingerprint; the global-scope flag is included because it changes policy identity. Including stable policy parameters means two limits with the same `by()` value but different windows/algorithms naturally have different state, and changing policy configuration starts clean state while the old TTL expires. The Laravel fallback-key mutation is unnecessary. Add golden-vector tests for the canonical encoding so an apparently harmless refactor cannot orphan all active state. + +Always hash physical identities. Remove `ThrottleRequests::shouldHashKeys()` and its process-global switch. The Swoole key itself remains a fixed 32-character digest, safely below Swoole Table's key limit. + +## Internal package architecture + +Target layout (names may move only if implementation reveals a concrete repository convention conflict): + +```text +src/rate-limiter/ +├── README.md +├── composer.json +├── config/ +│ └── rate-limiter.php +└── src/ + ├── ArrayStore.php + ├── Backoff.php + ├── BackoffResult.php + ├── Console/ + │ ├── PruneCommand.php + │ ├── RateLimiterTableCommand.php + │ └── stubs/rate-limits.stub + ├── Contracts/ + │ ├── Decision.php + │ ├── PrunableStore.php + │ └── Store.php + ├── DatabaseStore.php + ├── Exceptions/ + │ ├── InvalidRateLimitException.php + │ └── SwooleTableFullException.php + ├── ExponentialBackoff.php + ├── LeakyBucket.php + ├── Limit.php + ├── Limiter.php + ├── LimitResult.php + ├── RateLimit.php + ├── RateLimiter.php + ├── RateLimiterServiceProvider.php + ├── RedisStore.php + ├── Swoole/ + │ ├── CreateTables.php + │ ├── PruneTables.php + │ ├── TableManager.php + │ ├── TableState.php + │ └── Timer.php + ├── SwooleStore.php + └── Unlimited.php +``` + +Avoid an `Algorithms` service hierarchy in the first implementation. The policy classes hold validated immutable configuration; each store uses a small exhaustive `instanceof` dispatch to its private fixed-window, leaky-bucket, or backoff transition. An unsupported policy throws `InvalidRateLimitException` rather than silently changing behavior. + +`RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver using the then-current validated prefix plus the manager-owned optional scope callback. Built-in `createArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. Resolve/freeze static key configuration when the lazy store wrapper is created; do not read the config repository on every consume. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry. + +### Store contract + +The contract receives an already-resolved fixed-length physical key and validated policy: + +```php +interface Store +{ + public function consume(string $key, RateLimit $limit): LimitResult; + + public function inspect(string $key, RateLimit|Backoff $policy): LimitResult|BackoffResult; + + public function recordFailure(string $key, Backoff $backoff): BackoffResult; + + public function clear(string $key): bool; +} +``` + +```php +interface PrunableStore +{ + public function pruneExpired(int $chunkSize = 1000): int; +} +``` + +`Limiter` intercepts `Unlimited` before dispatch, so a store never reads or writes for it. Keep this contract inside `hypervel/rate-limiter`; external drivers necessarily depend on the package, so moving it to the global contracts package would add indirection without decoupling. + +### Configuration + +```php +return [ + 'default' => env('RATE_LIMITER_STORE', 'database'), + + 'stores' => [ + 'database' => [ + 'driver' => 'database', + 'connection' => env('RATE_LIMITER_DB_CONNECTION'), + 'table' => env('RATE_LIMITER_DB_TABLE', 'rate_limits'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('RATE_LIMITER_REDIS_CONNECTION', 'default'), + ], + + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => (int) env('RATE_LIMITER_SWOOLE_ROWS', 65536), + 'conflict_proportion' => 0.2, + 'prune_interval' => 60000, + ], + + 'array' => [ + 'driver' => 'array', + ], + ], + + 'prefix' => env('RATE_LIMITER_PREFIX', app_id() . '_rate_limiter'), +]; +``` + +`RateLimiterServiceProvider::mergeableOptions('rate-limiter')` returns `['stores']`, so applications can add custom named stores without losing defaults. Use typed config getters and validate every store at resolution. Publish configuration and expose the database migration generator/prune commands using the package's normal provider conventions. + +`prefix` is an application namespace included in the canonical identity before its final hash; it is not concatenated onto the 32-character physical key. This preserves cross-application isolation without variable-length Swoole keys. It is separate from Redis `OPT_PREFIX` and database table prefixes. + +Do not add algorithm defaults to global config. Rates belong to typed application policy definitions, not storage configuration. + +Policy construction performs store-independent range validation against the strictest shared numeric representation, including Redis Lua's largest exactly representable integer (`9_007_199_254_740_991`) and signed 64-bit Swoole/database columns. Driver operations then validate time-dependent additions (for example `now + emission * burst`) before mutation. Reject an unrepresentable policy with `InvalidRateLimitException`; do not add arbitrary-precision math, saturate silently, or let the same policy work on one first-party store and corrupt on another. + +## Algorithm specifications + +### Fixed window + +Semantics match Laravel's first-hit-anchored interval rather than a calendar-aligned window: + +1. An absent/expired key starts a window with the requested cost and the configured duration. +2. An existing key accepts only if `current + cost <= maxAttempts`. +3. A denied request does not increment the counter or extend the window. +4. Remaining capacity is `maxAttempts - current` after an accepted operation and the current remaining capacity after denial. +5. Retry/reset is the existing TTL rounded up. + +All drivers must implement the same boundary behavior, including weighted costs equal to capacity and rejected costs over capacity. + +### Leaky bucket / GCRA + +Use integer microseconds and one theoretical-arrival-time (TAT) state value: + +```text +emission = ceil(period_microseconds / rate) +candidate_tat = max(stored_tat, now) + emission * cost +allowed_at = candidate_tat - emission * burst +allowed = now >= allowed_at +``` + +On acceptance, persist `candidate_tat` with a TTL long enough for the bucket to become completely full. On denial, leave stored state unchanged. Retry time is `allowed_at - now`, rounded up to seconds. Remaining immediate capacity is the number of whole emissions between the stored debt and `now + emission * burst`, clamped to `[0, burst]`. + +Use the post-operation TAT for an accepted consume and the stored TAT for inspect/denial: + +```text +remaining = clamp(floor((now + emission * burst - effective_tat) / emission), 0, burst) +reset = max(effective_tat - now, 0) +``` + +An absent/fully drained bucket uses `effective_tat = now`, so inspect reports the full burst. Persist the accepted state for `ceil(reset / 1000)` milliseconds on Redis (minimum one millisecond while state is non-empty) and exact microseconds on numeric local/database stores. A driver may encounter a physically present but logically drained record and must treat it as empty without extending stale state. + +Use Redis `TIME`, Swoole `hrtime(true)`, and database server time (except local SQLite, which uses wall-clock microseconds) so distributed Redis/database decisions do not depend on the application node's clock. Clamp negative elapsed time to zero defensively. Validate microsecond resolution, integer overflow, Redis Lua's exact-integer range, positive rates/periods, and burst/cost limits before accessing storage. + +### Exponential backoff + +State is failure count, blocked-until time, and expiration/inactivity time. On `recordFailure`: + +1. Reset stale state whose inactivity deadline has passed. +2. Increment failures. +3. If failures are below `after`, return allowed with no delay. +4. Otherwise calculate `min(initialDelay * 2 ** (failures - after), maxDelay)` without an overflowing exponent. +5. Set blocked-until to now plus the delay. +6. Set expiry to at least both blocked-until and now plus `resetAfter`. + +`inspect` never increments failures. `clear` removes all state. Recording a failure while already blocked is allowed only when the application explicitly calls it; normal callers inspect first and will not execute blocked work. + +## Driver details + +### Redis store + +- Hold the Redis factory and connection name, and resolve the lightweight cached proxy by name at operation start rather than retaining it inside the store. A `RedisManager::purge()` can then replace the proxy/pool generation without leaving the rate limiter pinned to a stale proxy; the steady path pays only the manager's array lookup before its one pool checkout. +- Use `RedisProxy::withConnection()` and `RedisConnection::evalWithShaCache()`. +- One `EVALSHA` is the steady-state operation. A NOSCRIPT response may cause one fallback `EVAL` for the first execution on a node. +- Pass exactly one key to each script. Redis Cluster can route it without cross-slot behavior; do not invent multi-key hash-tag grouping. +- Rely on the EVAL key path for configured phpredis prefixing. Do not use raw commands or manually duplicate the Redis connection prefix. +- Store raw integer/string/hash state directly. Never pass it through cache serialization/compression. +- Use one Redis string plus TTL for a fixed counter, one Redis string TAT plus TTL for GCRA, and one small hash (`failures`, `available_at`) plus inactivity TTL for backoff. The policy fingerprint fixes the type for a key, so no strategy tag or JSON envelope is needed. +- Set TTL atomically in the script and return accepted, limit, remaining, retry microseconds, and reset microseconds in the same response. +- Validate every returned tuple's arity, integer types, flags, and non-negative/range invariants before constructing a result; `false`, `nil`, truncation, or malformed data must throw rather than cast into an allowed decision. +- Keep script bodies as private constants or dedicated internal operation classes only if file length warrants it. Do not build a generic script framework. + +Fixed-window Lua shape: + +```lua +local cost = tonumber(ARGV[1]) +local limit = tonumber(ARGV[2]) +local duration = tonumber(ARGV[3]) + +local function start_window() + redis.call('SET', KEYS[1], cost, 'PX', duration) + return {1, limit - cost, duration} +end + +local raw = redis.call('GET', KEYS[1]) + +if not raw then + return start_window() +end + +local current = tonumber(raw) +if not current or current < 0 or current > limit or current % 1 ~= 0 then + return redis.error_reply('CORRUPT rate limiter counter') +end + +local ttl = redis.call('PTTL', KEYS[1]) +if ttl == -1 then + return redis.error_reply('CORRUPT rate limiter counter has no expiry') +end +if ttl <= 0 then + return start_window() +end + +local next = current + cost + +if next > limit then + return {0, limit - current, ttl} +end + +redis.call('SET', KEYS[1], next, 'KEEPTTL') +return {1, limit - next, ttl} +``` + +The production script must keep every result numeric, provide an inspect mode without creating a missing key, and include the required `@TODO` immediately beside it. A present fixed-window key with a non-integer value, negative/out-of-range count, or no expiry (`PTTL == -1`) is corrupt: raise a Lua error and propagate it rather than deleting the key and potentially failing open. A zero/expired TTL is a real boundary condition and starts a fresh window atomically. Validate impossible costs in PHP so the script never creates an over-capacity first value. + +Do not alter `RedisConnection::callEvalsha()` for this package; the correct `evalWithShaCache()` path already exists and has real Redis integration coverage. + +### Swoole store + +- Own a dedicated `Swoole\Table`; do not reuse `SwooleStore` or `SwooleTableManager` from cache. +- Columns are `value`, `available_at`, and `expires_at`, all `Table::TYPE_INT` with an explicit 8-byte width. +- Use a fixed 32-character hashed key. +- Bind one package-local `Swoole\TableManager` singleton. `CreateTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. +- Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. If a running worker requests a Swoole store whose table was not created before fork, throw a lifecycle/configuration exception rather than silently allocate a worker-private table. Console/tests may explicitly initialize a table before concurrent use. +- Use 64 striped locks and a short spin/backoff timeout pattern equivalent to the proven cache `SwooleTableState`, but keep the limiter table independent and numeric. +- Perform read/check/write within one row lock. No serialization, closures, cache repository, or generic eviction policy appears in the hot path. +- Use `intdiv(hrtime(true), 1000)` for a host-monotonic microsecond clock shared by workers. +- Expired rows are reclaimed on access. Worker 0 owns a periodic expiry scan timer; stop it on worker exit. Timer/full-table pruning must lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. +- If insertion fails, perform one synchronous expired-row prune and retry once. If the table remains full of live rows, throw `SwooleTableFullException`. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. +- Document that Swoole is host-local and is not a distributed rate limiter across servers. + +### Database store + +Hold `ConnectionResolverInterface` and the configured connection name, not a concrete pooled `Connection`. Resolve once at operation start; Hypervel's coroutine resolver then retains that connection for the full transaction/operation and releases it normally, while pool purge/reconnect can still supply a new generation later. + +Migration generated by `make:rate-limiter-table` (`rate-limiter:table` alias): + +```php +Schema::create('rate_limits', function (Blueprint $table) { + $table->char('key', 32)->primary(); + $table->unsignedBigInteger('value')->default(0); + $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('expires_at')->index(); +}); +``` + +State mapping: + +- fixed window: `value = consumed`, `available_at = reset_at`, `expires_at = reset_at`; +- leaky bucket: `value = TAT`, `available_at = 0`, `expires_at = full_refill_at`; +- exponential backoff: `value = failures`, `available_at = blocked_until`, `expires_at = inactivity expiry`. + +The strategy and parameters are already in the hashed physical key, so a strategy column and JSON payload are unnecessary. This representation is compact, queryable, portable across Hypervel's MySQL, MariaDB, PostgreSQL, and SQLite connections, and avoids serialization. + +Mutating operation: + +```php +return $connection->transaction(function ($connection) use ($key, $policy) { + $connection->table($table)->insertOrIgnore([ + 'key' => $key, + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]); + + $row = $connection->table($table) + ->where('key', $key) + ->lockForUpdate() + ->first(); + + $now = $this->currentTimeInMicroseconds($connection); + + // Compute and persist one validated state transition, then return its result. +}, attempts: 3); +``` + +The initial `insertOrIgnore` solves the first-row race and also obtains SQLite's writer lock before reading; `lockForUpdate` provides row serialization on MySQL/MariaDB/PostgreSQL. Fetch time after lock acquisition so lock wait does not make the decision's timestamp stale. + +`inspect()` is intentionally different: select without inserting or locking, read the clock, and return a best-effort snapshot. Both its row query (`useWritePdo()`) and server-time scalar must use the primary/write PDO so a configured read replica cannot return stale limiter state or a clock from a different server, but it must not create, refresh, delete, or otherwise mutate a row. `clear()` is a direct keyed delete. Only `consume()` and `recordFailure()` use the insert/lock transaction. + +Use database-server microsecond time for MySQL/MariaDB and PostgreSQL: `FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)` for MySQL/MariaDB and `FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint` for PostgreSQL. These exact expressions were executed successfully during plan review against MySQL 9.5, MariaDB 10.11, and PostgreSQL 17. PostgreSQL must use `clock_timestamp()`, not the transaction-start timestamp returned by `CURRENT_TIMESTAMP`. SQLite is local/non-distributed and may use application wall-clock microseconds. Return the scalar as a decimal/integer and range-check it before casting. Keep driver-specific clock SQL private and covered by integration tests; reject unsupported database drivers instead of silently choosing an application clock, and do not create a general database capability framework in this package. + +Add `rate-limiter:prune {store?} {--chunk=1000}` for stores implementing `PrunableStore`. Database pruning uses a fixed current-time cutoff and bounded, portable batches: select at most the configured number of expired keys from the write connection, then delete only those keys that still satisfy `expires_at <= cutoff`. This avoids one unbounded delete/lock while ensuring a concurrently renewed row is skipped. Validate a positive bounded chunk size, stop when a selection is short/empty, and report the total deleted. Recommend scheduling it hourly. Do not add random bulk-delete queries to admission's hot path. + +The prune command resolves `$manager->store($name)->getStore()` and rejects stores that do not implement `PrunableStore`; it must not reach through the container to a concrete database implementation. + +The database driver is correctness-first and will require several SQL statements in a transaction; documentation must not present it as equivalent to Redis throughput. + +### Array store + +- Use an in-process numeric state array and a monotonic clock. +- The name follows Laravel manager conventions, but its scope must be explicit in docs: it is shared for the lifetime of one Hypervel worker, not coroutine-local and not shared across workers. +- It is suitable for tests and deliberately local workloads such as Reverb per-connection message limits, because a connection remains owned by one worker and Reverb clears its key on close. +- Operations contain no suspension point, so a transition is atomic within one cooperative worker; it does not coordinate processes or hosts. +- Lazily discard an expired entry whenever its key is touched. Do not add an abandoned-key scheduler/expiry index in the initial store or perform an unbounded whole-array sweep in a request hot path; rely on explicit `clear()`, Reverb close cleanup, and worker recycling for this deliberately local/test store. +- Do not call this store `worker-array`; that name belongs to cache semantics. + +## Framework consumer refactor + +### Routing + +Rewrite `ThrottleRequests` around `LimitResult`: + +This refactor must close, and then remove, both existing Redis entries in `docs/todo.md`; they are acceptance requirements for this package replacement, not follow-up work: + +- keep remaining/reset/decision data in request-local variables so the singleton middleware has no worker-lifetime per-key state and same-key concurrent requests cannot overwrite one another's headers; +- implement `after()` for Redis-backed policies with non-mutating inspection followed by a conditional atomic consume. + +- Inline `throttle:60,1` creates a fixed `Limit` and consumes it once. +- Named callbacks retain `Response`, `Unlimited`, one policy, or an ordered array of policies. +- For a normal named policy, call `consume($policy, $limiterName)` once and retain the local result for exception/header generation. Inline policies omit the name. +- For a named `after()` policy, call `inspect($policy, $limiterName)` before the downstream handler; after the response, call `consume($policy, $limiterName)` only when the predicate returns true. A concurrent post-response consume may be denied after the response has already been admitted; return headers from that result but do not retroactively throw. Document/test this inherent response-dependent semantic. +- Use `retryAfter()` and `remaining()` from the local result. Remove second reads and all request state from singleton middleware properties. +- Preserve Laravel-compatible headers: successful responses use `X-RateLimit-Limit`/`X-RateLimit-Remaining`; denied responses additionally use `Retry-After` and an absolute `X-RateLimit-Reset` derived from `retryAfter()`. Do not substitute leaky-bucket full-refill `resetAfter()` for the earliest retry time. For leaky policies, document that the limit/remaining header pair describes burst capacity while the policy definition describes the sustained rate. +- With multiple policies, retain the header pair for the most restrictive (lowest remaining) local result and do not overwrite an application-provided lower `X-RateLimit-Remaining` value. +- Ordered multiple policies consume sequentially. If a later policy denies, earlier accepted policies remain consumed. Do not add an all-or-nothing multi-key API: it cannot be implemented consistently across stores and Redis Cluster, and preflight checks would reintroduce races. +- Remove `ThrottleRequestsWithRedis`. +- Remove `Middleware::$throttleWithRedis`, `throttleWithRedis()`, the `$redis` argument to `throttleApi()`, alias branching, kernel priority entries, docs, and tests. Store selection now belongs to `rate-limiter.default`. +- Remove `ThrottleRequests::shouldHashKeys()` because key hashing is an invariant of the new limiter. +- Return raw normalized user/route/IP signatures from `resolveRequestSignature()` and remove its private `formatIdentifier()` helper; the canonical limiter hashes the full identity once. + +### Queue `RateLimited` + +- Resolve the named policy through the manager, then use the configured default store and pass the limiter name into each `consume()` so named identities remain isolated. +- Add a Laravel-style `store(UnitEnum|string $store): static` modifier for jobs that need a non-default limiter store. Serialize only limiter name, selected store, release delay, and release behavior. +- Consume each policy once and release denied jobs using `result->retryAfter() + 3` unless explicitly overridden. +- Preserve ordered partial-consumption semantics for multiple policies, matching routing; do not add a queue-only preflight or rollback protocol. +- Remove `RateLimitedWithRedis`; a named Redis-backed rate-limiter store replaces both the class and connection-specific implementation. + +### Queue `ThrottlesExceptions` + +- Represent its existing “N failures in decay window” behavior with a fixed `Limit` keyed to the job. +- `inspect()` before running the job; `consume()` only when a qualifying exception occurs; `clear()` after success. +- Add the same store selector and remove `ThrottlesExceptionsWithRedis`. +- Persist only the selected store name with the middleware/job; resolve the manager/wrapper inside `handle()` and never serialize a resolved backend store or Redis proxy. +- Keep its existing `backoff()` method for the ordinary queue retry delay; do not conflate that delay with the package's server-enforced `ExponentialBackoff` policy. +- Preserve the Laravel-style optional second callback argument, but always pass the selected package `Limiter` wrapper to `when()` and `report()` callbacks. Redis and non-Redis paths must no longer expose different concrete/cache limiter objects. +- Stop pre-hashing the job class inside `getKey()` because the canonical limiter hashes the complete identity. Replace the misleading Laravel-interoperability prefix/comment—the new state format is intentionally not cache-compatible—with `hypervel:queue:throttles-exceptions:` while retaining `withPrefix()` for callers that choose another namespace. + +### Fortify + +Rewrite `LoginRateLimiter` around a private fixed-policy factory. `inspect()` supplies too-many/remaining/retry state, `consume()` records a failed login, and `clear()` records success. Preserve all five existing public methods: `attempts()` returns `result->limit() - result->remaining()` for this fixed policy, `tooManyAttempts()` uses `denied()`, `increment()` consumes, `availableIn()` returns `resetAfter()` (the current method reports the active window even before denial), and `clear()` removes the policy state. Preserve guard/username/IP scoping. Do not silently change Fortify's fixed five-per-minute policy to exponential backoff; applications may opt into `Backoff` separately. + +Update the Fortify provider stub imports to `Hypervel\RateLimiter\Limit`. + +### Foundation exception reporting + +Use the policy returned by the throttle callback directly: + +```php +$resolvedExceptionKey = $throttle->key ?: 'hypervel:foundation:exceptions:' . $e::class; + +return ! $this->container->make(RateLimiter::class)->attempt( + $throttle->by($resolvedExceptionKey), + fn (): bool => true, +); +``` + +Keep Lottery and Unlimited handling. Remove primitive key/max/decay calls and the handler's redundant pre-hashing/property; the dedicated limiter hashes every canonical identity. + +### Reverb + +Inject/resolve the new manager and use `store('array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. + +### Facade and container + +- `RateLimiterServiceProvider` binds `Hypervel\RateLimiter\RateLimiter` as a singleton manager and merges/publishes config. +- Add it unconditionally to `Hypervel\Support\DefaultProviders` immediately after `RedisServiceProvider` (database is already registered earlier); store creation remains lazy. The framework must not rely on package discovery to obtain its limiter. +- Update the support facade accessor and generated method annotations to the new manager/policies/results. +- Remove only the limiter binding from `CacheServiceProvider`; its cache commands/listeners remain cache-owned. Register the new table/prune commands exclusively from `RateLimiterServiceProvider`. + +## Package and repository metadata + +Add/update all of the following: + +- root `composer.json` PSR-4 mapping for `Hypervel\RateLimiter\`; +- root `replace` entry for `hypervel/rate-limiter`; +- `src/rate-limiter/composer.json`, auto-discovered provider, authors/support/branch alias, sorted requirements; +- exact direct requirements for `ext-hash`, `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, core events, database, Redis, support, and `symfony/console`, pruning anything implementation does not actually import; +- `hypervel/rate-limiter` dependencies in routing, queue, Fortify, foundation, and Reverb package manifests; +- remove `hypervel/cache` from packages where the limiter was its only cache use; retain unrelated cache/Redis dependencies after checking all imports; +- root/package metadata regression tests; +- facade API documentation metadata; +- `Hypervel\RateLimiter` package entry in any package inventories/documentation lists. + +Do not add a reverse `hypervel/rate-limiter` dependency to `hypervel/support`. Support is the lower-level package used by the new manager and service provider; its facade and default-provider references follow the repository's existing optional facade/provider bridge convention. The always-installed framework metapackage provides both packages, while an independently installed rate-limiter package already requires Support in the correct direction. + +The existing split script automatically discovers `src/*`; no hard-coded split list should be added unless the current script changes. + +After consumer imports are rewritten, remove `hypervel/cache` from routing, Fortify, and Reverb if the repository-wide import audit still confirms that rate limiting was their only actual cache-package use. Queue and foundation have unrelated cache responsibilities and retain that dependency. Add the new package as a direct dependency wherever its symbols are imported even if another package would provide it transitively. + +Coordinate the two adjacent official repositories in the same release: + +- add `hypervel/rate-limiter` to `contrib/hypervel/framework/composer.json`, sorted with the other split components; +- add the published `config/rate-limiter.php` to the `contrib/hypervel/hypervel` application skeleton; +- because the skeleton selects the database limiter store by default, add `database/migrations/0001_01_01_000008_create_rate_limits_table.php` after its current `000007` failed-jobs migration so a fresh application works immediately, while retaining the generator for existing applications; +- update the skeleton lock/config/environment documentation and run each repository's own metadata/config/migration tests. Do not modify the private `packages/hypervel` repositories unless a concrete import audit finds an actual consumer. + +Keep provider auto-discovery metadata in the split package so it works when independently required, matching other core components, but also assert `RateLimiterServiceProvider`'s exact presence/order in `DefaultProviders`. Discovery is not the framework's availability mechanism. + +Within components, add `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration, alongside its cache/cache-lock/session/queue defaults. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, rollback/refresh coverage, and the Testbench default config to include `rate-limiter`. Testbench must model a fresh skeleton accurately; it must not pass only because individual limiter tests create the table ad hoc. + +## Removal and cleanup inventory + +Delete after consumers compile against the new package: + +- `src/cache/src/RateLimiter.php`; +- `src/cache/src/RateLimiting/Limit.php`; +- `src/cache/src/RateLimiting/GlobalLimit.php`; +- `src/cache/src/RateLimiting/Unlimited.php`; +- cache provider limiter binding; +- `cache.limiter` config and its documentation block from `src/foundation/config/cache.php`; +- `src/routing/src/Middleware/ThrottleRequestsWithRedis.php`; +- `src/queue/src/Middleware/RateLimitedWithRedis.php`; +- `src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php`; +- Foundation middleware Redis-throttle switch/state/API; +- all old cache-rate-limiter unit/integration tests once their behavior is covered under `tests/RateLimiter`; +- the rate-limiter case from `RedisCacheIntegrationTest` (retain its actual Redis cache tests) after equivalent native Redis coverage exists under `tests/RateLimiter`; +- Inertia's old namespace/config override, Reverb's worker-array state assertions, Testbench's `cache.limiter` default assertions, and every other cross-package test fixture discovered by the stale-symbol search; rewrite them against the new package rather than merely deleting behavioral coverage; +- all docs and examples importing `Hypervel\Cache\RateLimiter` or `Hypervel\Cache\RateLimiting\*`; +- the two existing Redis TODO bullets about `ThrottleRequestsWithRedis` state and `after()` after the unified middleware makes them obsolete. +- the overall `Rate Limiting` implementation TODO added for this package once every acceptance item is complete; retain only the future native-`INCREX` and framework capability TODOs because they describe intentionally deferred work that still exists. + +Do not delete `Hypervel\Redis\Limiters\DurationLimiter` or its builder merely because request/queue middleware no longer imports it. `Redis::throttle()` and other Redis limiter APIs still use it. Audit its remaining references and leave it as a separate Redis concurrency/throttle primitive. + +At the end, repository-wide searches (excluding archived code, third-party examples, vendor, and historical `docs/plans`/`.tmp/plans` artifacts) must return no old namespace, no removed middleware class, no `throttleWithRedis`, and no `cache.limiter` in executable code, tests, configuration, stubs, or maintained user/agent documentation. Historical plans are records, not supported documentation, and must not be rewritten as part of this change. + +## Documentation work + +Update every applicable Boost document, not just the main rate-limiting page: + +- `routing.md`: named policies, leaky bucket, weighted cost, response-based semantics, stores, headers, and removal of `throttleWithRedis`; +- update the existing `src/boost/docs/rate-limiting.md` in place as the single canonical rate-limiting document: cover the package architecture, direct consume/inspect/attempt/clear APIs, typed policies and results, fixed-window/leaky-bucket/backoff behavior, driver selection and guarantees, configuration, database migration/pruning, custom drivers, distribution boundaries, performance guidance, and failure behavior. Do not add a competing `rate-limiter.md` page; +- `queues.md`: store selection and removal of Redis-specific middleware classes; +- `fortify.md`, `errors.md`, `starter-kits.md`: imports and new typed calls; +- `facades.md`: canonical accessor/class; +- `middleware.md`: one throttle middleware class; +- database docs: `make:rate-limiter-table`, schema purpose, pruning schedule; +- package README: driver guarantees, distribution boundaries, performance guidance, and failure behavior. + +`src/boost/docs-ported.md` already registers `rate-limiting.md`; retain that single inventory entry and do not add `rate-limiter.md` there or anywhere else. + +Add a concise explicit divergence to root `AGENTS.md`: Laravel locates its cache-bound limiter under `Illuminate\Cache`; Hypervel's canonical implementation is `hypervel/rate-limiter` / `Hypervel\RateLimiter`, uses typed policies and dedicated stores, and has no Cache namespace alias. This is the instruction LLMs should see when porting. + +Do not copy internal research criticism into user documentation. Public docs should state the supported design clearly. + +## Testing plan + +Create `tests/RateLimiter` and use the repository-required base test/coroutine conventions. Run each changed/new test file individually before the package suite. + +### Policy/value tests + +- Every fixed-window and leaky-bucket factory converts periods correctly. +- Invalid zero/negative capacity, rate, duration, burst, cost, and backoff settings throw named exceptions. +- Numeric boundary tests cover the shared Lua-exact/signed-64 limits and every overflow-prone multiplication/addition before a store mutation. +- Fluent methods return new copies and do not mutate the original policy. +- `globally`, scope, callbacks, cost, and response callbacks are retained correctly. +- Policy fingerprints are stable, parameter-sensitive, strategy-sensitive, and exclude cost/callbacks. +- Arbitrary key segments cannot create ambiguous preimages before hashing. +- Unlimited performs no store operation. +- `LimitResult` and `BackoffResult` round timing up correctly and never expose negative remaining/retry values. +- Manager default/named/`UnitEnum` store resolution, one-instance caching, purge/forget behavior, typed configuration failures, and a custom `extend()` callback returning `Contracts\Store` all produce the expected wrapped `Limiter` without a second cache. +- Named limiter identity differs by limiter name, scope, global flag, normalized key value, policy type, and stable parameters exactly as specified; equivalent scalar/stringable/enum key values normalize identically, and direct policies do not accidentally invoke the named scope resolver. + +### Shared store contract suite + +Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, Redis 8.6, Redis 8.8, and Valkey 9. Add isolated Docker-backed integration jobs where the existing service matrix does not already provide a target; do not claim a supported first-party store/server combination from mocks alone. + +- first consume, exact-capacity consume, weighted consume, over-capacity denial; +- denied consume does not mutate count or extend TTL; +- inspection does not create/mutate state; +- clear and expiration reset; +- fixed-window boundary immediately before/after reset; +- leaky-bucket initial burst, smooth recovery, weighted retry, full refill, denial immutability; +- exponential threshold, doubling, cap, inactivity reset, success clear; +- same physical semantics for every store to the precision promised by public seconds; +- corrupted/wrong-type backend state fails explicitly rather than allowing work. + +### Concurrency tests + +- Array: multiple coroutines in one worker admit exactly capacity. +- Swoole: multiple coroutines and forked workers admit exactly capacity and do not lose updates. +- Database: concurrent transactions against an absent key and an existing key admit exactly capacity; include SQLite writer serialization plus MySQL/PostgreSQL row locks in integration CI. +- Redis: many concurrent pooled clients admit exactly capacity for fixed and leaky bucket; test weighted costs. +- Redis Cluster: one-key scripts route without CROSSSLOT and work with configured prefixes. +- No driver allows stored state above capacity. + +### Redis-specific tests + +- Steady path calls `evalSha` and a node's first NOSCRIPT response falls back to `eval` through existing `evalWithShaCache()`. +- Script false/nil/error handling is not mistaken for NOSCRIPT. +- Serializer/compression configuration does not affect limiter state. +- Redis connection `OPT_PREFIX` is applied once. +- `TIME`-based leaky/backoff calculations ignore application-clock skew. +- TTL is applied atomically and is unchanged on denial. +- Redis 8.6 and Valkey 9 run the exact same Lua implementation as Redis 8.8. +- Add a focused assertion/test fixture guarding the required `@TODO`/portable path only if repository conventions permit source-shape tests; otherwise the docs TODO and code comment are sufficient. + +### Swoole-specific tests + +- Table columns are 8-byte integers and table creation occurs before fork. +- Same-key locks isolate transitions; different stripes can proceed independently. +- Expired rows are pruned by timer and on access. +- Full table retries after pruning once and then throws without evicting live state. +- Timer is registered only by worker 0 and cleaned on exit/recycle. +- Repeated worker lifecycle hooks do not register duplicate prune timers or retain stale timer IDs. +- Store state never serializes a PHP value. + +### Database-specific tests + +- Generated migration SQL/schema is valid for all four supported database families. +- `insertOrIgnore` plus lock handles simultaneous first use. +- Server time is read after lock acquisition. +- PostgreSQL uses current wall time rather than transaction-start time. +- Prune command rejects non-prunable stores, targets a named database store, and deletes only expired rows. +- Pruning validates/chunks work, terminates across multiple full batches, reports the exact total, and rechecks expiry in the delete. +- Concurrent pruning never deletes a row renewed by an in-flight consume transaction. +- Configured connection/table/prefix are honored. +- Inspection uses the primary/write PDO even when the connection has read replicas configured. +- No cache/cache-lock table query occurs. +- Testbench's standard migration set creates, refreshes, and rolls back `rate_limits`, and its exact migration inventory/config assertions include the new package defaults. + +### Framework integration tests + +- Routing inline and named fixed limits. +- Named leaky-bucket routing, weighted costs, custom response, global/scope behavior, multiple policy ordering. +- Response-based `after()` with matching/non-matching response and a concurrent post-response consume. +- Header values come from the local decision and remain isolated across same-key concurrent requests. +- Queue release timing, `dontRelease`, explicit store, and job serialization/wakeup. +- `ThrottlesExceptions` consumes only qualifying failures and clears on success. +- Fortify fixed lockout and clearing. +- Foundation exception report throttling. +- Reverb per-connection isolation and close cleanup with array store. +- Facade resolves the canonical manager. +- `DefaultProviders` always contains `RateLimiterServiceProvider` after `RedisServiceProvider`, independently of package discovery. +- Middleware configuration contains only `ThrottleRequests` and has no Redis switch. + +### Static/quality checks + +For every changed PHP file: + +1. run the directly related test file; +2. run formatter/linter required by the repository; +3. run PHPStan for the touched package/scope; +4. run the package test group; +5. run integration groups for Redis, database, routing, queue, Fortify, foundation, and Reverb; +6. run `git diff --check`; +7. run stale-symbol searches described above. + +Do not weaken PHPStan types, suppress errors, or widen return types to accommodate test mocks. Fix contract/test doubles correctly. + +## Performance validation + +Add a reproducible developer-only CLI harness under `tests/Benchmarks/RateLimiter/`, including documented Docker image/version inputs; do not register a production Artisan command or treat PHPUnit timing as a benchmark. The harness must exercise the framework manager, pool, driver, result decoding, and middleware-relevant operation—not only a raw Redis command—so its numbers represent the code being shipped. + +Measure at minimum: + +- Redis fixed-window and leaky-bucket consume with 1, 50, and 200 concurrent clients; +- allowed-heavy, denied-heavy, and high-cardinality key distributions; +- Redis 8.6, Redis 8.8, and Valkey 9; +- Swoole same-key contention and high-cardinality keys across workers; +- database SQLite/MySQL/MariaDB/PostgreSQL separately, clearly labeled as correctness fallback; +- a one-time old cache-backed fixed-limiter baseline versus the new drivers before old code is removed; retain the recorded comparison, not a compatibility adapter or old implementation in the final harness; +- p50/p95/p99 latency, operations/second, pool wait, backend CPU, and Redis memory/key footprint. + +Acceptance invariants: + +- Redis steady-state admission is one network round trip, one pool checkout, and one script invocation. +- No Redis cache serialization/compression path is entered. +- Swoole performs no serialization and no I/O. +- Middleware performs no post-consume state lookup for ordinary limits. +- Throughput/latency regressions between the portable Lua variants are explained before merge; optimize script internals rather than adding a premature version branch. + +The future `INCREX` TODO must be revisited with the same end-to-end result contract and benchmarks, not a raw-command microbenchmark alone. + +## Implementation sequence + +This order keeps the tree buildable while still delivering one final cut with no compatibility residue: + +1. Add package metadata/config/provider skeleton, root autoload/replace entry, and default provider registration. +2. Add immutable policies, fingerprints/key resolver, decisions, contracts, manager, and per-store `Limiter` wrapper with unit tests. +3. Implement array store and run the full shared contract against it. +4. Implement Redis Lua transitions using `evalWithShaCache()`, including the required focused `@TODO`; run Redis 8.6/8.8/Valkey integration and concurrency tests. +5. Implement Swoole table/state/timer/pruning and multi-worker concurrency tests. +6. Implement database store, migration/prune commands, server clocks, the Testbench default migration/config updates, and database integration/concurrency tests. +7. Rewrite routing and Foundation middleware configuration; delete the Redis-specific request middleware/switch once tests pass. +8. Rewrite queue middleware and remove the two Redis-specific queue classes. +9. Rewrite Fortify, foundation exception throttling, Reverb, and facade access. +10. Move/replace rate-limiter tests into `tests/RateLimiter`; remove cache rate-limiter classes/config/binding/tests. +11. Update every composer dependency, Boost document, README, facade annotation, AGENTS divergence, and package inventory. +12. Update the official framework metapackage and application skeleton dependency/config/base migration, verifying those repositories under their own instructions. +13. Remove the completed package TODO and obsolete Redis middleware-defect TODO bullets while retaining the native-increment and framework capability TODOs. +14. Run stale-code searches, per-package suites, cross-package integration suites, static analysis, benchmarks, and `git diff --check`. + +No step should add a temporary alias or dual API. If intermediate local compilation requires ordering, make the consumer and provider changes in the same working change before handoff. + +## Final verification checklist + +- [ ] `Hypervel\RateLimiter` is the only limiter namespace. +- [ ] The support facade resolves `Hypervel\RateLimiter\RateLimiter`. +- [ ] `RateLimiterServiceProvider` is unconditional framework infrastructure in `DefaultProviders`, not dependent on package discovery. +- [ ] Fixed, leaky-bucket/GCRA, and exponential backoff policies are typed separately. +- [ ] No strategy/driver enum or nullable strategy parameter bag exists. +- [ ] Redis/Swoole/database/array stores pass one shared semantic suite. +- [ ] The package has no cache-repository dependency, generic cache driver, or file driver. +- [ ] Database uses only the dedicated `rate_limits` table. +- [ ] The framework metapackage requires the split package and a fresh application skeleton includes its config and `rate_limits` migration. +- [ ] Testbench's default configuration/migrations provision and roll back the same database limiter table as the application skeleton. +- [ ] `src/boost/docs/rate-limiting.md` is the one canonical rate-limiting page and is updated comprehensively; no duplicate `rate-limiter.md` exists. +- [ ] Redis's normal path is one cached Lua invocation and works on Redis 8.6/8.8 and Valkey 9. +- [ ] The `INCREX` docs TODO and focused code `@TODO` both exist with accurate prerequisites. +- [ ] Routing has one throttle middleware and no `throttleWithRedis` API. +- [ ] Queue has no Redis-specific rate-limit middleware subclasses. +- [ ] Reverb does not construct a cache rate limiter. +- [ ] No ordinary admission path checks then separately hits or re-reads for headers. +- [ ] No singleton stores request-local remaining/reset state. +- [ ] Swoole never evicts a live limiter row. +- [ ] Store failures never fail open. +- [ ] All old classes, docs, imports, tests, config keys, aliases, and obsolete TODOs are removed. +- [ ] AGENTS.md tells porting agents about the deliberate Laravel namespace/API divergence. +- [ ] Benchmarks and concurrency tests demonstrate the performance/correctness claims. From b2a001d19cf63b8356783311ed12c2abb9c6543c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:20 +0000 Subject: [PATCH 02/41] Refine rate limiter package plan --- .../2026-08-04-1543-rate-limiter-package.md | 273 +++++++----------- docs/todo.md | 8 + 2 files changed, 118 insertions(+), 163 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index 5a275a57f..c04613660 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -121,85 +121,16 @@ A local indicative Redis 8.8 `redis-benchmark` run (300,000 requests, 50 clients Remove the comment and the documentation TODO together when the native implementation actually replaces Lua. -## Research findings that shape the design +## Research findings that constrain implementation -### Current Laravel (local snapshot `examples/laravel/framework`, commit `2c410561c2`, 2026-07-30) +- Current Laravel and Hypervel expose no strategy parameter. Preserve `RateLimiter::for()`, `Limit::perMinute()`, `by()`, `after()`, `response()`, and routing middleware helpers, but replace the split cache check/hit/read engine. +- Hypervel's normal middleware is non-atomic; its Redis variant keeps request-local result data on a worker singleton and ignores `after()`. Both recorded defects are acceptance items. +- Migrate routing, queue middleware, Fortify, Foundation exception throttling, Reverb, the facade/provider/config, tests, documentation, and package metadata. Keep Redis `DurationLimiter`/`ConcurrencyLimiter` as separate blocking primitives. +- Retain the archived package's one-call decision, weighted consumption, Redis Lua, and typed bucket ideas. Do not retain JSON/whole-second state, the timeout race, invalid fractional admission, or multi-key Cluster scripts. +- The requested Laravel packages add no safe driver boundary: Oltrematica is configuration-oriented, while milenmk's progressive lockout replays cache hits and is not concurrency-safe. Fibonacci remains deliberately unimplemented; exponential backoff is a separate failure policy. +- Symfony supports typed policies, weighted consumption, and rich results, but its generic storage/lock path is not the Redis hot-path design. `go-redis/redis_rate` and Cloudflare support the single-TAT GCRA representation. -- `Illuminate\Cache\RateLimiter` accepts only a cache repository and exposes no strategy parameter. -- `Limit` contains only `key`, `maxAttempts`, `decaySeconds`, `afterCallback`, and `responseCallback`. -- The generic path is fixed-window and split across several cache calls. -- Laravel mutates duplicate keyed limits to fallback keys based on attempts/decay. -- Laravel's Redis throttle middleware is a separate implementation choice rather than a general strategy/store abstraction. - -Conclusion: preserve the approachable `RateLimiter::for(...)`, `Limit::perMinute(...)`, `by`, `after`, and `response` vocabulary, but do not copy the storage architecture. - -### Current Hypervel - -- `src/cache/src/RateLimiter.php` is a Laravel-derived fixed-window cache implementation with a Hypervel scope resolver and xxh128 key hashing. -- The normal route middleware first checks all limits, then records hits, then reads again for response headers. The operation is not one atomic decision. -- `ThrottleRequestsWithRedis` uses `DurationLimiter::acquire()` atomically but stores `$decaysAt` and `$remaining` on a worker-lifetime singleton. Same-key concurrent requests can overwrite one another's header state, and unique keys accumulate indefinitely. -- The Redis middleware ignores `Limit::after()`. -- `RedisConnection::callEvalsha()` loads before each call, but the public `RedisConnection::evalWithShaCache()` already implements the correct SHA/NOSCRIPT fallback. The new package must use the latter and must not add a duplicate script cache. -- `DurationLimiter` is also used by `Redis::throttle()` and concurrency/queue APIs. It is not dead when request middleware stops using it and must not be deleted wholesale. - -Current consumers that must be migrated: - -- `src/routing/src/Middleware/ThrottleRequests.php` and `ThrottleRequestsWithRedis.php`; -- `src/queue/src/Middleware/RateLimited.php`, `RateLimitedWithRedis.php`, `ThrottlesExceptions.php`, and `ThrottlesExceptionsWithRedis.php`; -- `src/fortify/src/LoginRateLimiter.php` and the Fortify provider stub; -- `src/foundation/src/Exceptions/Handler.php`; -- `src/reverb/src/Protocols/Pusher/Server.php`; -- `src/support/src/Facades/RateLimiter.php`; -- `src/cache/src/CacheServiceProvider.php` and `cache.limiter` config; -- all related tests, Boost documentation, facade metadata, and package dependency metadata. - -### Archived Hypervel 0.3 package (`packages/hypervel/_archive/src/rate-limiter`) - -Ideas to retain: - -- a decision object returned from a single call; -- native Redis Lua for atomic admission; -- support for weighted consumption and multiple policies; -- dedicated typed leaky-bucket configuration/state. - -Defects not to port: - -- application-supplied whole-second time inside Lua; -- JSON state encoding; -- fractional admission that checks the old level but can store a level over capacity; -- a timeout race that can return a wrong retry time; -- multi-key scripts that do not account for Redis Cluster slots; -- incomplete SHA execution support; -- validation that permits invalid algorithm state. - -### `examples/ratelimiter` - -Useful ideas are the fluent bucket vocabulary and resolver separation. Do not port its cache read/modify/write algorithm, leak timer calculation, or event-heavy hot path. The implementation is not atomic under concurrency. - -### Requested third-party packages - -- `Oltrematica/laravel-rate-limiter` is primarily configuration/wrapping and does not supply a strong atomic algorithm or reusable driver boundary. -- `milenmk/laravel-rate-limiting` implements linear, Fibonacci, and exponential lockout growth, but reconstructs history with multiple cache calls/O(N) replay and is not concurrency-safe. Its useful lesson is to distinguish failure penalties from ordinary admission. - -### Other references - -- Symfony RateLimiter has useful typed policies, weighted `consume($tokens)`, and rich decision results. Its generic storage-plus-lock model is not suitable for Hypervel's Redis hot path. -- `go-redis/redis_rate` demonstrates the compact GCRA model and one-call result shape used for leaky-bucket semantics. -- Cloudflare's GCRA description supports using a theoretical-arrival-time representation for scalable smooth limiting. -- Generic PHP cache implementations reviewed during research either require external locks or contain read/modify/write races; none provides a better backend boundary than dedicated drivers. - -Authoritative references to retain in implementation notes/tests where relevant: - -- Laravel rate limiting: -- Symfony RateLimiter: -- Redis scripting: -- Redis Functions trade-offs: -- Redis `TIME`: -- Redis `INCREX`: -- Redis Cluster key-slot rules: -- Google retry/backoff guidance: -- OWASP authentication throttling guidance: -- Cloudflare rate-limiting algorithms: +Implementation references: [Laravel rate limiting](https://laravel.com/docs/13.x/rate-limiting), [Symfony RateLimiter](https://symfony.com/doc/current/rate_limiter.html), [Redis scripting](https://redis.io/docs/latest/develop/programmability/eval-intro/), [Redis Functions](https://redis.io/docs/latest/develop/programmability/functions-intro/), [Redis TIME](https://redis.io/docs/latest/commands/time/), [Redis INCREX](https://redis.io/docs/latest/commands/increx/), [Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/), [Google retry guidance](https://docs.cloud.google.com/storage/docs/retry-strategy), [OWASP authentication throttling](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html), and [Cloudflare rate-limiting algorithms](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/). ## Public API @@ -211,7 +142,7 @@ Authoritative references to retain in implementation notes/tests where relevant: namespace Hypervel\RateLimiter; /** @mixin \Hypervel\RateLimiter\Limiter */ -final class RateLimiter extends MultipleInstanceManager +class RateLimiter extends MultipleInstanceManager { public function store(UnitEnum|string|null $name = null): Limiter; @@ -221,10 +152,16 @@ final class RateLimiter extends MultipleInstanceManager public function getInstanceConfig(string $name): array; - public function for(UnitEnum|string $name, Closure $callback): static; + public function for( + UnitEnum|string $name, + Closure $callback, + UnitEnum|string|null $store = null, + ): static; public function limiter(UnitEnum|string $name): ?Closure; + public function limiterStore(UnitEnum|string $name): ?string; + public function resolveKeyScopeUsing(?Closure $resolver): void; // Inherited: extend(string $driver, Closure $callback): static. @@ -245,6 +182,8 @@ RateLimiter::store('redis')->consume( `store()` accepts `UnitEnum|string|null` and normalizes enums through `enum_value()`. Built-in `create*Driver()` methods and custom `extend()` callbacks return a `Contracts\Store`; the manager's protected `resolve()` wraps that store in one `Limiter`. This keeps key resolution and unlimited handling out of drivers while giving third-party drivers a small native-operation contract. A custom creator therefore has the familiar shape `fn (Application $app, array $config): Store` rather than having to construct a framework wrapper. +The optional third argument to `for()` selects the store for that named limiter, so an application can keep login lockouts in the database while routing API traffic through Redis. `limiterStore()` exposes that normalized registration to framework consumers; `null` means use the current default store. Queue middleware's explicit `store()` modifier overrides the registered store. Keep the callback and store in synchronized manager-owned maps rather than adding a named-limiter descriptor class. + Resolved stores capture immutable configuration. `setDefaultInstance()`, `for()`, `resolveKeyScopeUsing()`, `extend()`, `forgetInstance()`, and `purge()` are explicitly boot/test-only under the repository's coroutine rules. The `Limiter` receives a resolver closure owned by the manager so a named-policy key can include the limiter name without mutating the policy object. ### Fixed-window policy @@ -291,10 +230,10 @@ RateLimiter::for('api', function (Request $request) { return LeakyBucket::perSecond(100) ->burst(200) ->by($request->user()?->getAuthIdentifier() ?? $request->ip()); -}); +}, store: 'redis'); ``` -Factories mirror `Limit`: `perSecond`, `perMinute`, `perMinutes`, `perHour`, and `perDay`. Their first argument is the sustained number of tokens emitted over the period. `burst(int $capacity)` is the total immediately available capacity, not “extra” capacity. It defaults to `1`, producing strict smoothing; callers that want a burst must opt in explicitly. `cost()` cannot exceed `burst()`. +Factories mirror `Limit`'s names and argument order: `perSecond(int $rate, int $decaySeconds = 1)`, `perMinute(int $rate, int $decayMinutes = 1)`, `perMinutes(int $decayMinutes, int $rate)`, `perHour(int $rate, int $decayHours = 1)`, and `perDay(int $rate, int $decayDays = 1)`. The rate is the sustained number of tokens emitted over the period. `burst(int $capacity)` is the total immediately available capacity, not “extra” capacity. It defaults to the factory's rate argument, matching the least-surprising reading of `perSecond(100)` while still replenishing continuously; strict smoothing is the explicit `->burst(1)` case. `cost()` cannot exceed `burst()`. Document that the backend implementation is GCRA, which provides leaky-bucket behavior with constant state rather than running a leak timer. @@ -389,7 +328,7 @@ The physical identity includes: 4. caller key from `by()` (an empty key intentionally means a shared/global policy); 5. policy type and canonical parameters. -Each segment is domain-tagged and length-prefixed before the entire identity is hashed with `xxh128`. Domain tags distinguish, for example, a limiter name from a caller key; length prefixes keep arbitrary normalized strings injective. Keys are normalized to strings first, so equivalent `1`, `'1'`, a stringable `'1'`, and an enum value `'1'` intentionally identify the same bucket. The configured prefix is a pre-hash application namespace, so every driver still receives the same fixed 32-character lowercase hexadecimal key. Redis's connection-level `OPT_PREFIX` and a database connection's table prefix remain outside this digest and are applied exactly once by those components. +Each segment is domain-tagged and length-prefixed before the entire identity is hashed with seeded `xxh128`. Derive the stable integer seed once when resolving the store as `hexdec(substr(hash('xxh128', 'rate-limiter|' . $prefix), 0, 15))`, using only the validated limiter prefix. Do not use `app.key` or generate a process-local seed: rotating an unrelated encryption key must not clear active limiter state, and every worker/application node with the same limiter prefix must derive identical physical keys. Domain tags distinguish, for example, a limiter name from a caller key; length prefixes keep arbitrary normalized strings injective. Keys are normalized to strings first, so equivalent `1`, `'1'`, a stringable `'1'`, and an enum value `'1'` intentionally identify the same bucket. The configured prefix is a pre-hash application namespace, so every driver still receives the same fixed 32-character lowercase hexadecimal key. Redis's connection-level `OPT_PREFIX` and a database connection's table prefix remain outside this digest and are applied exactly once by those components. Policy callbacks and request cost are excluded from the policy fingerprint; the global-scope flag is included because it changes policy identity. Including stable policy parameters means two limits with the same `by()` value but different windows/algorithms naturally have different state, and changing policy configuration starts clean state while the old TTL expires. The Laravel fallback-key mutation is unnecessary. Add golden-vector tests for the canonical encoding so an apparently harmless refactor cannot orphan all active state. @@ -403,8 +342,6 @@ Target layout (names may move only if implementation reveals a concrete reposito src/rate-limiter/ ├── README.md ├── composer.json -├── config/ -│ └── rate-limiter.php └── src/ ├── ArrayStore.php ├── Backoff.php @@ -413,6 +350,8 @@ src/rate-limiter/ │ ├── PruneCommand.php │ ├── RateLimiterTableCommand.php │ └── stubs/rate-limits.stub + ├── Concerns/ + │ └── CalculatesRateLimits.php ├── Contracts/ │ ├── Decision.php │ ├── PrunableStore.php @@ -434,13 +373,12 @@ src/rate-limiter/ │ ├── CreateTables.php │ ├── PruneTables.php │ ├── TableManager.php - │ ├── TableState.php - │ └── Timer.php + │ └── TableState.php ├── SwooleStore.php └── Unlimited.php ``` -Avoid an `Algorithms` service hierarchy in the first implementation. The policy classes hold validated immutable configuration; each store uses a small exhaustive `instanceof` dispatch to its private fixed-window, leaky-bucket, or backoff transition. An unsupported policy throws `InvalidRateLimitException` rather than silently changing behavior. +Avoid an `Algorithms` service hierarchy. Policies hold validated immutable configuration. Array, Swoole, and database stores share typed integer transition math through `CalculatesRateLimits`; Redis implements the same semantics in Lua. Both paths use a small exhaustive `instanceof` dispatch, never descriptor arrays or strategy enums. An unsupported policy throws `InvalidRateLimitException`. `RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver using the then-current validated prefix plus the manager-owned optional scope callback. Built-in `createArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. Resolve/freeze static key configuration when the lazy store wrapper is created; do not read the config repository on every consume. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry. @@ -472,6 +410,8 @@ interface PrunableStore ### Configuration +As an always-installed framework component, the canonical defaults live in `src/foundation/config/rate-limiter.php`, alongside Cache, Queue, and Concurrency configuration. Mirror that file into the application and Testbench skeletons. Add `'rate-limiter' => ['stores']` to `LoadConfiguration::mergeableOptions()` so application stores merge by name; this declares merge policy only and does not duplicate configuration values. `RateLimiterServiceProvider` must not merge or publish a second package-owned config file. + ```php return [ 'default' => env('RATE_LIMITER_STORE', 'database'), @@ -504,13 +444,13 @@ return [ ]; ``` -`RateLimiterServiceProvider::mergeableOptions('rate-limiter')` returns `['stores']`, so applications can add custom named stores without losing defaults. Use typed config getters and validate every store at resolution. Publish configuration and expose the database migration generator/prune commands using the package's normal provider conventions. +Use typed config getters and validate every store at resolution. The service provider registers the database migration generator and prune commands. `prefix` is an application namespace included in the canonical identity before its final hash; it is not concatenated onto the 32-character physical key. This preserves cross-application isolation without variable-length Swoole keys. It is separate from Redis `OPT_PREFIX` and database table prefixes. Do not add algorithm defaults to global config. Rates belong to typed application policy definitions, not storage configuration. -Policy construction performs store-independent range validation against the strictest shared numeric representation, including Redis Lua's largest exactly representable integer (`9_007_199_254_740_991`) and signed 64-bit Swoole/database columns. Driver operations then validate time-dependent additions (for example `now + emission * burst`) before mutation. Reject an unrepresentable policy with `InvalidRateLimitException`; do not add arbitrary-precision math, saturate silently, or let the same policy work on one first-party store and corrupt on another. +Policy construction performs store-independent range validation against the strictest shared numeric representation, including Redis Lua's largest exactly representable integer (`9_007_199_254_740_991`) and signed 64-bit Swoole/database columns. Reject a leaky-bucket rate greater than its period's microsecond count because its sub-microsecond emission interval cannot be represented without changing the promised rate. Driver operations then validate time-dependent additions (for example `now + emission * burst`) before mutation. Reject an unrepresentable policy with `InvalidRateLimitException`; do not add arbitrary-precision math, saturate silently, or let the same policy work on one first-party store and corrupt on another. ## Algorithm specifications @@ -574,7 +514,7 @@ State is failure count, blocked-until time, and expiration/inactivity time. On ` - Rely on the EVAL key path for configured phpredis prefixing. Do not use raw commands or manually duplicate the Redis connection prefix. - Store raw integer/string/hash state directly. Never pass it through cache serialization/compression. - Use one Redis string plus TTL for a fixed counter, one Redis string TAT plus TTL for GCRA, and one small hash (`failures`, `available_at`) plus inactivity TTL for backoff. The policy fingerprint fixes the type for a key, so no strategy tag or JSON envelope is needed. -- Set TTL atomically in the script and return accepted, limit, remaining, retry microseconds, and reset microseconds in the same response. +- Set TTL atomically in the script. Every algorithm returns the same five-integer tuple—accepted flag, limit, remaining, retry microseconds, reset microseconds—even when the Redis command uses milliseconds. Convert `PTTL` milliseconds to microseconds inside the fixed-window script and validate the converted values; result decoding never guesses a unit from the policy type. - Validate every returned tuple's arity, integer types, flags, and non-negative/range invariants before constructing a result; `false`, `nil`, truncation, or malformed data must throw rather than cast into an allowed decision. - Keep script bodies as private constants or dedicated internal operation classes only if file length warrants it. Do not build a generic script framework. @@ -583,11 +523,12 @@ Fixed-window Lua shape: ```lua local cost = tonumber(ARGV[1]) local limit = tonumber(ARGV[2]) -local duration = tonumber(ARGV[3]) +local durationMilliseconds = tonumber(ARGV[3]) +local durationMicroseconds = durationMilliseconds * 1000 local function start_window() - redis.call('SET', KEYS[1], cost, 'PX', duration) - return {1, limit - cost, duration} + redis.call('SET', KEYS[1], cost, 'PX', durationMilliseconds) + return {1, limit, limit - cost, 0, durationMicroseconds} end local raw = redis.call('GET', KEYS[1]) @@ -612,11 +553,11 @@ end local next = current + cost if next > limit then - return {0, limit - current, ttl} + return {0, limit, limit - current, ttl * 1000, ttl * 1000} end redis.call('SET', KEYS[1], next, 'KEEPTTL') -return {1, limit - next, ttl} +return {1, limit, limit - next, 0, ttl * 1000} ``` The production script must keep every result numeric, provide an inspect mode without creating a missing key, and include the required `@TODO` immediately beside it. A present fixed-window key with a non-integer value, negative/out-of-range count, or no expiry (`PTTL == -1`) is corrupt: raise a Lua error and propagate it rather than deleting the key and potentially failing open. A zero/expired TTL is a real boundary condition and starts a fresh window atomically. Validate impossible costs in PHP so the script never creates an over-capacity first value. @@ -628,14 +569,16 @@ Do not alter `RedisConnection::callEvalsha()` for this package; the correct `eva - Own a dedicated `Swoole\Table`; do not reuse `SwooleStore` or `SwooleTableManager` from cache. - Columns are `value`, `available_at`, and `expires_at`, all `Table::TYPE_INT` with an explicit 8-byte width. - Use a fixed 32-character hashed key. -- Bind one package-local `Swoole\TableManager` singleton. `CreateTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. +- Resolve the package-local `Swoole\TableManager` as an unbound concrete, using Hypervel's auto-singleton behavior rather than an explicit container binding. `CreateTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. - Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. If a running worker requests a Swoole store whose table was not created before fork, throw a lifecycle/configuration exception rather than silently allocate a worker-private table. Console/tests may explicitly initialize a table before concurrent use. -- Use 64 striped locks and a short spin/backoff timeout pattern equivalent to the proven cache `SwooleTableState`, but keep the limiter table independent and numeric. +- Extract the proven 64-stripe Atomic lock coordinator from cache's `SwooleTableState` into a small `Hypervel\Core\Swoole\StripedLock` primitive used by both Cache and RateLimiter. It owns key-to-stripe selection, short spin/backoff acquisition, all-lock acquisition required by Cache, and release; it does not own a table, cache columns, limiter state, or arbitrary multi-key transactions. Keep both packages' table managers and state formats independent. +- Move Cache's existing injectable `SwooleTimer` wrapper to `Hypervel\Core\Swoole\Timer` and use that same two-method `tick()`/`clear()` test seam from Cache and RateLimiter lifecycle listeners. Do not create a package-local duplicate or expand it into a scheduler abstraction. - Perform read/check/write within one row lock. No serialization, closures, cache repository, or generic eviction policy appears in the hot path. - Use `intdiv(hrtime(true), 1000)` for a host-monotonic microsecond clock shared by workers. - Expired rows are reclaimed on access. Worker 0 owns a periodic expiry scan timer; stop it on worker exit. Timer/full-table pruning must lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. -- If insertion fails, perform one synchronous expired-row prune and retry once. If the table remains full of live rows, throw `SwooleTableFullException`. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. +- If insertion fails, log one warning for the synchronous full-table prune attempt, perform one expired-row prune, and retry once. Inject `Psr\Log\LoggerInterface`; no logging lookup belongs in the hot path. If the table remains full of live rows, throw `SwooleTableFullException`. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. - Document that Swoole is host-local and is not a distributed rate limiter across servers. +- Document sizing as `rows >= peak concurrently live physical keys × headroom`, where a key remains live for its window/refill/inactivity TTL. Include examples for per-IP cardinality and explain that the warning indicates exhausted headroom before a live-only table begins failing closed. ### Database store @@ -715,9 +658,10 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ - implement `after()` for Redis-backed policies with non-mutating inspection followed by a conditional atomic consume. - Inline `throttle:60,1` creates a fixed `Limit` and consumes it once. +- Preserve the public `ThrottleRequests::using()` and `ThrottleRequests::with()` helpers, middleware pipe syntax, named-limiter lookup and `MissingRateLimiterException` behavior. These are ergonomic Laravel routing APIs, not cache-counter compatibility methods. - Named callbacks retain `Response`, `Unlimited`, one policy, or an ordered array of policies. -- For a normal named policy, call `consume($policy, $limiterName)` once and retain the local result for exception/header generation. Inline policies omit the name. -- For a named `after()` policy, call `inspect($policy, $limiterName)` before the downstream handler; after the response, call `consume($policy, $limiterName)` only when the predicate returns true. A concurrent post-response consume may be denied after the response has already been admitted; return headers from that result but do not retroactively throw. Document/test this inherent response-dependent semantic. +- Resolve the named limiter's registered store once for the request, or use the default store when none was registered. For a normal named policy, call that store's `consume($policy, $limiterName)` once and retain the local result for exception/header generation. Inline policies use the default store and omit the name. +- For a named `after()` policy, call the selected store's `inspect($policy, $limiterName)` before the downstream handler; after the response, call `consume($policy, $limiterName)` only when the predicate returns true. A concurrent post-response consume may be denied after the response has already been admitted; return headers from that result but do not retroactively throw. Document/test this inherent response-dependent semantic. - Use `retryAfter()` and `remaining()` from the local result. Remove second reads and all request state from singleton middleware properties. - Preserve Laravel-compatible headers: successful responses use `X-RateLimit-Limit`/`X-RateLimit-Remaining`; denied responses additionally use `Retry-After` and an absolute `X-RateLimit-Reset` derived from `retryAfter()`. Do not substitute leaky-bucket full-refill `resetAfter()` for the earliest retry time. For leaky policies, document that the limit/remaining header pair describes burst capacity while the policy definition describes the sustained rate. - With multiple policies, retain the header pair for the most restrictive (lowest remaining) local result and do not overwrite an application-provided lower `X-RateLimit-Remaining` value. @@ -729,8 +673,8 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ ### Queue `RateLimited` -- Resolve the named policy through the manager, then use the configured default store and pass the limiter name into each `consume()` so named identities remain isolated. -- Add a Laravel-style `store(UnitEnum|string $store): static` modifier for jobs that need a non-default limiter store. Serialize only limiter name, selected store, release delay, and release behavior. +- Resolve the named policy through the manager and use its registered store, falling back to the configured default; pass the limiter name into each `consume()` so named identities remain isolated. +- Add a Laravel-style `store(UnitEnum|string $store): static` modifier that overrides the named limiter's registered store for this queued job. Serialize only limiter name, explicit store override, release delay, and release behavior; resolve a non-overridden registered/default store after wakeup. - Consume each policy once and release denied jobs using `result->retryAfter() + 3` unless explicitly overridden. - Preserve ordered partial-consumption semantics for multiple policies, matching routing; do not add a queue-only preflight or rollback protocol. - Remove `RateLimitedWithRedis`; a named Redis-backed rate-limiter store replaces both the class and connection-specific implementation. @@ -739,7 +683,7 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ - Represent its existing “N failures in decay window” behavior with a fixed `Limit` keyed to the job. - `inspect()` before running the job; `consume()` only when a qualifying exception occurs; `clear()` after success. -- Add the same store selector and remove `ThrottlesExceptionsWithRedis`. +- Add the same `store()` selector and remove `ThrottlesExceptionsWithRedis`; this middleware constructs its policy directly, so it uses the default store unless explicitly overridden. - Persist only the selected store name with the middleware/job; resolve the manager/wrapper inside `handle()` and never serialize a resolved backend store or Redis proxy. - Keep its existing `backoff()` method for the ordinary queue retry delay; do not conflate that delay with the package's server-enforced `ExponentialBackoff` policy. - Preserve the Laravel-style optional second callback argument, but always pass the selected package `Limiter` wrapper to `when()` and `report()` callbacks. Redis and non-Redis paths must no longer expose different concrete/cache limiter objects. @@ -770,10 +714,10 @@ Keep Lottery and Unlimited handling. Remove primitive key/max/decay calls and th Inject/resolve the new manager and use `store('array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. -### Facade and container +### Facade and provider -- `RateLimiterServiceProvider` binds `Hypervel\RateLimiter\RateLimiter` as a singleton manager and merges/publishes config. -- Add it unconditionally to `Hypervel\Support\DefaultProviders` immediately after `RedisServiceProvider` (database is already registered earlier); store creation remains lazy. The framework must not rely on package discovery to obtain its limiter. +- `Hypervel\RateLimiter\RateLimiter` is a concrete manager and therefore uses Hypervel's normal unbound-concrete auto-singleton behavior; do not add a redundant container binding or alias. `RateLimiterServiceProvider` registers only its commands and lifecycle listeners. Foundation owns the default config. +- Add it unconditionally to `Hypervel\Support\DefaultProviders` between `QueueServiceProvider` and `RedisServiceProvider`, preserving the list's package ordering. Store creation remains lazy, so provider order does not force a backend connection. The framework must not rely on package discovery to obtain its limiter. - Update the support facade accessor and generated method annotations to the new manager/policies/results. - Remove only the limiter binding from `CacheServiceProvider`; its cache commands/listeners remain cache-owned. Register the new table/prune commands exclusively from `RateLimiterServiceProvider`. @@ -784,13 +728,14 @@ Add/update all of the following: - root `composer.json` PSR-4 mapping for `Hypervel\RateLimiter\`; - root `replace` entry for `hypervel/rate-limiter`; - `src/rate-limiter/composer.json`, auto-discovered provider, authors/support/branch alias, sorted requirements; -- exact direct requirements for `ext-hash`, `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, core events, database, Redis, support, and `symfony/console`, pruning anything implementation does not actually import; +- exact direct requirements for `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, core events/primitives, database, Redis, support, `psr/log`, and `symfony/console`, pruning anything implementation does not actually import; PHP's mandatory Hash extension needs no Composer requirement; - `hypervel/rate-limiter` dependencies in routing, queue, Fortify, foundation, and Reverb package manifests; - remove `hypervel/cache` from packages where the limiter was its only cache use; retain unrelated cache/Redis dependencies after checking all imports; -- root/package metadata regression tests; - facade API documentation metadata; - `Hypervel\RateLimiter` package entry in any package inventories/documentation lists. +Move only the generic striped-lock and timer behavior described above into Core and update Cache imports/tests in the same change. Do not move Cache's table manager, string-value validation, eviction state, or exceptions that the numeric limiter table does not use. + Do not add a reverse `hypervel/rate-limiter` dependency to `hypervel/support`. Support is the lower-level package used by the new manager and service provider; its facade and default-provider references follow the repository's existing optional facade/provider bridge convention. The always-installed framework metapackage provides both packages, while an independently installed rate-limiter package already requires Support in the correct direction. The existing split script automatically discovers `src/*`; no hard-coded split list should be added unless the current script changes. @@ -800,13 +745,13 @@ After consumer imports are rewritten, remove `hypervel/cache` from routing, Fort Coordinate the two adjacent official repositories in the same release: - add `hypervel/rate-limiter` to `contrib/hypervel/framework/composer.json`, sorted with the other split components; -- add the published `config/rate-limiter.php` to the `contrib/hypervel/hypervel` application skeleton; +- add `config/rate-limiter.php` to the `contrib/hypervel/hypervel` application skeleton; - because the skeleton selects the database limiter store by default, add `database/migrations/0001_01_01_000008_create_rate_limits_table.php` after its current `000007` failed-jobs migration so a fresh application works immediately, while retaining the generator for existing applications; -- update the skeleton lock/config/environment documentation and run each repository's own metadata/config/migration tests. Do not modify the private `packages/hypervel` repositories unless a concrete import audit finds an actual consumer. +- add `RATE_LIMITER_STORE=database` and commented connection/prefix overrides to the skeleton environment example, update lock/config documentation, and run each repository's own metadata/config/migration tests. Do not modify the private `packages/hypervel` repositories unless a concrete import audit finds an actual consumer. -Keep provider auto-discovery metadata in the split package so it works when independently required, matching other core components, but also assert `RateLimiterServiceProvider`'s exact presence/order in `DefaultProviders`. Discovery is not the framework's availability mechanism. +Keep provider auto-discovery metadata in the split package so it works when independently required, matching other core components, but also assert `RateLimiterServiceProvider`'s presence in `DefaultProviders`. Discovery is not the framework's availability mechanism; its alphabetical placement is a code-style requirement, not runtime behavior that needs a brittle order test. -Within components, add `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration, alongside its cache/cache-lock/session/queue defaults. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, rollback/refresh coverage, and the Testbench default config to include `rate-limiter`. Testbench must model a fresh skeleton accurately; it must not pass only because individual limiter tests create the table ad hoc. +Within components, add `src/testbench/hypervel/config/rate-limiter.php` and `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, and rollback/refresh coverage. Testbench must model a fresh skeleton accurately; limiter tests must not create the default table ad hoc. ## Removal and cleanup inventory @@ -822,6 +767,7 @@ Delete after consumers compile against the new package: - `src/queue/src/Middleware/RateLimitedWithRedis.php`; - `src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php`; - Foundation middleware Redis-throttle switch/state/API; +- the `ThrottleRequests::flushState()` call in `src/testing/src/PHPUnit/AfterEachTestSubscriber.php`, because removing the hash opt-out leaves the middleware with no static state and no empty cleanup method should remain; - all old cache-rate-limiter unit/integration tests once their behavior is covered under `tests/RateLimiter`; - the rate-limiter case from `RedisCacheIntegrationTest` (retain its actual Redis cache tests) after equivalent native Redis coverage exists under `tests/RateLimiter`; - Inertia's old namespace/config override, Reverb's worker-array state assertions, Testbench's `cache.limiter` default assertions, and every other cross-package test fixture discovered by the stale-symbol search; rewrite them against the new package rather than merely deleting behavioral coverage; @@ -831,8 +777,12 @@ Delete after consumers compile against the new package: Do not delete `Hypervel\Redis\Limiters\DurationLimiter` or its builder merely because request/queue middleware no longer imports it. `Redis::throttle()` and other Redis limiter APIs still use it. Audit its remaining references and leave it as a separate Redis concurrency/throttle primitive. +As a bounded adjacent Redis optimization, change `DurationLimiter` and `ConcurrencyLimiter` from sending their full Lua body through `eval()` on every operation to the existing NOSCRIPT-aware `evalWithShaCache()` path, preserving their public APIs and cluster key handling. Add focused unit and real-Redis coverage; do not otherwise fold those blocking concurrency/throttle primitives into the new package. + At the end, repository-wide searches (excluding archived code, third-party examples, vendor, and historical `docs/plans`/`.tmp/plans` artifacts) must return no old namespace, no removed middleware class, no `throttleWithRedis`, and no `cache.limiter` in executable code, tests, configuration, stubs, or maintained user/agent documentation. Historical plans are records, not supported documentation, and must not be rewritten as part of this change. +Add `tests/Integration/RateLimiter` explicitly to both the Redis 8 and Valkey 9 command lists in `.github/workflows/redis.yml`; that workflow enumerates integration directories and will not discover the new suite automatically. The database workflow already discovers driver directories and needs no equivalent path edit. + ## Documentation work Update every applicable Boost document, not just the main rate-limiting page: @@ -844,12 +794,16 @@ Update every applicable Boost document, not just the main rate-limiting page: - `facades.md`: canonical accessor/class; - `middleware.md`: one throttle middleware class; - database docs: `make:rate-limiter-table`, schema purpose, pruning schedule; -- package README: driver guarantees, distribution boundaries, performance guidance, and failure behavior. +- package README: only the package heading, the canonical Boost documentation link, and concise public `Differences From Laravel`; omit an upstream link because this independently maintained package does not track a source package. `src/boost/docs-ported.md` already registers `rate-limiting.md`; retain that single inventory entry and do not add `rate-limiter.md` there or anywhere else. Add a concise explicit divergence to root `AGENTS.md`: Laravel locates its cache-bound limiter under `Illuminate\Cache`; Hypervel's canonical implementation is `hypervel/rate-limiter` / `Hypervel\RateLimiter`, uses typed policies and dedicated stores, and has no Cache namespace alias. This is the instruction LLMs should see when porting. +For intentionally omitted or deliberately changed Laravel behavior, follow the repository's three-place rule: concise package README differences, concise comments at the natural source insertion points, and `REMOVED:` markers at matching upstream test locations. Cover the `Hypervel\Cache` location, primitive counter methods, Redis-specific middleware classes/switch, `GlobalLimit`, hash opt-out, atomic `attempt()` consuming before the callback and retaining the charge on callback failure, sequential stacked-policy consumption that retains earlier charges when a later policy denies, and truthful non-zero remaining capacity on a weighted denial. Explain the replacement behavior and cover these semantics in Boost docs/tests. Do not add entries to `docs/ai/differences-vs-laravel.md`, which is queued for deletion. + +While updating root `AGENTS.md` with the rate-limiter divergence, remove its stale instructions to maintain `docs/ai/differences-vs-laravel.md`; that document's own header already marks it for deletion. Do not leave contradictory agent guidance in the touched file. + Do not copy internal research criticism into user documentation. Public docs should state the supported design clearly. ## Testing plan @@ -858,21 +812,22 @@ Create `tests/RateLimiter` and use the repository-required base test/coroutine c ### Policy/value tests -- Every fixed-window and leaky-bucket factory converts periods correctly. +- Every fixed-window and leaky-bucket factory converts periods correctly; leaky factories default burst to the sustained token count and `burst(1)` opts into strict smoothing. - Invalid zero/negative capacity, rate, duration, burst, cost, and backoff settings throw named exceptions. - Numeric boundary tests cover the shared Lua-exact/signed-64 limits and every overflow-prone multiplication/addition before a store mutation. - Fluent methods return new copies and do not mutate the original policy. - `globally`, scope, callbacks, cost, and response callbacks are retained correctly. -- Policy fingerprints are stable, parameter-sensitive, strategy-sensitive, and exclude cost/callbacks. +- Policy fingerprints are stable for a limiter prefix, change when the prefix or policy parameters change, distinguish policy types, and exclude cost/callbacks. - Arbitrary key segments cannot create ambiguous preimages before hashing. - Unlimited performs no store operation. - `LimitResult` and `BackoffResult` round timing up correctly and never expose negative remaining/retry values. -- Manager default/named/`UnitEnum` store resolution, one-instance caching, purge/forget behavior, typed configuration failures, and a custom `extend()` callback returning `Contracts\Store` all produce the expected wrapped `Limiter` without a second cache. +- Manager default/named/`UnitEnum` store resolution, named-limiter registered stores, explicit queue overrides, one-instance caching, purge/forget behavior, typed configuration failures, and a custom `extend()` callback returning `Contracts\Store` all produce the expected wrapped `Limiter` without a second cache. - Named limiter identity differs by limiter name, scope, global flag, normalized key value, policy type, and stable parameters exactly as specified; equivalent scalar/stringable/enum key values normalize identically, and direct policies do not accidentally invoke the named scope resolver. +- The shared typed PHP calculator produces the same transitions used by array, Swoole, and database stores without descriptor arrays or floating-point state. ### Shared store contract suite -Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, Redis 8.6, Redis 8.8, and Valkey 9. Add isolated Docker-backed integration jobs where the existing service matrix does not already provide a target; do not claim a supported first-party store/server combination from mocks alone. +Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, and the existing CI services for Redis 8 and Valkey 9. Do not add redundant Redis point-release jobs for a portable Lua path with no version branch, and do not claim a supported first-party store/server combination from mocks alone. - first consume, exact-capacity consume, weighted consume, over-capacity denial; - denied consume does not mutate count or extend TTL; @@ -901,15 +856,17 @@ Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, Postg - Redis connection `OPT_PREFIX` is applied once. - `TIME`-based leaky/backoff calculations ignore application-clock skew. - TTL is applied atomically and is unchanged on denial. -- Redis 8.6 and Valkey 9 run the exact same Lua implementation as Redis 8.8. +- Redis 8 and Valkey 9 run the exact same Lua implementation. - Add a focused assertion/test fixture guarding the required `@TODO`/portable path only if repository conventions permit source-shape tests; otherwise the docs TODO and code comment are sufficient. ### Swoole-specific tests +- Core `StripedLock` preserves Cache's row/all-lock behavior and timeout coverage after extraction; RateLimiter creates the same lock primitive before fork. +- Core `Timer` preserves Cache's injectable timer lifecycle coverage and is reused by RateLimiter without a package-local wrapper. - Table columns are 8-byte integers and table creation occurs before fork. - Same-key locks isolate transitions; different stripes can proceed independently. - Expired rows are pruned by timer and on access. -- Full table retries after pruning once and then throws without evicting live state. +- Full table logs the pressure warning, retries after pruning once, and then throws without evicting live state. - Timer is registered only by worker 0 and cleaned on exit/recycle. - Repeated worker lifecycle hooks do not register duplicate prune timers or retain stale timer IDs. - Store state never serializes a PHP value. @@ -930,18 +887,23 @@ Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, Postg ### Framework integration tests +- Foundation loads the rate-limiter defaults, application stores merge by name while a same-named store replaces its whole definition, and the provider does not perform a second merge. +- Application/Testbench config and environment defaults select the database store without requiring Redis. - Routing inline and named fixed limits. -- Named leaky-bucket routing, weighted costs, custom response, global/scope behavior, multiple policy ordering. +- `ThrottleRequests::using()`, `ThrottleRequests::with()`, middleware pipe syntax, missing named limiters, and custom responses retain their Laravel-facing behavior. +- Named leaky-bucket routing, registered/default store selection, weighted costs, custom response, global/scope behavior, and multiple policy ordering. - Response-based `after()` with matching/non-matching response and a concurrent post-response consume. -- Header values come from the local decision and remain isolated across same-key concurrent requests. +- Header values come from the local decision and remain isolated across same-key concurrent requests; weighted denials retain truthful non-zero remaining capacity when the rejected cost exceeds it. +- Atomic `attempt()` charges before invoking the callback and retains the charge on an exception; stacked policies retain earlier successful charges if a later policy denies. - Queue release timing, `dontRelease`, explicit store, and job serialization/wakeup. - `ThrottlesExceptions` consumes only qualifying failures and clears on success. - Fortify fixed lockout and clearing. - Foundation exception report throttling. - Reverb per-connection isolation and close cleanup with array store. - Facade resolves the canonical manager. -- `DefaultProviders` always contains `RateLimiterServiceProvider` after `RedisServiceProvider`, independently of package discovery. +- An existing provider/application integration test asserts that `DefaultProviders` contains `RateLimiterServiceProvider`, independently of package discovery; do not create composer-manifest tests or assert alphabetical order as runtime behavior. - Middleware configuration contains only `ThrottleRequests` and has no Redis switch. +- Existing Redis Duration/Concurrency limiter tests prove the SHA-cache migration preserves results, connection selection, prefixes, and cluster slot handling; real Redis verifies NOSCRIPT fallback. ### Static/quality checks @@ -959,25 +921,22 @@ Do not weaken PHPStan types, suppress errors, or widen return types to accommoda ## Performance validation -Add a reproducible developer-only CLI harness under `tests/Benchmarks/RateLimiter/`, including documented Docker image/version inputs; do not register a production Artisan command or treat PHPUnit timing as a benchmark. The harness must exercise the framework manager, pool, driver, result decoding, and middleware-relevant operation—not only a raw Redis command—so its numbers represent the code being shipped. +Add a reproducible developer-only CLI harness under `tests/Benchmarks/RateLimiter/`, including documented backend inputs; do not register a production Artisan command or treat PHPUnit timing as a benchmark. The harness must exercise the framework manager, pool, driver, result decoding, and middleware-relevant operation—not only a raw backend command—so its numbers represent the code being shipped. Measure at minimum: -- Redis fixed-window and leaky-bucket consume with 1, 50, and 200 concurrent clients; -- allowed-heavy, denied-heavy, and high-cardinality key distributions; -- Redis 8.6, Redis 8.8, and Valkey 9; -- Swoole same-key contention and high-cardinality keys across workers; -- database SQLite/MySQL/MariaDB/PostgreSQL separately, clearly labeled as correctness fallback; +- fixed-window and leaky-bucket consume through Redis, Swoole, and one explicitly labeled configured database backend; +- representative single-client and contended concurrency on allowed-heavy and denied-heavy paths, with the exact workload recorded in the output rather than a mandatory combinatorial matrix; - a one-time old cache-backed fixed-limiter baseline versus the new drivers before old code is removed; retain the recorded comparison, not a compatibility adapter or old implementation in the final harness; -- p50/p95/p99 latency, operations/second, pool wait, backend CPU, and Redis memory/key footprint. +- p50/p95/p99 latency and operations/second. Measure pool wait, backend CPU, memory, or extra server versions ad hoc only when the core results expose a concrete question. Acceptance invariants: - Redis steady-state admission is one network round trip, one pool checkout, and one script invocation. - No Redis cache serialization/compression path is entered. -- Swoole performs no serialization and no I/O. +- Swoole's ordinary admission path performs no serialization and no I/O; only the exceptional full-table path logs capacity pressure. - Middleware performs no post-consume state lookup for ordinary limits. -- Throughput/latency regressions between the portable Lua variants are explained before merge; optimize script internals rather than adding a premature version branch. +- Material throughput/latency regressions against the old baseline or between supported Redis and Valkey services are explained before merge; optimize the portable script rather than adding a premature version branch. The future `INCREX` TODO must be revisited with the same end-to-end result contract and benchmarks, not a raw-command microbenchmark alone. @@ -985,45 +944,33 @@ The future `INCREX` TODO must be revisited with the same end-to-end result contr This order keeps the tree buildable while still delivering one final cut with no compatibility residue: -1. Add package metadata/config/provider skeleton, root autoload/replace entry, and default provider registration. -2. Add immutable policies, fingerprints/key resolver, decisions, contracts, manager, and per-store `Limiter` wrapper with unit tests. -3. Implement array store and run the full shared contract against it. -4. Implement Redis Lua transitions using `evalWithShaCache()`, including the required focused `@TODO`; run Redis 8.6/8.8/Valkey integration and concurrency tests. -5. Implement Swoole table/state/timer/pruning and multi-worker concurrency tests. -6. Implement database store, migration/prune commands, server clocks, the Testbench default migration/config updates, and database integration/concurrency tests. +1. Add package metadata/provider skeleton, Foundation/application/Testbench config, root autoload/replace entry, and default provider registration. +2. Add immutable policies, fingerprints/key resolver, decisions, contracts, manager, shared typed PHP calculator, and per-store `Limiter` wrapper with unit tests. +3. Implement array store with the shared calculator and run the full store contract against it. +4. Implement Redis Lua transitions using `evalWithShaCache()`, including the required focused `@TODO`; run the existing Redis 8/Valkey 9 integration jobs and concurrency tests after adding their explicit RateLimiter path. +5. Extract the generic striped lock and existing cache timer seam into Core, update Cache, then implement the independent numeric Swoole table/state/timer/pruning and multi-worker tests. +6. Implement database store with the shared calculator, migration/prune commands, server clocks, default migrations, and database integration/concurrency tests. 7. Rewrite routing and Foundation middleware configuration; delete the Redis-specific request middleware/switch once tests pass. 8. Rewrite queue middleware and remove the two Redis-specific queue classes. 9. Rewrite Fortify, foundation exception throttling, Reverb, and facade access. 10. Move/replace rate-limiter tests into `tests/RateLimiter`; remove cache rate-limiter classes/config/binding/tests. -11. Update every composer dependency, Boost document, README, facade annotation, AGENTS divergence, and package inventory. +11. Update every composer dependency, Boost document, minimal README/divergence record, facade annotation, AGENTS divergence/stale references, package inventory, and explicit Redis workflow path. 12. Update the official framework metapackage and application skeleton dependency/config/base migration, verifying those repositories under their own instructions. -13. Remove the completed package TODO and obsolete Redis middleware-defect TODO bullets while retaining the native-increment and framework capability TODOs. -14. Run stale-code searches, per-package suites, cross-package integration suites, static analysis, benchmarks, and `git diff --check`. +13. Move the surviving Redis Duration/Concurrency limiter scripts onto `evalWithShaCache()` with focused tests. +14. Remove the completed package TODO and obsolete Redis middleware-defect TODO bullets while retaining the native-increment and framework capability TODOs. +15. Run stale-code searches, per-package suites, cross-package integration suites, static analysis, benchmarks, and `git diff --check`. No step should add a temporary alias or dual API. If intermediate local compilation requires ordering, make the consumer and provider changes in the same working change before handoff. ## Final verification checklist -- [ ] `Hypervel\RateLimiter` is the only limiter namespace. -- [ ] The support facade resolves `Hypervel\RateLimiter\RateLimiter`. -- [ ] `RateLimiterServiceProvider` is unconditional framework infrastructure in `DefaultProviders`, not dependent on package discovery. -- [ ] Fixed, leaky-bucket/GCRA, and exponential backoff policies are typed separately. -- [ ] No strategy/driver enum or nullable strategy parameter bag exists. -- [ ] Redis/Swoole/database/array stores pass one shared semantic suite. -- [ ] The package has no cache-repository dependency, generic cache driver, or file driver. -- [ ] Database uses only the dedicated `rate_limits` table. -- [ ] The framework metapackage requires the split package and a fresh application skeleton includes its config and `rate_limits` migration. -- [ ] Testbench's default configuration/migrations provision and roll back the same database limiter table as the application skeleton. -- [ ] `src/boost/docs/rate-limiting.md` is the one canonical rate-limiting page and is updated comprehensively; no duplicate `rate-limiter.md` exists. -- [ ] Redis's normal path is one cached Lua invocation and works on Redis 8.6/8.8 and Valkey 9. -- [ ] The `INCREX` docs TODO and focused code `@TODO` both exist with accurate prerequisites. -- [ ] Routing has one throttle middleware and no `throttleWithRedis` API. -- [ ] Queue has no Redis-specific rate-limit middleware subclasses. -- [ ] Reverb does not construct a cache rate limiter. -- [ ] No ordinary admission path checks then separately hits or re-reads for headers. -- [ ] No singleton stores request-local remaining/reset state. -- [ ] Swoole never evicts a live limiter row. -- [ ] Store failures never fail open. -- [ ] All old classes, docs, imports, tests, config keys, aliases, and obsolete TODOs are removed. -- [ ] AGENTS.md tells porting agents about the deliberate Laravel namespace/API divergence. -- [ ] Benchmarks and concurrency tests demonstrate the performance/correctness claims. +- [ ] `Hypervel\RateLimiter` is the sole namespace; its facade and unconditional default provider resolve the new manager with no Cache shim or dual API. +- [ ] Fixed, GCRA/leaky-bucket, unlimited, and exponential-backoff policies are typed; no strategy/driver enum, descriptor bag, or speculative algorithm exists. +- [ ] Redis/Swoole/database/array pass the shared semantic and concurrency suites; failures never fail open and no driver routes through generic cache serialization. +- [ ] Redis admission is one cached Lua call on the existing Redis 8 and Valkey 9 services; Swoole uses shared numeric state without live eviction and documents/logs capacity pressure; database uses only `rate_limits`. +- [ ] Foundation, the application skeleton, and Testbench carry matching config/default migrations; named stores merge without a duplicate package config. +- [ ] Routing retains its Laravel-facing helpers, syntax, callbacks, exceptions, headers, and registered-store selection with one middleware; queue and Reverb have no Redis/cache limiter branches. +- [ ] The framework metapackage, package dependencies, facade metadata, Boost's single `rate-limiting.md`, minimal README, AGENTS guidance, and required source/test difference markers agree. +- [ ] Old namespaces, classes, config, tests, docs, switches, stale state, and obsolete TODOs are absent; the INCREX and capability TODOs remain accurate. +- [ ] Existing Redis Duration/Concurrency limiters use the tested SHA-cache path without API changes. +- [ ] Static analysis, all affected suites, end-to-end benchmarks, stale-symbol searches, and `git diff --check` pass. diff --git a/docs/todo.md b/docs/todo.md index 27f441ca8..73ae6e1db 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -18,6 +18,7 @@ ## Framework-wide - Convert the remaining tests that extend `PHPUnit\Framework\TestCase` to `Hypervel\Tests\TestCase` as required by `AGENTS.md`, verifying each file individually under coroutine execution and opting out only when the test explicitly exercises coroutine transitions. +- Design a connection-owned service identity and capability API for external backends that packages can query without repeated hot-path probes. Redis/Valkey and database connections already expose fragments of this information in different forms; prefer lazy detection cached for the current connection or pool generation, with invalidation on reconnect and purge, over an eager process-global startup registry that performs unused I/O or survives a backend change. Start with concrete consumers and capability checks rather than a universal version-comparison abstraction. - Find a clean, simple framework-wide solution for configuration-dependent services resolved before worker configuration reload. `server:reload` refreshes the existing configuration repository, but objects that have already copied configuration into their own state remain stale. For example, `SentryServiceProvider` eagerly resolves a worker-lifetime Hub and client during boot, so DSN, environment, and sampling changes are not applied until a full restart; resolving `Cache::store('some-store')` from a service provider populates `CacheManager`'s store cache before reload, so changes to that store's driver, connection, prefix, or other captured configuration are likewise not applied. Define the reload contract, audit framework-owned eager resolutions and manager caches, and solve the lifecycle at their shared owning boundary instead of adding package-specific refresh hooks or application workarounds. - Convert container array access to `make()` across `src/`. About 40 files use `$app['...']` (e.g. `LogManager`, `ViewServiceProvider`, `TranslationServiceProvider`), carried over from upstream Laravel. `offsetGet()` always returns `mixed`, while `make()` has class-string generics phpstan can follow, so the conversion makes static analysis strictly more useful. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule. - Investigate where requiring and directly using a PHP extension would make framework code significantly faster than its current pure-PHP implementation. The framework already declares bundled extensions it depends on, so the question is which hot paths are doing in PHP what a C extension does natively. The worked example is `ext-gmp` for identifier encoding: UUID and ULID string conversion and any base32/base58/base62 short-id work exceed 64 bits, so `ramsey/uuid` and `symfony/uid` convert them digit by digit in PHP, while `gmp_init()`/`gmp_strval()` do arbitrary-base conversion natively — a hand-rolled base-36 UUID conversion measured 14.6 µs against 0.5 µs for the GMP equivalent with byte-identical output. Anything that fits in a 64-bit int (snowflakes, timestamps, counters) needs no extension, and hashing, encryption, and signatures are already C. Measure `Str::uuid()`/`Str::ulid()` and the other candidates before adding a requirement, and weigh each new extension against installation cost. @@ -45,6 +46,13 @@ ## 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. +- Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then. +- Remove request-local state from the auto-singletoned `ThrottleRequestsWithRedis` middleware. Its `$decaysAt` and `$remaining` arrays persist for the worker lifetime: concurrent requests using the same limiter key can overwrite response-header state while an earlier request is running the downstream handler, and distinct keys accumulate without bound. Keep each `DurationLimiter::acquire()` result local to the request while preserving atomic Redis admission and the middleware's supported extension surface; add concurrent same-key header-isolation coverage and worker-lifetime state cleanup coverage. +- Make `ThrottleRequestsWithRedis` honor `Limit::after()`. The current middleware acquires every limit before running the downstream handler and never evaluates `afterCallback`, so responses that the named limit explicitly excludes are still counted; current Laravel checks first and records the hit after the response when the callback accepts it. Preserve Hypervel's one-call atomic consume path for limits without an after callback, use a non-consuming check followed by a conditional consume only for response-dependent limits, and port the upstream behavior coverage together with Redis concurrency tests. + +## Rate Limiting + +- Replace the cache-bound limiter and Redis-specific middleware branches with a first-party `hypervel/rate-limiter` package under the canonical `Hypervel\RateLimiter` namespace, with no Cache namespace shim. Use typed immutable fixed-window, GCRA/leaky-bucket, unlimited, and capped exponential-backoff policies; dedicated atomic Redis Lua, shared-memory Swoole, dedicated-table database, and worker-local array stores; one complete decision per operation; and a driver extension boundary that never routes native state through generic cache serialization. Refactor routing, queue, Fortify, exception reporting, Reverb, the facade, configuration, official metapackage, and application skeleton to the new API; remove the three Redis-specific middleware classes/switches and every stale cache-limiter symbol/config/test/doc; close the two Redis middleware defects listed above; and require shared semantic, concurrency, integration, static-analysis, and end-to-end performance coverage before removing this TODO. ## Collections From 69ff7db3dc77e7abd9205fb2682ce151e3f08bb0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:22:20 +0000 Subject: [PATCH 03/41] Finalize rate limiter package plan --- .../2026-08-04-1543-rate-limiter-package.md | 197 +++++++++++------- 1 file changed, 122 insertions(+), 75 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index c04613660..b511ce7ed 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -15,7 +15,7 @@ Create `src/rate-limiter` as a split first-party package with these properties: - No `Hypervel\Cache\RateLimiter` class, no `Hypervel\Cache\RateLimiting` namespace, and no aliases back to either namespace. - The cache repository is not part of the driver contract. Each driver owns its atomic state transition and native storage representation. - Fixed-window admission, GCRA-backed leaky-bucket admission, and capped exponential failure backoff are distinct typed policies. There is no generic `strategy` string plus nullable bag of unrelated parameters. -- Redis, Swoole, database, and array are first-party stores. File and generic cache stores are intentionally not supported. +- Redis, Swoole, database, and worker-array are first-party stores. File, request-local array, and generic cache stores are intentionally not supported. - Redis performs one pooled checkout and one `EVALSHA` in the steady-state admission path, with the existing `evalWithShaCache()` NOSCRIPT fallback on the first execution per Redis node. - Swoole performs one short striped-lock critical section over native integer columns, with no PHP serialization. - The database store uses a dedicated `rate_limits` table, not `cache` or `cache_locks`, and performs an atomic transaction with a row lock. @@ -59,10 +59,10 @@ Do not add a `Hypervel\Cache\RateLimiter` alias or wrapper. Two class locations The manager resolves named stores from `rate-limiter.stores`. Each store has a `driver` string, following Laravel's manager/config conventions. The algorithm is selected by the policy object's concrete type: -- `Limit` is the familiar fixed-window policy. +- `AdmissionPolicy` is the clearly named admission-policy base; Laravel's familiar concrete `Limit` remains the fixed-window policy. - `LeakyBucket` is the smoothed admission policy, implemented with GCRA. - `Unlimited` bypasses storage. -- `Backoff::exponential(...)` returns an `ExponentialBackoff` failure policy. +- `Backoff::exponential(...)` returns a concrete exponential failure policy. Adding an admission algorithm later means adding a typed policy and implementing its state transition in each supported store. That is intentional: atomic algorithms and their storage primitives are coupled. A single strategy DTO would hide that coupling and accumulate irrelevant fields. @@ -107,6 +107,7 @@ Verified constraints as of 2026-08-04, including direct `COMMAND INFO INCREX`/ex - `Redis::rawCommand()` bypasses `OPT_PREFIX`; this was verified against Redis 8.8 (`raw-key` remained unprefixed while an EVAL key became `prefix:eval-key`). - `RedisCluster::rawCommand()` has the different signature `rawCommand($key_or_address, $command, ...$args)`, so a generic standalone raw-command call is not cluster-safe. - Direct `INCREX` returns the new counter and applied increment but not the remaining TTL required for `Retry-After`/reset metadata. A second command, a pipeline/transaction, or a Lua wrapper would still be needed for the framework's full result. +- The portable fixed-window script can use `INCRBY` for an accepted existing window. It has existed since Redis 1.0 and changes the string value in place, so Redis/Valkey preserve the existing TTL without Redis 6's `SET ... KEEPTTL`. The complete `EVAL`/`TIME`/`PTTL`/`SET PX`/`INCRBY` path was also executed against Redis 5.0.14 with its TTL intact. Do not advertise a broader server support matrix without CI, but do not introduce an unnecessarily new command floor either. A local indicative Redis 8.8 `redis-benchmark` run (300,000 requests, 50 clients, random keyspace) measured approximately 61.6k requests/s for direct `INCREX`, 48.4k for a portable full-result Lua script, and 50.5k for Lua wrapping `INCREX` plus `PTTL`. These figures are not a release benchmark, but they show that the native primitive may eventually be useful while also showing that full framework semantics reduce the direct-command advantage. @@ -130,13 +131,13 @@ Remove the comment and the documentation TODO together when the native implement - The requested Laravel packages add no safe driver boundary: Oltrematica is configuration-oriented, while milenmk's progressive lockout replays cache hits and is not concurrency-safe. Fibonacci remains deliberately unimplemented; exponential backoff is a separate failure policy. - Symfony supports typed policies, weighted consumption, and rich results, but its generic storage/lock path is not the Redis hot-path design. `go-redis/redis_rate` and Cloudflare support the single-TAT GCRA representation. -Implementation references: [Laravel rate limiting](https://laravel.com/docs/13.x/rate-limiting), [Symfony RateLimiter](https://symfony.com/doc/current/rate_limiter.html), [Redis scripting](https://redis.io/docs/latest/develop/programmability/eval-intro/), [Redis Functions](https://redis.io/docs/latest/develop/programmability/functions-intro/), [Redis TIME](https://redis.io/docs/latest/commands/time/), [Redis INCREX](https://redis.io/docs/latest/commands/increx/), [Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/), [Google retry guidance](https://docs.cloud.google.com/storage/docs/retry-strategy), [OWASP authentication throttling](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html), and [Cloudflare rate-limiting algorithms](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/). +Implementation references: [Laravel rate limiting](https://laravel.com/docs/13.x/rate-limiting), [Symfony RateLimiter](https://symfony.com/doc/current/rate_limiter.html), [Redis scripting](https://redis.io/docs/latest/develop/programmability/eval-intro/), [Redis Functions](https://redis.io/docs/latest/develop/programmability/functions-intro/), [Redis TIME](https://redis.io/docs/latest/commands/time/), [Redis INCRBY](https://redis.io/docs/latest/commands/incrby/), [Redis expiry preservation](https://redis.io/docs/latest/commands/expire/), [Redis INCREX](https://redis.io/docs/latest/commands/increx/), [Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/), numeric command-argument bridges in [Redis 5](https://github.com/redis/redis/blob/5.0/src/scripting.c#L426-L432), [Redis 8](https://github.com/redis/redis/blob/8.0/src/script_lua.c#L799-L814), and [Valkey 9](https://github.com/valkey-io/valkey/blob/9.0.0/src/lua/script_lua.c#L829-L844), [Google retry guidance](https://docs.cloud.google.com/storage/docs/retry-strategy), [OWASP authentication throttling](https://cheatsheets.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html), and [Cloudflare rate-limiting algorithms](https://blog.cloudflare.com/counting-things-a-lot-of-different-things/). ## Public API ### Manager and store selection -`Hypervel\RateLimiter\RateLimiter` extends `MultipleInstanceManager`. It owns named limiter callbacks and resolves per-store `Limiter` instances. The facade delegates unknown methods to the default store through the manager, matching Laravel manager conventions. The package name is `hypervel/rate-limiter`, not `hypervel/rate-limit`: the former names the component being provided, while `RateLimit` is the policy abstraction consumed by that component. +`Hypervel\RateLimiter\RateLimiter` extends `MultipleInstanceManager`. It owns named limiter callbacks and resolves per-store `Limiter` instances. The facade delegates unknown methods to the default store through the manager, matching Laravel manager conventions. The package name is `hypervel/rate-limiter`, not `hypervel/rate-limit`: the former names the component being provided, while `AdmissionPolicy` clearly names the admission abstraction and Laravel's `Limit` remains the fixed-window policy applications use. ```php namespace Hypervel\RateLimiter; @@ -211,13 +212,15 @@ Factories: Modifiers shared by admission policies: -- `by(Stringable|UnitEnum|string|int $key): static` (normalized once to a string with `enum_value()` for enums); -- `cost(int $cost): static` (positive, and no greater than the policy's capacity); +- `by(Stringable|UnitEnum|string|int|null $key): static` (normalized once to a string, with `null` intentionally matching Laravel's empty/shared key); +- `cost(int $cost): static` (positive; the final capacity relationship is validated at operation time so fluent order is irrelevant); - `globally(bool $global = true): static` (bypasses Hypervel's named key-scope resolver); - `after(callable $callback): static`; - `response(callable $callback): static`. -Retain Laravel's convenient readable-property shape, but make the properties `public readonly`: `key`, `cost`, `global`, `afterCallback`, and `responseCallback` on `RateLimit`; `maxAttempts` and `decaySeconds` on `Limit`; and `rate`, `periodMicroseconds`, and `burst` on `LeakyBucket`. Each modifier constructs/copies a fully validated new value. Do not expose mutable public state, reflection-based cloning, or a generic `options` array. Internal stores may read these typed properties directly without getter-call overhead. +Retain Laravel's convenient readable-property shape, but make the properties `public readonly`: `key`, `cost`, `global`, `afterCallback`, and `responseCallback` on `AdmissionPolicy`; `maxAttempts` and `decaySeconds` on `Limit`; and `rate`, `periodMicroseconds`, and `burst` on `LeakyBucket`. `AdmissionPolicy` declares a protected copy hook receiving every shared field; each concrete policy implements it by invoking its own constructor with those fields plus its typed algorithm fields. Shared modifiers call that hook. Concrete modifiers use the same constructor path. Do not use reflection, post-clone readonly writes, or a generic options array. + +Modifiers validate their own scalar/range input immediately. Cross-field constraints are validated on `Limiter::consume()`/`inspect()` before key resolution or storage access, so fluent order is irrelevant: both `LeakyBucket::perSecond(100)->cost(150)->burst(200)` and the reverse order are valid, while a final `cost > burst` fails before mutation. Internal stores may read the typed readonly properties directly without getter-call overhead. `globally()` replaces `GlobalLimit`; do not carry a second class solely to mark scope behavior. @@ -265,23 +268,24 @@ The common `Decision` contract contains `allowed()`, `denied()`, and `retryAfter ### Limiter operations ```php -final class Limiter +class Limiter { public function getStore(): Contracts\Store; - public function consume(RateLimit $limit, UnitEnum|string|null $limiterName = null): LimitResult; + public function consume(AdmissionPolicy $policy, UnitEnum|string|null $limiterName = null): LimitResult; - public function inspect(RateLimit|Backoff $policy, UnitEnum|string|null $limiterName = null): LimitResult|BackoffResult; + /** @return ($policy is Backoff ? BackoffResult : LimitResult) */ + public function inspect(AdmissionPolicy|Backoff $policy, UnitEnum|string|null $limiterName = null): LimitResult|BackoffResult; - public function attempt(RateLimit $limit, Closure $callback, UnitEnum|string|null $limiterName = null): mixed; + public function attempt(AdmissionPolicy $policy, Closure $callback, UnitEnum|string|null $limiterName = null): mixed; public function recordFailure(Backoff $backoff, UnitEnum|string|null $limiterName = null): BackoffResult; - public function clear(RateLimit|Backoff $policy, UnitEnum|string|null $limiterName = null): bool; + public function clear(AdmissionPolicy|Backoff $policy, UnitEnum|string|null $limiterName = null): bool; } ``` -`RateLimit` is the abstract admission-policy base implemented by `Limit`, `LeakyBucket`, and `Unlimited`; `Backoff` is a separate failure-policy base. `consume()` is the normal one-call atomic operation. `inspect()` never mutates state. `attempt()` atomically consumes before invoking the callback and returns `false` on denial; if a callback returns `null`, it returns `true`, preserving Laravel's convenient semantics. If the callback throws, the accepted token remains consumed. Code that should charge only on failure or on a response predicate must use `inspect()` followed by the appropriate explicit operation. +`AdmissionPolicy` is the abstract base implemented by `Limit`, `LeakyBucket`, and `Unlimited`; the distinct name avoids conflating the `RateLimiter` manager, per-store `Limiter`, and Laravel-compatible `Limit`. `Backoff` is a separate concrete failure policy. `consume()` is the normal one-call atomic operation. `inspect()` never mutates state. The conditional PHPDoc return is part of both public and store contracts so PHPStan narrows admission inspection to `LimitResult` and backoff inspection to `BackoffResult` without caller assertions. `attempt()` atomically consumes before invoking the callback and returns `false` on denial; if a callback returns `null`, it returns `true`, preserving Laravel's convenient semantics. If the callback throws, the accepted token remains consumed. Code that should charge only on failure or on a response predicate must use `inspect()` followed by the appropriate explicit operation. The optional `limiterName` is only identity context for a policy obtained from `RateLimiter::for()`. Routing and queue middleware must pass it; direct calls omit it. It is deliberately not a mutable hidden field on a policy and not the selected store name. This closes the collision between two named limiters that return otherwise identical policies while keeping direct policy use terse. @@ -310,7 +314,7 @@ try { } ``` -`Backoff::exponential(...)` returns a typed `ExponentialBackoff`. The fifth failure in the example creates the initial one-second block. Each subsequent failure after the block is eligible doubles the delay, capped at `maxDelay`. `resetAfter` resets failure history after inactivity and must be at least `maxDelay`. A success calls `clear()`. +`Backoff::exponential(...)` is the sole constructor and returns a `Backoff` configured for exponential delay. Keep this as one concrete value class until a second backoff algorithm is justified; an abstract base plus a one-member subclass would add hierarchy without current polymorphism. The fifth failure in the example creates the initial one-second block. Each subsequent failure after the block is eligible doubles the delay, capped at `maxDelay`. `resetAfter` resets failure history after inactivity and must be at least `maxDelay`. A success calls `clear()`. As with admission policies, expose validated `public readonly` fields (`key`, `after`, `initialDelay`, `maxDelay`, and `resetAfter`) and make `by()` return a new value. Keep integer seconds at the public boundary and convert once to the driver's internal microseconds. @@ -328,10 +332,12 @@ The physical identity includes: 4. caller key from `by()` (an empty key intentionally means a shared/global policy); 5. policy type and canonical parameters. -Each segment is domain-tagged and length-prefixed before the entire identity is hashed with seeded `xxh128`. Derive the stable integer seed once when resolving the store as `hexdec(substr(hash('xxh128', 'rate-limiter|' . $prefix), 0, 15))`, using only the validated limiter prefix. Do not use `app.key` or generate a process-local seed: rotating an unrelated encryption key must not clear active limiter state, and every worker/application node with the same limiter prefix must derive identical physical keys. Domain tags distinguish, for example, a limiter name from a caller key; length prefixes keep arbitrary normalized strings injective. Keys are normalized to strings first, so equivalent `1`, `'1'`, a stringable `'1'`, and an enum value `'1'` intentionally identify the same bucket. The configured prefix is a pre-hash application namespace, so every driver still receives the same fixed 32-character lowercase hexadecimal key. Redis's connection-level `OPT_PREFIX` and a database connection's table prefix remain outside this digest and are applied exactly once by those components. +Each segment is domain-tagged and length-prefixed before the entire identity is hashed with seeded `xxh128`. Derive the stable integer seed once when resolving the store as `hexdec(substr(hash('xxh128', 'rate-limiter|' . $prefix), 0, 15))`, using only the validated limiter prefix. Do not use `app.key` or generate a process-local seed: rotating an unrelated encryption key must not clear active limiter state, and every worker/application node with the same limiter prefix must derive identical physical keys. Domain tags distinguish, for example, a limiter name from a caller key; length prefixes keep arbitrary normalized strings injective. Keys are normalized to strings first, so equivalent `1`, `'1'`, a stringable `'1'`, and an enum value `'1'` intentionally identify the same bucket; `null` intentionally normalizes to the same empty/shared key as `''`. The configured prefix is a pre-hash application namespace, so every driver still receives the same fixed 32-character lowercase hexadecimal key. Redis's connection-level `OPT_PREFIX` and a database connection's table prefix remain outside this digest and are applied exactly once by those components. Policy callbacks and request cost are excluded from the policy fingerprint; the global-scope flag is included because it changes policy identity. Including stable policy parameters means two limits with the same `by()` value but different windows/algorithms naturally have different state, and changing policy configuration starts clean state while the old TTL expires. The Laravel fallback-key mutation is unnecessary. Add golden-vector tests for the canonical encoding so an apparently harmless refactor cannot orphan all active state. +Because parameters are part of identity, `clear()` removes only state addressed by an identically parameterized policy. Changing a limit/window intentionally starts new state; callers that must clear the previous state need the previous policy value until its TTL expires. Reverb and Fortify must centralize policy construction so consume/inspect/clear rebuild the same value. Document this difference and test both matching and changed-parameter clears; do not add backend scans or secondary key indexes to clear every historical variant. + Always hash physical identities. Remove `ThrottleRequests::shouldHashKeys()` and its process-global switch. The Swoole key itself remains a fixed 32-character digest, safely below Swoole Table's key limit. ## Internal package architecture @@ -343,7 +349,7 @@ src/rate-limiter/ ├── README.md ├── composer.json └── src/ - ├── ArrayStore.php + ├── AdmissionPolicy.php ├── Backoff.php ├── BackoffResult.php ├── Console/ @@ -360,27 +366,27 @@ src/rate-limiter/ ├── Exceptions/ │ ├── InvalidRateLimitException.php │ └── SwooleTableFullException.php - ├── ExponentialBackoff.php ├── LeakyBucket.php ├── Limit.php ├── Limiter.php ├── LimitResult.php - ├── RateLimit.php + ├── Listeners/ + │ ├── InitializeSwooleTables.php + │ └── RegisterPruneTimer.php ├── RateLimiter.php ├── RateLimiterServiceProvider.php ├── RedisStore.php ├── Swoole/ - │ ├── CreateTables.php - │ ├── PruneTables.php │ ├── TableManager.php │ └── TableState.php ├── SwooleStore.php - └── Unlimited.php + ├── Unlimited.php + └── WorkerArrayStore.php ``` -Avoid an `Algorithms` service hierarchy. Policies hold validated immutable configuration. Array, Swoole, and database stores share typed integer transition math through `CalculatesRateLimits`; Redis implements the same semantics in Lua. Both paths use a small exhaustive `instanceof` dispatch, never descriptor arrays or strategy enums. An unsupported policy throws `InvalidRateLimitException`. +Avoid an `Algorithms` service hierarchy. Policies hold validated immutable configuration. Worker-array, Swoole, and database stores share typed integer transition math through `CalculatesRateLimits`; Redis implements the same semantics in Lua. Both paths use a small exhaustive `instanceof` dispatch, never descriptor arrays or strategy enums. An unsupported policy throws `InvalidRateLimitException`. -`RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver using the then-current validated prefix plus the manager-owned optional scope callback. Built-in `createArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. Resolve/freeze static key configuration when the lazy store wrapper is created; do not read the config repository on every consume. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry. +`RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver. Built-in `createWorkerArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. Resolve/freeze static key configuration such as the validated prefix when the lazy store wrapper is created, but have the resolver read the manager's current optional scope callback on every named operation. That single property read keeps `resolveKeyScopeUsing()` effective even if a store was resolved first. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry. ### Store contract @@ -389,9 +395,10 @@ The contract receives an already-resolved fixed-length physical key and validate ```php interface Store { - public function consume(string $key, RateLimit $limit): LimitResult; + public function consume(string $key, AdmissionPolicy $policy): LimitResult; - public function inspect(string $key, RateLimit|Backoff $policy): LimitResult|BackoffResult; + /** @return ($policy is Backoff ? BackoffResult : LimitResult) */ + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult; public function recordFailure(string $key, Backoff $backoff): BackoffResult; @@ -410,7 +417,7 @@ interface PrunableStore ### Configuration -As an always-installed framework component, the canonical defaults live in `src/foundation/config/rate-limiter.php`, alongside Cache, Queue, and Concurrency configuration. Mirror that file into the application and Testbench skeletons. Add `'rate-limiter' => ['stores']` to `LoadConfiguration::mergeableOptions()` so application stores merge by name; this declares merge policy only and does not duplicate configuration values. `RateLimiterServiceProvider` must not merge or publish a second package-owned config file. +As an always-installed framework component, the canonical defaults live in `src/foundation/config/rate-limiter.php`, alongside Cache, Queue, and Concurrency configuration. Mirror the database-default file into the application skeleton. Testbench carries the same stores but overrides only its default to `worker-array`, matching Testbench's deliberate in-memory cache default; its standard database migration remains available for database-store integration tests. Add `'rate-limiter' => ['stores']` to `LoadConfiguration::mergeableOptions()` so application stores merge by name; this declares merge policy only and does not duplicate configuration values. `RateLimiterServiceProvider` must not merge or publish a second package-owned config file. ```php return [ @@ -432,11 +439,12 @@ return [ 'driver' => 'swoole', 'rows' => (int) env('RATE_LIMITER_SWOOLE_ROWS', 65536), 'conflict_proportion' => 0.2, - 'prune_interval' => 60000, + 'memory_limit_buffer' => 0.05, + 'prune_interval' => 60, // seconds ], - 'array' => [ - 'driver' => 'array', + 'worker-array' => [ + 'driver' => 'worker-array', ], ], @@ -450,7 +458,9 @@ Use typed config getters and validate every store at resolution. The service pro Do not add algorithm defaults to global config. Rates belong to typed application policy definitions, not storage configuration. -Policy construction performs store-independent range validation against the strictest shared numeric representation, including Redis Lua's largest exactly representable integer (`9_007_199_254_740_991`) and signed 64-bit Swoole/database columns. Reject a leaky-bucket rate greater than its period's microsecond count because its sub-microsecond emission interval cannot be represented without changing the promised rate. Driver operations then validate time-dependent additions (for example `now + emission * burst`) before mutation. Reject an unrepresentable policy with `InvalidRateLimitException`; do not add arbitrary-precision math, saturate silently, or let the same policy work on one first-party store and corrupt on another. +Policy construction performs store-independent scalar/range validation against the strictest shared numeric representation, including Redis Lua's largest exactly representable integer (`9_007_199_254_740_991`) and signed 64-bit Swoole/database columns. Reject a leaky-bucket rate greater than its period's microsecond count because its sub-microsecond emission interval cannot be represented without changing the promised rate. `Limiter` then validates cross-field constraints and time-dependent additions (for example `cost <= burst` and `now + emission * burst`) before key resolution or mutation. Reject an unrepresentable policy with `InvalidRateLimitException`; do not add arbitrary-precision math, saturate silently, or let the same policy work on one first-party store and corrupt on another. + +Keep the exact Redis ceiling above; do not confuse Lua's lower-precision `tostring()` display with the `redis.call()` command bridge. Redis 5 deliberately converts numeric command arguments with `%.17g` instead of `lua_tolstring()` to avoid precision loss, while current Redis and Valkey convert exact integer-valued doubles through `double2ll()`/`ll2string()`. Live Redis 5.0.14 and Valkey 9 checks accepted `9_007_199_254_740_991` as an `INCRBY` argument and preserved the full decimal value. Reuse the original canonical `ARGV` strings for fixed-window `SET`/`INCRBY` arguments because they are already available, but do not add `string.format()` wrappers or an artificial `10^14` policy ceiling; computed integer command arguments remain exact under the validated `2^53 - 1` bound. ## Algorithm specifications @@ -464,6 +474,8 @@ Semantics match Laravel's first-hit-anchored interval rather than a calendar-ali 4. Remaining capacity is `maxAttempts - current` after an accepted operation and the current remaining capacity after denial. 5. Retry/reset is the existing TTL rounded up. +`inspect()` on an absent or expired key must not start a window. It returns allowed, `remaining = maxAttempts`, `retryAfter = 0`, and `resetAfter = 0`. By contrast, the first accepted `consume()` creates the window and returns its full duration as `resetAfter`. This distinction preserves Fortify's public `availableIn()` behavior for untouched keys and must be identical across stores. + All drivers must implement the same boundary behavior, including weighted costs equal to capacity and rejected costs over capacity. ### Leaky bucket / GCRA @@ -488,7 +500,7 @@ reset = max(effective_tat - now, 0) An absent/fully drained bucket uses `effective_tat = now`, so inspect reports the full burst. Persist the accepted state for `ceil(reset / 1000)` milliseconds on Redis (minimum one millisecond while state is non-empty) and exact microseconds on numeric local/database stores. A driver may encounter a physically present but logically drained record and must treat it as empty without extending stale state. -Use Redis `TIME`, Swoole `hrtime(true)`, and database server time (except local SQLite, which uses wall-clock microseconds) so distributed Redis/database decisions do not depend on the application node's clock. Clamp negative elapsed time to zero defensively. Validate microsecond resolution, integer overflow, Redis Lua's exact-integer range, positive rates/periods, and burst/cost limits before accessing storage. +Use Redis `TIME`, epoch-microsecond application time for worker-array/Swoole/local SQLite, and database server time for the other databases so distributed Redis/database decisions do not depend on the application node's clock. Clamp negative elapsed time to zero defensively. Validate microsecond resolution, integer overflow, Redis Lua's exact-integer range, positive rates/periods, and burst/cost limits before accessing storage. ### Exponential backoff @@ -527,7 +539,7 @@ local durationMilliseconds = tonumber(ARGV[3]) local durationMicroseconds = durationMilliseconds * 1000 local function start_window() - redis.call('SET', KEYS[1], cost, 'PX', durationMilliseconds) + redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) return {1, limit, limit - cost, 0, durationMicroseconds} end @@ -537,6 +549,10 @@ if not raw then return start_window() end +if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then + return redis.error_reply('CORRUPT rate limiter counter') +end + local current = tonumber(raw) if not current or current < 0 or current > limit or current % 1 ~= 0 then return redis.error_reply('CORRUPT rate limiter counter') @@ -550,17 +566,17 @@ if ttl <= 0 then return start_window() end -local next = current + cost +local nextValue = current + cost -if next > limit then +if nextValue > limit then return {0, limit, limit - current, ttl * 1000, ttl * 1000} end -redis.call('SET', KEYS[1], next, 'KEEPTTL') -return {1, limit, limit - next, 0, ttl * 1000} +local incremented = redis.call('INCRBY', KEYS[1], ARGV[1]) +return {1, limit, limit - incremented, 0, ttl * 1000} ``` -The production script must keep every result numeric, provide an inspect mode without creating a missing key, and include the required `@TODO` immediately beside it. A present fixed-window key with a non-integer value, negative/out-of-range count, or no expiry (`PTTL == -1`) is corrupt: raise a Lua error and propagate it rather than deleting the key and potentially failing open. A zero/expired TTL is a real boundary condition and starts a fresh window atomically. Validate impossible costs in PHP so the script never creates an over-capacity first value. +The production script must keep every result numeric, provide an inspect mode without creating a missing key (returning `{1, limit, limit, 0, 0}`), and include the required `@TODO` immediately beside it. A present fixed-window key with a noncanonical integer string (including a leading-zero value other than `0`), negative/out-of-range count, or no expiry (`PTTL == -1`) is corrupt: raise a Lua error and propagate it rather than deleting the key and potentially failing open. A zero/expired TTL is a real boundary condition and starts a fresh window atomically only for consume. Validate impossible costs in PHP so the script never creates an over-capacity first value. On an accepted existing window, use the integer returned by `INCRBY` for the decision and verify through integration coverage that the original TTL is retained; do not replace the value with `SET ... KEEPTTL`. Do not alter `RedisConnection::callEvalsha()` for this package; the correct `evalWithShaCache()` path already exists and has real Redis integration coverage. @@ -569,16 +585,17 @@ Do not alter `RedisConnection::callEvalsha()` for this package; the correct `eva - Own a dedicated `Swoole\Table`; do not reuse `SwooleStore` or `SwooleTableManager` from cache. - Columns are `value`, `available_at`, and `expires_at`, all `Table::TYPE_INT` with an explicit 8-byte width. - Use a fixed 32-character hashed key. -- Resolve the package-local `Swoole\TableManager` as an unbound concrete, using Hypervel's auto-singleton behavior rather than an explicit container binding. `CreateTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. -- Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. If a running worker requests a Swoole store whose table was not created before fork, throw a lifecycle/configuration exception rather than silently allocate a worker-private table. Console/tests may explicitly initialize a table before concurrent use. +- Resolve the package-local `Swoole\TableManager` as an unbound concrete, using Hypervel's auto-singleton behavior rather than an explicit container binding. `Listeners\InitializeSwooleTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. +- Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. After creating the configured tables, `InitializeSwooleTables` seals the manager. Before sealing, console/tests may explicitly initialize named tables; after sealing, `get()` returns only a pre-created state and an unknown name throws instead of allocating worker-private state. The sealed flag is set before fork and inherited by workers. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. - Extract the proven 64-stripe Atomic lock coordinator from cache's `SwooleTableState` into a small `Hypervel\Core\Swoole\StripedLock` primitive used by both Cache and RateLimiter. It owns key-to-stripe selection, short spin/backoff acquisition, all-lock acquisition required by Cache, and release; it does not own a table, cache columns, limiter state, or arbitrary multi-key transactions. Keep both packages' table managers and state formats independent. -- Move Cache's existing injectable `SwooleTimer` wrapper to `Hypervel\Core\Swoole\Timer` and use that same two-method `tick()`/`clear()` test seam from Cache and RateLimiter lifecycle listeners. Do not create a package-local duplicate or expand it into a scheduler abstraction. +- Use the existing `Hypervel\Coordinator\Timer` from `Listeners\RegisterPruneTimer`, with its default `WORKER_EXIT` coordinator. It already provides injectable repeating timers, exception reporting, cancellation, and automatic worker-exit cleanup; do not add another timer wrapper, timer-ID registry, or `OnWorkerExit` listener. Register only on worker 0 and never in task workers. `prune_interval` is seconds and is passed directly to `Timer::tick()`. - Perform read/check/write within one row lock. No serialization, closures, cache repository, or generic eviction policy appears in the hot path. -- Use `intdiv(hrtime(true), 1000)` for a host-monotonic microsecond clock shared by workers. -- Expired rows are reclaimed on access. Worker 0 owns a periodic expiry scan timer; stop it on worker exit. Timer/full-table pruning must lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. -- If insertion fails, log one warning for the synchronous full-table prune attempt, perform one expired-row prune, and retry once. Inject `Psr\Log\LoggerInterface`; no logging lookup belongs in the hot path. If the table remains full of live rows, throw `SwooleTableFullException`. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. +- Use epoch microseconds for worker-array and Swoole: `(int) (microtime(true) * 1_000_000)` normally and `(int) CarbonImmutable::now()->getPreciseTimestamp(6)` under `CarbonImmutable::hasTestNow()`. Both branches must use the same origin and unit so switching test time after creating state cannot manufacture an expiry. This also aligns local state with Redis `TIME` and database wall clocks; accepting wall-clock adjustment behavior is preferable to a separate monotonic-offset test abstraction that the distributed stores could not share. +- Expired rows are reclaimed on access. Worker 0 owns the coordinator-backed periodic expiry scan. Timer/full-table pruning must lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. +- After each periodic prune, use `Swoole\Table::stats()` to calculate the same O(1) conflict/fill pressure signal as Cache: warn through injected `Psr\Log\LoggerInterface` when either ratio exceeds `1 - memory_limit_buffer`. This signals exhausted headroom off the request path while avoiding warnings for pressure relieved by expired-row pruning. +- If insertion fails, perform one synchronous expired-row prune and retry once. If the table remains full of live rows, throw `SwooleTableFullException`; normal exception reporting supplies the hard-failure signal. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. - Document that Swoole is host-local and is not a distributed rate limiter across servers. -- Document sizing as `rows >= peak concurrently live physical keys × headroom`, where a key remains live for its window/refill/inactivity TTL. Include examples for per-IP cardinality and explain that the warning indicates exhausted headroom before a live-only table begins failing closed. +- Document sizing as `rows >= peak concurrently live physical keys × headroom`, where a key remains live for its window/refill/inactivity TTL. Include examples for per-IP cardinality and explain that periodic pressure warnings indicate exhausted headroom before a live-only table begins failing closed. ### Database store @@ -637,14 +654,13 @@ The prune command resolves `$manager->store($name)->getStore()` and rejects stor The database driver is correctness-first and will require several SQL statements in a transaction; documentation must not present it as equivalent to Redis throughput. -### Array store +### Worker-array store -- Use an in-process numeric state array and a monotonic clock. -- The name follows Laravel manager conventions, but its scope must be explicit in docs: it is shared for the lifetime of one Hypervel worker, not coroutine-local and not shared across workers. +- Use an in-process numeric state array and the same epoch-microsecond clock/test seam as Swoole. +- Use the `worker-array` name established by Hypervel Cache for worker-lifetime state. It is not coroutine-local and not shared across workers; do not call it `array`, which Hypervel documentation reserves for request-local scratch state. - It is suitable for tests and deliberately local workloads such as Reverb per-connection message limits, because a connection remains owned by one worker and Reverb clears its key on close. - Operations contain no suspension point, so a transition is atomic within one cooperative worker; it does not coordinate processes or hosts. - Lazily discard an expired entry whenever its key is touched. Do not add an abandoned-key scheduler/expiry index in the initial store or perform an unbounded whole-array sweep in a request hot path; rely on explicit `clear()`, Reverb close cleanup, and worker recycling for this deliberately local/test store. -- Do not call this store `worker-array`; that name belongs to cache semantics. ## Framework consumer refactor @@ -658,7 +674,7 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ - implement `after()` for Redis-backed policies with non-mutating inspection followed by a conditional atomic consume. - Inline `throttle:60,1` creates a fixed `Limit` and consumes it once. -- Preserve the public `ThrottleRequests::using()` and `ThrottleRequests::with()` helpers, middleware pipe syntax, named-limiter lookup and `MissingRateLimiterException` behavior. These are ergonomic Laravel routing APIs, not cache-counter compatibility methods. +- Preserve the public `ThrottleRequests::using()` and `ThrottleRequests::with()` helpers, middleware pipe syntax, named-limiter lookup and `MissingRateLimiterException` behavior. Keep `resolveMaxAttempts()` semantics intact: pipe values such as `60|120` select guest/authenticated limits, and a nonnumeric value may resolve from the authenticated user's named attribute before the existing missing-limiter exception is chosen. These are ergonomic Laravel routing APIs, not cache-counter compatibility methods. - Named callbacks retain `Response`, `Unlimited`, one policy, or an ordered array of policies. - Resolve the named limiter's registered store once for the request, or use the default store when none was registered. For a normal named policy, call that store's `consume($policy, $limiterName)` once and retain the local result for exception/header generation. Inline policies use the default store and omit the name. - For a named `after()` policy, call the selected store's `inspect($policy, $limiterName)` before the downstream handler; after the response, call `consume($policy, $limiterName)` only when the predicate returns true. A concurrent post-response consume may be denied after the response has already been admitted; return headers from that result but do not retroactively throw. Document/test this inherent response-dependent semantic. @@ -685,7 +701,7 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ - `inspect()` before running the job; `consume()` only when a qualifying exception occurs; `clear()` after success. - Add the same `store()` selector and remove `ThrottlesExceptionsWithRedis`; this middleware constructs its policy directly, so it uses the default store unless explicitly overridden. - Persist only the selected store name with the middleware/job; resolve the manager/wrapper inside `handle()` and never serialize a resolved backend store or Redis proxy. -- Keep its existing `backoff()` method for the ordinary queue retry delay; do not conflate that delay with the package's server-enforced `ExponentialBackoff` policy. +- Keep its existing `backoff()` method for the ordinary queue retry delay; do not conflate that delay with the package's server-enforced exponential `Backoff` policy. - Preserve the Laravel-style optional second callback argument, but always pass the selected package `Limiter` wrapper to `when()` and `report()` callbacks. Redis and non-Redis paths must no longer expose different concrete/cache limiter objects. - Stop pre-hashing the job class inside `getKey()` because the canonical limiter hashes the complete identity. Replace the misleading Laravel-interoperability prefix/comment—the new state format is intentionally not cache-compatible—with `hypervel:queue:throttles-exceptions:` while retaining `withPrefix()` for callers that choose another namespace. @@ -710,9 +726,11 @@ return ! $this->container->make(RateLimiter::class)->attempt( Keep Lottery and Unlimited handling. Remove primitive key/max/decay calls and the handler's redundant pre-hashing/property; the dedicated limiter hashes every canonical identity. +Change `Handler::throttle()` from `Lottery|Limit|null` to `Lottery|AdmissionPolicy|null`. `Limit::none()` returns the sibling `Unlimited`, so retaining the old concrete return type would throw on the default path; the broader admission type also permits leaky policies. An application override returning `Lottery|Limit|null` remains a valid covariant narrowing. + ### Reverb -Inject/resolve the new manager and use `store('array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. +Inject/resolve the new manager and use `store('worker-array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. ### Facade and provider @@ -728,13 +746,22 @@ Add/update all of the following: - root `composer.json` PSR-4 mapping for `Hypervel\RateLimiter\`; - root `replace` entry for `hypervel/rate-limiter`; - `src/rate-limiter/composer.json`, auto-discovered provider, authors/support/branch alias, sorted requirements; -- exact direct requirements for `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, core events/primitives, database, Redis, support, `psr/log`, and `symfony/console`, pruning anything implementation does not actually import; PHP's mandatory Hash extension needs no Composer requirement; +- exact direct requirements for `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, coordinator, core events/primitives, database, Redis, support, `psr/log`, and `symfony/console`, pruning anything implementation does not actually import; PHP's mandatory Hash extension needs no Composer requirement; - `hypervel/rate-limiter` dependencies in routing, queue, Fortify, foundation, and Reverb package manifests; - remove `hypervel/cache` from packages where the limiter was its only cache use; retain unrelated cache/Redis dependencies after checking all imports; - facade API documentation metadata; - `Hypervel\RateLimiter` package entry in any package inventories/documentation lists. -Move only the generic striped-lock and timer behavior described above into Core and update Cache imports/tests in the same change. Do not move Cache's table manager, string-value validation, eviction state, or exceptions that the numeric limiter table does not use. +Move only the generic striped-lock behavior described above into Core and update Cache imports/tests in the same change. Also make Coordinator's existing `Timer` the one Swoole maintenance mechanism across both touched packages: + +- add `hypervel/coordinator` as Cache's direct dependency; +- replace Cache's `CreateSwooleTimers` with the accurately named `RegisterSwooleMaintenanceTimers`, inject `Coordinator\Timer`, and register its eviction/interval-refresh callbacks with the default `WORKER_EXIT` coordinator; +- retain Cache's established millisecond config values and documentation, read each named store's complete interval values through typed config getters without duplicating inline defaults, require both integers to be positive, and divide by `1000` once during worker-start registration before calling the seconds-based `Timer::tick()`; this preserves subsecond configuration and avoids silently reinterpreting existing/skeleton values, while RateLimiter's independently documented `prune_interval` remains seconds; +- remove the old `=== false` registration guards and their `RuntimeException` messages because `Coordinator\Timer::tick(): int` either returns an ID or throws; retain thrown-registration rollback with only a method-local list of returned IDs, then discard it; +- delete the listener's persistent ID registry and `stop()` method because coordinator shutdown owns cleanup; +- delete `Hypervel\Cache\SwooleTimer` and the Cache provider's inline `OnWorkerExit` closure, including its nested exception-handler/stderr reporting fallback, then update listener/provider/recycle tests to drive the worker-exit coordinator. + +Do not change Coordinator `Timer` itself or move Cache's table manager, string-value validation, eviction state, or exceptions that the numeric limiter table does not use. This is deletion and convergence on an established primitive, not a new timer abstraction. Do not add a reverse `hypervel/rate-limiter` dependency to `hypervel/support`. Support is the lower-level package used by the new manager and service provider; its facade and default-provider references follow the repository's existing optional facade/provider bridge convention. The always-installed framework metapackage provides both packages, while an independently installed rate-limiter package already requires Support in the correct direction. @@ -751,7 +778,7 @@ Coordinate the two adjacent official repositories in the same release: Keep provider auto-discovery metadata in the split package so it works when independently required, matching other core components, but also assert `RateLimiterServiceProvider`'s presence in `DefaultProviders`. Discovery is not the framework's availability mechanism; its alphabetical placement is a code-style requirement, not runtime behavior that needs a brittle order test. -Within components, add `src/testbench/hypervel/config/rate-limiter.php` and `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, and rollback/refresh coverage. Testbench must model a fresh skeleton accurately; limiter tests must not create the default table ad hoc. +Within components, add `src/testbench/hypervel/config/rate-limiter.php` with `worker-array` as its test default and `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, and rollback/refresh coverage. The migration lets database-store tests opt in without pushing unrelated container-resolved limiter tests through SQLite; limiter tests must not create the standard table ad hoc. ## Removal and cleanup inventory @@ -762,6 +789,8 @@ Delete after consumers compile against the new package: - `src/cache/src/RateLimiting/GlobalLimit.php`; - `src/cache/src/RateLimiting/Unlimited.php`; - cache provider limiter binding; +- `src/cache/src/SwooleTimer.php`, Cache's explicit timer-ID/`stop()` lifecycle, and the provider's inline `OnWorkerExit` closure/reporting fallback after `RegisterSwooleMaintenanceTimers` uses Coordinator cleanup; +- obsolete support-facade annotations for primitive limiter methods, `cleanRateLimiterKey()`, and the Hypervel-specific `resolveNamedLimiterKey()` helper; - `cache.limiter` config and its documentation block from `src/foundation/config/cache.php`; - `src/routing/src/Middleware/ThrottleRequestsWithRedis.php`; - `src/queue/src/Middleware/RateLimitedWithRedis.php`; @@ -783,6 +812,8 @@ At the end, repository-wide searches (excluding archived code, third-party examp Add `tests/Integration/RateLimiter` explicitly to both the Redis 8 and Valkey 9 command lists in `.github/workflows/redis.yml`; that workflow enumerates integration directories and will not discover the new suite automatically. The database workflow already discovers driver directories and needs no equivalent path edit. +Correct the `redis.yml` row in root `AGENTS.md` while touching both files: list the workflow's actual `tests/Integration/Auth`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/RateLimiter`, and `tests/Integration/Redis` directories, removing the nonexistent `tests/Redis/Integration` path. + ## Documentation work Update every applicable Boost document, not just the main rate-limiting page: @@ -800,7 +831,17 @@ Update every applicable Boost document, not just the main rate-limiting page: Add a concise explicit divergence to root `AGENTS.md`: Laravel locates its cache-bound limiter under `Illuminate\Cache`; Hypervel's canonical implementation is `hypervel/rate-limiter` / `Hypervel\RateLimiter`, uses typed policies and dedicated stores, and has no Cache namespace alias. This is the instruction LLMs should see when porting. -For intentionally omitted or deliberately changed Laravel behavior, follow the repository's three-place rule: concise package README differences, concise comments at the natural source insertion points, and `REMOVED:` markers at matching upstream test locations. Cover the `Hypervel\Cache` location, primitive counter methods, Redis-specific middleware classes/switch, `GlobalLimit`, hash opt-out, atomic `attempt()` consuming before the callback and retaining the charge on callback failure, sequential stacked-policy consumption that retains earlier charges when a later policy denies, and truthful non-zero remaining capacity on a weighted denial. Explain the replacement behavior and cover these semantics in Boost docs/tests. Do not add entries to `docs/ai/differences-vs-laravel.md`, which is queued for deletion. +For intentionally omitted or deliberately changed Laravel behavior, follow the repository's three-place rule: concise package README differences, concise comments at the natural source insertion points, and `REMOVED:` markers at matching upstream test locations. Cover: + +- the `Hypervel\Cache` location and primitive counter/key APIs, including `fallbackKey()` and `cleanRateLimiterKey()`; +- Redis-specific middleware classes/switches and the removed `redis:` argument on `Middleware::throttleApi()`; +- `GlobalLimit`, `ThrottleRequests::shouldHashKeys()`, and Foundation Handler's protected `$hashThrottleKeys` extension point, because canonical hashing is mandatory; +- atomic `attempt()` consuming before the callback and retaining the charge on callback failure; +- sequential stacked-policy consumption retaining earlier charges when a later policy denies; +- truthful non-zero remaining capacity on a weighted denial; and +- parameter-sensitive identity causing configuration changes to start new state and requiring the same policy parameters for `clear()`. + +Explain the replacement behavior and cover these semantics in Boost docs/tests. `resolveNamedLimiterKey()` is a Hypervel 0.4 implementation helper rather than a Laravel API; remove it and its facade annotation in the cleanup sweep, but do not mislabel it as a Difference From Laravel. Do not add entries to `docs/ai/differences-vs-laravel.md`, which is queued for deletion. While updating root `AGENTS.md` with the rate-limiter divergence, remove its stale instructions to maintain `docs/ai/differences-vs-laravel.md`; that document's own header already marks it for deletion. Do not leave contradictory agent guidance in the touched file. @@ -816,36 +857,39 @@ Create `tests/RateLimiter` and use the repository-required base test/coroutine c - Invalid zero/negative capacity, rate, duration, burst, cost, and backoff settings throw named exceptions. - Numeric boundary tests cover the shared Lua-exact/signed-64 limits and every overflow-prone multiplication/addition before a store mutation. - Fluent methods return new copies and do not mutate the original policy. +- Concrete copy hooks preserve readonly shared/algorithm fields without reflection or post-clone writes, and cross-field validation makes `cost()`/`burst()` fluent order irrelevant. - `globally`, scope, callbacks, cost, and response callbacks are retained correctly. - Policy fingerprints are stable for a limiter prefix, change when the prefix or policy parameters change, distinguish policy types, and exclude cost/callbacks. - Arbitrary key segments cannot create ambiguous preimages before hashing. - Unlimited performs no store operation. - `LimitResult` and `BackoffResult` round timing up correctly and never expose negative remaining/retry values. -- Manager default/named/`UnitEnum` store resolution, named-limiter registered stores, explicit queue overrides, one-instance caching, purge/forget behavior, typed configuration failures, and a custom `extend()` callback returning `Contracts\Store` all produce the expected wrapped `Limiter` without a second cache. +- Manager default/named/`UnitEnum` store resolution, named-limiter registered stores, explicit queue overrides, one-instance caching, purge/forget behavior, typed configuration failures, and a custom `extend()` callback returning `Contracts\Store` all produce the expected wrapped `Limiter` without a second cache. Registering the scope resolver after a store was resolved still affects subsequent named operations. - Named limiter identity differs by limiter name, scope, global flag, normalized key value, policy type, and stable parameters exactly as specified; equivalent scalar/stringable/enum key values normalize identically, and direct policies do not accidentally invoke the named scope resolver. -- The shared typed PHP calculator produces the same transitions used by array, Swoole, and database stores without descriptor arrays or floating-point state. +- The shared typed PHP calculator produces the same transitions used by worker-array, Swoole, and database stores without descriptor arrays or floating-point state. ### Shared store contract suite -Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, and the existing CI services for Redis 8 and Valkey 9. Do not add redundant Redis point-release jobs for a portable Lua path with no version branch, and do not claim a supported first-party store/server combination from mocks alone. +Run one behavioral contract against worker-array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, and the existing CI services for Redis 8 and Valkey 9. Do not add redundant Redis point-release jobs for a portable Lua path with no version branch, and do not claim a supported first-party store/server combination from mocks alone. - first consume, exact-capacity consume, weighted consume, over-capacity denial; - denied consume does not mutate count or extend TTL; -- inspection does not create/mutate state; -- clear and expiration reset; +- inspection does not create/mutate state, and absent fixed-window inspection returns full remaining capacity with zero retry/reset; +- clear with matching parameters, changed-parameter isolation, and expiration reset; - fixed-window boundary immediately before/after reset; - leaky-bucket initial burst, smooth recovery, weighted retry, full refill, denial immutability; - exponential threshold, doubling, cap, inactivity reset, success clear; - same physical semantics for every store to the precision promised by public seconds; - corrupted/wrong-type backend state fails explicitly rather than allowing work. +Time control is store-appropriate rather than abstracted into a production clock service. Worker-array and Swoole use epoch microseconds in production and honor `CarbonImmutable::hasTestNow()` on the same scale for exact semantic tests. Tests must create state before setting/travelling test time as well as while test time is already active, proving the seam cannot change the clock origin. Redis and database continue using authoritative backend time; their integration boundary/expiry cases use the shortest valid intervals with bounded polling and a hard deadline, while the shared calculator suite covers exact before/after arithmetic without sleeping. + ### Concurrency tests -- Array: multiple coroutines in one worker admit exactly capacity. +- Worker-array: multiple coroutines in one worker admit exactly capacity. - Swoole: multiple coroutines and forked workers admit exactly capacity and do not lose updates. - Database: concurrent transactions against an absent key and an existing key admit exactly capacity; include SQLite writer serialization plus MySQL/PostgreSQL row locks in integration CI. - Redis: many concurrent pooled clients admit exactly capacity for fixed and leaky bucket; test weighted costs. -- Redis Cluster: one-key scripts route without CROSSSLOT and work with configured prefixes. +- Structural Redis tests assert every limiter script is invoked with exactly one key; configured-prefix integration coverage verifies the key path. Do not add a Redis Cluster service merely to test an impossible CROSSSLOT case for one-key scripts. - No driver allows stored state above capacity. ### Redis-specific tests @@ -855,20 +899,22 @@ Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, Postg - Serializer/compression configuration does not affect limiter state. - Redis connection `OPT_PREFIX` is applied once. - `TIME`-based leaky/backoff calculations ignore application-clock skew. -- TTL is applied atomically and is unchanged on denial. +- TTL is applied atomically, is unchanged on denial, and an accepted existing fixed-window increment uses portable `INCRBY` without changing the original TTL. +- A stored leading-zero counter such as `010` is rejected by the package's explicit corruption branch before `INCRBY`, while zero and canonical non-zero decimal counters remain valid. +- The validated `9_007_199_254_740_991` ceiling survives fixed-window `SET`/`INCRBY` command arguments and computed Redis state without scientific-notation truncation; do not assert against Lua `tostring()`, which is not the command bridge. - Redis 8 and Valkey 9 run the exact same Lua implementation. - Add a focused assertion/test fixture guarding the required `@TODO`/portable path only if repository conventions permit source-shape tests; otherwise the docs TODO and code comment are sufficient. ### Swoole-specific tests - Core `StripedLock` preserves Cache's row/all-lock behavior and timeout coverage after extraction; RateLimiter creates the same lock primitive before fork. -- Core `Timer` preserves Cache's injectable timer lifecycle coverage and is reused by RateLimiter without a package-local wrapper. +- `RegisterPruneTimer` uses Coordinator `Timer` to register the prune callback only for worker 0/non-task workers and stops it through the existing `WORKER_EXIT` coordinator without package timer IDs or exit listeners. +- Cache's renamed maintenance listener reads complete positive millisecond intervals through typed config getters, converts them to seconds at registration, removes unreachable native-false guards, rolls back earlier registrations if a later registration throws, and relies on the same worker-exit coordinator in its recycle test. Missing, wrong-type, zero, and negative intervals fail before timer registration. Cache has no duplicated listener defaults, native Swoole timer wrapper, persistent timer-ID registry, `stop()` path, or provider-owned `OnWorkerExit` closure afterward. - Table columns are 8-byte integers and table creation occurs before fork. +- `TableManager` allows explicit creation before sealing, is sealed by `InitializeSwooleTables` before fork, and rejects unknown tables afterward. - Same-key locks isolate transitions; different stripes can proceed independently. - Expired rows are pruned by timer and on access. -- Full table logs the pressure warning, retries after pruning once, and then throws without evicting live state. -- Timer is registered only by worker 0 and cleaned on exit/recycle. -- Repeated worker lifecycle hooks do not register duplicate prune timers or retain stale timer IDs. +- Periodic pruning logs pressure only when post-prune conflict/fill ratios cross the configured buffer; full insertion retries after one synchronous prune and then throws without evicting live state. - Store state never serializes a PHP value. ### Database-specific tests @@ -888,7 +934,7 @@ Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, Postg ### Framework integration tests - Foundation loads the rate-limiter defaults, application stores merge by name while a same-named store replaces its whole definition, and the provider does not perform a second merge. -- Application/Testbench config and environment defaults select the database store without requiring Redis. +- Foundation/application defaults select database without requiring Redis; Testbench defaults to worker-array while its standard migration supports explicit database-store tests. - Routing inline and named fixed limits. - `ThrottleRequests::using()`, `ThrottleRequests::with()`, middleware pipe syntax, missing named limiters, and custom responses retain their Laravel-facing behavior. - Named leaky-bucket routing, registered/default store selection, weighted costs, custom response, global/scope behavior, and multiple policy ordering. @@ -899,7 +945,8 @@ Run one behavioral contract against array, Swoole, SQLite, MySQL, MariaDB, Postg - `ThrottlesExceptions` consumes only qualifying failures and clears on success. - Fortify fixed lockout and clearing. - Foundation exception report throttling. -- Reverb per-connection isolation and close cleanup with array store. +- Foundation's default `Limit::none()` throttle path satisfies the widened `Lottery|AdmissionPolicy|null` return type. +- Reverb per-connection isolation and close cleanup with worker-array store. - Facade resolves the canonical manager. - An existing provider/application integration test asserts that `DefaultProviders` contains `RateLimiterServiceProvider`, independently of package discovery; do not create composer-manifest tests or assert alphabetical order as runtime behavior. - Middleware configuration contains only `ThrottleRequests` and has no Redis switch. @@ -934,7 +981,7 @@ Acceptance invariants: - Redis steady-state admission is one network round trip, one pool checkout, and one script invocation. - No Redis cache serialization/compression path is entered. -- Swoole's ordinary admission path performs no serialization and no I/O; only the exceptional full-table path logs capacity pressure. +- Swoole's ordinary admission path performs no serialization and no I/O; periodic maintenance may log capacity pressure. - Middleware performs no post-consume state lookup for ordinary limits. - Material throughput/latency regressions against the old baseline or between supported Redis and Valkey services are explained before merge; optimize the portable script rather than adding a premature version branch. @@ -946,9 +993,9 @@ This order keeps the tree buildable while still delivering one final cut with no 1. Add package metadata/provider skeleton, Foundation/application/Testbench config, root autoload/replace entry, and default provider registration. 2. Add immutable policies, fingerprints/key resolver, decisions, contracts, manager, shared typed PHP calculator, and per-store `Limiter` wrapper with unit tests. -3. Implement array store with the shared calculator and run the full store contract against it. +3. Implement worker-array store with the shared calculator and run the full store contract against it. 4. Implement Redis Lua transitions using `evalWithShaCache()`, including the required focused `@TODO`; run the existing Redis 8/Valkey 9 integration jobs and concurrency tests after adding their explicit RateLimiter path. -5. Extract the generic striped lock and existing cache timer seam into Core, update Cache, then implement the independent numeric Swoole table/state/timer/pruning and multi-worker tests. +5. Extract the generic striped lock into Core and update Cache; converge Cache's Swoole maintenance timers on Coordinator `Timer`; then implement the independent numeric Swoole table/state plus Coordinator-backed pruning listener and multi-worker tests. 6. Implement database store with the shared calculator, migration/prune commands, server clocks, default migrations, and database integration/concurrency tests. 7. Rewrite routing and Foundation middleware configuration; delete the Redis-specific request middleware/switch once tests pass. 8. Rewrite queue middleware and remove the two Redis-specific queue classes. @@ -966,9 +1013,9 @@ No step should add a temporary alias or dual API. If intermediate local compilat - [ ] `Hypervel\RateLimiter` is the sole namespace; its facade and unconditional default provider resolve the new manager with no Cache shim or dual API. - [ ] Fixed, GCRA/leaky-bucket, unlimited, and exponential-backoff policies are typed; no strategy/driver enum, descriptor bag, or speculative algorithm exists. -- [ ] Redis/Swoole/database/array pass the shared semantic and concurrency suites; failures never fail open and no driver routes through generic cache serialization. +- [ ] Redis/Swoole/database/worker-array pass the shared semantic and concurrency suites; failures never fail open and no driver routes through generic cache serialization. - [ ] Redis admission is one cached Lua call on the existing Redis 8 and Valkey 9 services; Swoole uses shared numeric state without live eviction and documents/logs capacity pressure; database uses only `rate_limits`. -- [ ] Foundation, the application skeleton, and Testbench carry matching config/default migrations; named stores merge without a duplicate package config. +- [ ] Foundation, the application skeleton, and Testbench carry the same stores/migration, with database as the application default and worker-array as the deliberate Testbench default; named stores merge without duplicate package config. - [ ] Routing retains its Laravel-facing helpers, syntax, callbacks, exceptions, headers, and registered-store selection with one middleware; queue and Reverb have no Redis/cache limiter branches. - [ ] The framework metapackage, package dependencies, facade metadata, Boost's single `rate-limiting.md`, minimal README, AGENTS guidance, and required source/test difference markers agree. - [ ] Old namespaces, classes, config, tests, docs, switches, stale state, and obsolete TODOs are absent; the INCREX and capability TODOs remain accurate. From 9e325b5057d6b5e6ff4d504602bd8df3b7aaf756 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:12:01 +0000 Subject: [PATCH 04/41] Add shared Swoole striped lock primitive Extract the 64-stripe lock coordinator into Core so Cache, Reverb, and the rate limiter share one pre-fork-safe primitive. Cover deterministic key striping, ascending multi-lock acquisition, shared-stripe deduplication, bounded contention, rollback, and reverse-order release. --- src/core/src/Swoole/StripedLock.php | 176 ++++++++++++++++++++ tests/Core/Swoole/StripedLockTest.php | 227 ++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/core/src/Swoole/StripedLock.php create mode 100644 tests/Core/Swoole/StripedLockTest.php diff --git a/src/core/src/Swoole/StripedLock.php b/src/core/src/Swoole/StripedLock.php new file mode 100644 index 000000000..854fd1169 --- /dev/null +++ b/src/core/src/Swoole/StripedLock.php @@ -0,0 +1,176 @@ + + */ + protected array $locks; + + /** + * Create a new striped lock. + */ + public function __construct() + { + $this->locks = array_map( + static fn (): Atomic => new Atomic(0), + range(0, static::STRIPE_COUNT - 1), + ); + } + + /** + * Run the callback while holding the stripe for the given key. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withLock(string $key, callable $callback): mixed + { + $lock = $this->lockFor($key); + $this->acquire($lock); + + try { + return $callback(); + } finally { + $this->release($lock); + } + } + + /** + * Run the callback while holding the stripes for the given keys. + * + * @template T + * @param list $keys + * @param callable(): T $callback + * @return T + */ + public function withLocks(array $keys, callable $callback): mixed + { + $stripeIndexes = []; + + foreach ($keys as $key) { + $stripeIndexes[$this->lockIndexFor($key)] = true; + } + + $stripeIndexes = array_keys($stripeIndexes); + sort($stripeIndexes, SORT_NUMERIC); + + $locks = []; + + foreach ($stripeIndexes as $stripeIndex) { + $locks[] = $this->locks[$stripeIndex]; + } + + return $this->withSelectedLocks($locks, $callback); + } + + /** + * Run the callback while holding every stripe. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withAllLocks(callable $callback): mixed + { + return $this->withSelectedLocks($this->locks, $callback); + } + + /** + * Run the callback while holding the selected stripes in their supplied order. + * + * @template T + * @param list $locks + * @param callable(): T $callback + * @return T + */ + protected function withSelectedLocks(array $locks, callable $callback): mixed + { + $acquired = []; + + try { + foreach ($locks as $lock) { + $this->acquire($lock); + $acquired[] = $lock; + } + + return $callback(); + } finally { + while (($lock = array_pop($acquired)) !== null) { + $this->release($lock); + } + } + } + + /** + * Get the stripe for a key. + */ + protected function lockFor(string $key): Atomic + { + return $this->locks[$this->lockIndexFor($key)]; + } + + /** + * Get the stripe index for a key. + */ + protected function lockIndexFor(string $key): int + { + return crc32($key) % static::STRIPE_COUNT; + } + + /** + * Acquire a stripe. + */ + protected function acquire(Atomic $lock): void + { + $deadline = null; + $spins = 0; + + while (! $lock->cmpset(0, 1)) { + $deadline ??= hrtime(true) + static::ACQUIRE_TIMEOUT_NANOSECONDS; + + if (++$spins < static::SPINS_BEFORE_BACKOFF) { + continue; + } + + if (hrtime(true) >= $deadline) { + throw new RuntimeException('Timed out acquiring a Swoole striped lock.'); + } + + $spins = 0; + usleep(1); + } + } + + /** + * Release a stripe. + */ + protected function release(Atomic $lock): void + { + $lock->cmpset(1, 0); + } +} diff --git a/tests/Core/Swoole/StripedLockTest.php b/tests/Core/Swoole/StripedLockTest.php new file mode 100644 index 000000000..04648e12c --- /dev/null +++ b/tests/Core/Swoole/StripedLockTest.php @@ -0,0 +1,227 @@ +hold('key'); + $called = false; + + run(function () use ($locks, &$called): void { + go(function () use ($locks): void { + usleep(5_000); + $locks->releaseHeld('key'); + }); + + $locks->withLock('key', function () use (&$called): void { + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testLockFailureIsBoundedAndDescriptive(): void + { + $locks = new TestStripedLock; + $locks->hold('key'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Timed out acquiring a Swoole striped lock.'); + + $locks->withLock('key', static fn (): bool => true); + } + + public function testDifferentStripesProceedIndependently(): void + { + $locks = new TestStripedLock; + $locks->hold('first'); + + $this->assertNotSame($locks->stripe('first'), $locks->stripe('second')); + $this->assertTrue($locks->withLock('second', static fn (): bool => true)); + } + + public function testSelectedLocksDeduplicateAndAcquireStripesInAscendingOrder(): void + { + $locks = new RecordingSelectedStripedLock; + $lowKey = $locks->keyForStripe(7); + $highKey = $locks->keyForStripe(51); + + $result = $locks->withLocks( + [$highKey, $lowKey, $highKey], + static fn (): string => 'completed', + ); + + $this->assertSame('completed', $result); + $this->assertSame([7, 51], $locks->acquiredStripes); + $this->assertSame([51, 7], $locks->releasedStripes); + } + + public function testSelectedLockFailureReleasesEarlierAcquisitions(): void + { + $locks = new FailingSelectedStripedLock; + + try { + $locks->withLocks([ + $locks->keyForStripe(2), + $locks->keyForStripe(19), + $locks->keyForStripe(37), + ], static fn (): bool => true); + $this->fail('The third selected stripe acquisition should fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Synthetic selected stripe acquisition failure.', $exception->getMessage()); + } + + $this->assertTrue($locks->firstAcquiredStripesAreReleased()); + } + + public function testAllLockFailureReleasesEarlierAcquisitions(): void + { + $locks = new FailingAllStripedLock; + + try { + $locks->withAllLocks(static fn (): bool => true); + $this->fail('The third stripe acquisition should fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Synthetic stripe acquisition failure.', $exception->getMessage()); + } + + $this->assertTrue($locks->firstAcquiredStripesAreReleased()); + } +} + +class TestStripedLock extends StripedLock +{ + protected const int ACQUIRE_TIMEOUT_NANOSECONDS = 50_000_000; + + public function hold(string $key): void + { + $this->lockFor($key)->set(1); + } + + public function releaseHeld(string $key): void + { + $this->lockFor($key)->set(0); + } + + public function stripe(string $key): int + { + return $this->lockIndexFor($key); + } + + public function keyForStripe(int $stripe): string + { + for ($index = 0; $index < 10_000; ++$index) { + $key = "stripe-key-{$index}"; + + if ($this->lockIndexFor($key) === $stripe) { + return $key; + } + } + + throw new RuntimeException("Unable to find a key for stripe [{$stripe}]."); + } +} + +class RecordingSelectedStripedLock extends TestStripedLock +{ + /** @var list */ + public array $acquiredStripes = []; + + /** @var list */ + public array $releasedStripes = []; + + protected function acquire(Atomic $lock): void + { + $this->acquiredStripes[] = $this->stripeFor($lock); + parent::acquire($lock); + } + + protected function release(Atomic $lock): void + { + $this->releasedStripes[] = $this->stripeFor($lock); + parent::release($lock); + } + + protected function stripeFor(Atomic $lock): int + { + $stripe = array_search($lock, $this->locks, true); + + if ($stripe === false) { + throw new RuntimeException('The Atomic does not belong to this striped lock.'); + } + + return $stripe; + } +} + +class FailingSelectedStripedLock extends TestStripedLock +{ + private int $acquisitions = 0; + + /** @var list */ + private array $acquiredStripes = []; + + protected function acquire(Atomic $lock): void + { + if (++$this->acquisitions === 3) { + throw new RuntimeException('Synthetic selected stripe acquisition failure.'); + } + + parent::acquire($lock); + + $stripe = array_search($lock, $this->locks, true); + + if ($stripe === false) { + throw new RuntimeException('The Atomic does not belong to this striped lock.'); + } + + $this->acquiredStripes[] = $stripe; + } + + public function firstAcquiredStripesAreReleased(): bool + { + foreach ($this->acquiredStripes as $stripe) { + if ($this->locks[$stripe]->get() !== 0) { + return false; + } + } + + return true; + } +} + +class FailingAllStripedLock extends StripedLock +{ + private int $acquisitions = 0; + + protected function acquire(Atomic $lock): void + { + if (++$this->acquisitions === 3) { + throw new RuntimeException('Synthetic stripe acquisition failure.'); + } + + parent::acquire($lock); + } + + public function firstAcquiredStripesAreReleased(): bool + { + return $this->locks[0]->get() === 0 + && $this->locks[1]->get() === 0; + } +} From aa99fb910b44ebd87170e299fae77e430a6ece09 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:12:18 +0000 Subject: [PATCH 05/41] Use shared striped locks for Swoole cache state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Cache’s package-local Atomic lock implementation with the shared Core primitive while preserving row and all-lock behavior. Move generic contention, timeout, and rollback coverage to Core and retain Cache’s real cross-process store concurrency coverage. --- src/cache/src/SwooleTableState.php | 88 +------------- .../Cache/CacheSwooleStoreConcurrencyTest.php | 109 ------------------ 2 files changed, 5 insertions(+), 192 deletions(-) diff --git a/src/cache/src/SwooleTableState.php b/src/cache/src/SwooleTableState.php index 3c101e789..698812d52 100644 --- a/src/cache/src/SwooleTableState.php +++ b/src/cache/src/SwooleTableState.php @@ -4,8 +4,7 @@ namespace Hypervel\Cache; -use RuntimeException; -use Swoole\Atomic; +use Hypervel\Core\Swoole\StripedLock; /** * Coordinates multi-step Swoole table row mutations across workers. @@ -21,20 +20,7 @@ */ class SwooleTableState { - protected const int STRIPE_COUNT = 64; - - // Late-bound so deterministic test subclasses can shorten the spin phase. - protected const int SPINS_BEFORE_BACKOFF = 64; - - // Late-bound so deterministic test subclasses can shorten the timeout. - protected const int LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 1_000_000_000; - - /** - * Striped locks for row lifecycle operations. - * - * @var list - */ - protected array $rowLocks; + protected StripedLock $locks; /** * Create a new Swoole table state instance. @@ -44,11 +30,7 @@ public function __construct( protected int $hashSeed = 0, ) { $this->hashSeed = $hashSeed ?: random_int(1, PHP_INT_MAX); - - $this->rowLocks = array_map( - fn () => new Atomic(0), - range(0, self::STRIPE_COUNT - 1), - ); + $this->locks = new StripedLock; } /** @@ -76,14 +58,7 @@ public function hashSeed(): int */ public function withRowLock(string $key, callable $callback): mixed { - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { - return $callback(); - } finally { - $this->release($lock); - } + return $this->locks->withLock($key, $callback); } /** @@ -95,59 +70,6 @@ public function withRowLock(string $key, callable $callback): mixed */ public function withAllRowLocks(callable $callback): mixed { - $acquired = []; - - try { - foreach ($this->rowLocks as $lock) { - $this->acquire($lock); - $acquired[] = $lock; - } - - return $callback(); - } finally { - while ($lock = array_pop($acquired)) { - $this->release($lock); - } - } - } - - /** - * Get the striped lock for a table key. - */ - protected function lockFor(string $key): Atomic - { - return $this->rowLocks[crc32($key) % self::STRIPE_COUNT]; - } - - /** - * Acquire a striped lock. - */ - protected function acquire(Atomic $lock): void - { - $deadline = null; - $spins = 0; - - while (! $lock->cmpset(0, 1)) { - $deadline ??= hrtime(true) + static::LOCK_ACQUIRE_TIMEOUT_NANOSECONDS; - - if (++$spins < static::SPINS_BEFORE_BACKOFF) { - continue; - } - - if (hrtime(true) >= $deadline) { - throw new RuntimeException('Timed out acquiring a Swoole table state lock.'); - } - - $spins = 0; - usleep(1); - } - } - - /** - * Release a striped lock. - */ - protected function release(Atomic $lock): void - { - $lock->cmpset(1, 0); + return $this->locks->withAllLocks($callback); } } diff --git a/tests/Cache/CacheSwooleStoreConcurrencyTest.php b/tests/Cache/CacheSwooleStoreConcurrencyTest.php index 27326f0cc..c92f065ad 100644 --- a/tests/Cache/CacheSwooleStoreConcurrencyTest.php +++ b/tests/Cache/CacheSwooleStoreConcurrencyTest.php @@ -16,9 +16,6 @@ use Swoole\Process; use Throwable; -use function Hypervel\Coroutine\go; -use function Hypervel\Coroutine\run; - class CacheSwooleStoreConcurrencyTest extends TestCase { private const FRAME_HEADER_BYTES = 4; @@ -102,69 +99,6 @@ public function testConcurrentLockAcquireHasExactlyOneWinner(): void $this->assertCount(1, array_filter($results, fn (array $result): bool => $result['won'])); } - public function testContendedStateLockBacksOffAndAcquiresAfterRelease(): void - { - $state = $this->createLockState(); - $state->holdLockFor('key'); - $called = false; - - run(function () use ($state, &$called): void { - go(function () use ($state): void { - usleep(5_000); - $state->releaseLockFor('key'); - }); - - $state->withRowLock('key', function () use (&$called): void { - $called = true; - }); - }); - - $this->assertTrue($called); - } - - public function testStateLockFailureIsBoundedAndDescriptive(): void - { - $state = $this->createLockState(); - - try { - $this->runConcurrentProcesses( - $state, - 1, - function (int $id, SwooleStore $store, LockTestSwooleTableState $state): bool { - $state->holdLockFor('key'); - $state->withRowLock('key', fn (): bool => true); - - return true; - }, - timeout: 0.25, - ); - - $this->fail('The pre-locked stripe should time out.'); - } catch (RuntimeException $exception) { - $this->assertStringContainsString( - 'Timed out acquiring a Swoole table state lock.', - $exception->getMessage(), - ); - } - } - - public function testAllStripeFailureReleasesEarlierAcquisitions(): void - { - $state = new FailingAllStripeSwooleTableState( - $this->createState()->table(), - 12345, - ); - - try { - $state->withAllRowLocks(fn (): bool => true); - $this->fail('The third stripe acquisition should fail.'); - } catch (RuntimeException $exception) { - $this->assertSame('Synthetic stripe acquisition failure.', $exception->getMessage()); - } - - $this->assertTrue($state->firstAcquiredStripesAreReleased()); - } - public function testChildExitBeforeReadyFailsWithinTheHarnessDeadline(): void { $this->expectException(RuntimeException::class); @@ -521,14 +455,6 @@ private function createState(): SwooleTableState ->createState(128, 10240, 0.2, 12345); } - private function createLockState(): LockTestSwooleTableState - { - return new LockTestSwooleTableState( - $this->createState()->table(), - 12345, - ); - } - private function createStore(SwooleTableState $state): SwooleStore { return new SwooleStore($state, 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05); @@ -542,38 +468,3 @@ private function tableKey(SwooleStore $store, string $method, string $key): stri return $reflection->invoke($store, $key); } } - -class LockTestSwooleTableState extends SwooleTableState -{ - protected const int LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 50_000_000; - - public function holdLockFor(string $key): void - { - $this->lockFor($key)->set(1); - } - - public function releaseLockFor(string $key): void - { - $this->lockFor($key)->set(0); - } -} - -class FailingAllStripeSwooleTableState extends SwooleTableState -{ - private int $acquisitions = 0; - - protected function acquire(Atomic $lock): void - { - if (++$this->acquisitions === 3) { - throw new RuntimeException('Synthetic stripe acquisition failure.'); - } - - parent::acquire($lock); - } - - public function firstAcquiredStripesAreReleased(): bool - { - return $this->rowLocks[0]->get() === 0 - && $this->rowLocks[1]->get() === 0; - } -} From b1883506d9b21b3197b5486024d3050d5be57001 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:12:34 +0000 Subject: [PATCH 06/41] Refresh managers after application swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh each manager’s cached configuration repository when tests swap the application container. Cover both MultipleInstanceManager and MailManager so subsequent resolutions read the replacement application without rebuilding existing instances. --- src/mail/src/MailManager.php | 6 +++-- src/support/src/MultipleInstanceManager.php | 7 +++--- .../Fixtures/MultipleInstanceManager.php | 1 + .../Support/MultipleInstanceManagerTest.php | 23 +++++++++++++++++++ tests/Mail/MailManagerTest.php | 18 +++++++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index e79d2e89d..b918cb0f9 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -791,12 +791,14 @@ public function getApplication(): Container /** * Set the application instance used by the manager. * - * Tests only. Swaps the singleton's container reference; per-request use - * races across coroutines and breaks every concurrent mail send. + * Tests only. Swaps the singleton's container and configuration references + * without rebuilding resolved mailers; per-request use races across + * coroutines and breaks every concurrent mail send. */ public function setApplication(Container $app): static { $this->app = $app; + $this->config = $app->make('config'); return $this; } diff --git a/src/support/src/MultipleInstanceManager.php b/src/support/src/MultipleInstanceManager.php index 96461027d..2f2f460a5 100644 --- a/src/support/src/MultipleInstanceManager.php +++ b/src/support/src/MultipleInstanceManager.php @@ -189,15 +189,16 @@ public function extend(string $name, Closure $callback): static /** * Set the application instance used by the manager. * - * Tests only. Swaps the singleton's application reference; per-request use - * races across coroutines and breaks every concurrent resolution through - * this manager. + * Tests only. Swaps the singleton's application and configuration references + * without rebuilding resolved instances; per-request use races across + * coroutines and breaks every concurrent resolution through this manager. * * @return $this */ public function setApplication(Application $app): static { $this->app = $app; + $this->config = $app->make('config'); return $this; } diff --git a/tests/Integration/Support/Fixtures/MultipleInstanceManager.php b/tests/Integration/Support/Fixtures/MultipleInstanceManager.php index d7cebc856..34be88696 100644 --- a/tests/Integration/Support/Fixtures/MultipleInstanceManager.php +++ b/tests/Integration/Support/Fixtures/MultipleInstanceManager.php @@ -74,6 +74,7 @@ public function getInstanceConfig(string $name): array 'custom' => [ 'driver' => 'custom', ], + 'configured' => $this->config->array('instances.configured'), default => [], }; } diff --git a/tests/Integration/Support/MultipleInstanceManagerTest.php b/tests/Integration/Support/MultipleInstanceManagerTest.php index 8892a339e..6824a4318 100644 --- a/tests/Integration/Support/MultipleInstanceManagerTest.php +++ b/tests/Integration/Support/MultipleInstanceManagerTest.php @@ -4,8 +4,11 @@ namespace Hypervel\Tests\Integration\Support; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Integration\Support\Fixtures\MultipleInstanceManager; +use Mockery as m; use RuntimeException; class MultipleInstanceManagerTest extends TestCase @@ -31,6 +34,26 @@ public function testConfigurableInstancesCanBeResolved() $this->assertEquals(spl_object_hash($mysqlInstance), spl_object_hash($duplicateMysqlInstance)); } + public function testSetApplicationRefreshesConfigWithoutRebuildingResolvedInstances(): void + { + $manager = new MultipleInstanceManager($this->app); + $resolved = $manager->instance('foo'); + $config = new Repository([ + 'instances' => [ + 'configured' => [ + 'driver' => 'foo', + 'source' => 'replacement', + ], + ], + ]); + $application = m::mock(Application::class); + $application->shouldReceive('make')->once()->with('config')->andReturn($config); + + $this->assertSame($manager, $manager->setApplication($application)); + $this->assertSame($resolved, $manager->instance('foo')); + $this->assertSame('replacement', $manager->instance('configured')->config['source']); + } + public function testUnresolvableInstancesThrowErrors() { $this->expectException(RuntimeException::class); diff --git a/tests/Mail/MailManagerTest.php b/tests/Mail/MailManagerTest.php index 8c6139c0f..f7b7b1275 100644 --- a/tests/Mail/MailManagerTest.php +++ b/tests/Mail/MailManagerTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Mail; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Log\LogManager; use Hypervel\Mail\Mailable; @@ -36,6 +38,22 @@ protected function setUp(): void $this->app->instance('view', m::mock(ViewFactory::class)); } + public function testSetApplicationRefreshesConfigWithoutRebuildingResolvedMailers(): void + { + $this->app->make('config')->set('mail.mailers.existing', ['transport' => 'array']); + + $manager = new MailManager($this->app); + $resolved = $manager->mailer('existing'); + $application = new Container; + $application->instance('config', new Repository([ + 'mail' => ['default' => 'replacement'], + ])); + + $this->assertSame($manager, $manager->setApplication($application)); + $this->assertSame('replacement', $manager->getDefaultDriver()); + $this->assertSame($resolved, $manager->mailer('existing')); + } + public function testIntegerEnumMailerNamesAreNormalizedWithoutTreatingZeroAsAbsent(): void { $this->app->make('config')->set('mail.mailers.0', ['transport' => 'array']); From 8aebfe5cae44417eaa4be4ff655abb68e7327c28 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:12:48 +0000 Subject: [PATCH 07/41] Reject parallel waits outside coroutines Fail before executing callbacks when Parallel::wait() is called without an active coroutine. Prevent Coroutine::join() from terminating callers without a useful diagnostic and prove misuse produces no callback side effects. --- src/coroutine/src/Parallel.php | 6 ++++ tests/Coroutine/ParallelNonCoroutineTest.php | 32 ++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/Coroutine/ParallelNonCoroutineTest.php diff --git a/src/coroutine/src/Parallel.php b/src/coroutine/src/Parallel.php index 128d83bd3..faa3d156d 100644 --- a/src/coroutine/src/Parallel.php +++ b/src/coroutine/src/Parallel.php @@ -6,6 +6,7 @@ use Hypervel\Coroutine\Exceptions\ParallelExecutionException; use Hypervel\Engine\Channel; +use Hypervel\Engine\Exceptions\RunningInNonCoroutineException; use Throwable; use function sprintf; @@ -62,9 +63,14 @@ public function add(callable $callable, int|string|null $key = null): void * @param bool $throw Whether to throw on errors * @return array The results keyed by callback key * @throws ParallelExecutionException When $throw is true and errors occurred + * @throws RunningInNonCoroutineException When running in non-coroutine context */ public function wait(bool $throw = true): array { + if (! Coroutine::inCoroutine()) { + throw new RunningInNonCoroutineException('Parallel execution requires an active coroutine.'); + } + // Reset per-run state so previous runs cannot leak into this one. Without this, a // failure from an earlier wait() would remain in $throwables and surface through // getThrowables() on subsequent runs, regardless of the current run's outcome. diff --git a/tests/Coroutine/ParallelNonCoroutineTest.php b/tests/Coroutine/ParallelNonCoroutineTest.php new file mode 100644 index 000000000..884dddee5 --- /dev/null +++ b/tests/Coroutine/ParallelNonCoroutineTest.php @@ -0,0 +1,32 @@ +add(function () use (&$callbackExecuted): void { + $callbackExecuted = true; + }); + + try { + $parallel->wait(); + $this->fail('Parallel execution should require an active coroutine.'); + } catch (RunningInNonCoroutineException $exception) { + $this->assertSame('Parallel execution requires an active coroutine.', $exception->getMessage()); + } + + $this->assertFalse($callbackExecuted); + } +} From 3c540f0390e6c5116343a048434b584909009df3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:19 +0000 Subject: [PATCH 08/41] Add first-party rate limiter component Introduce the dedicated hypervel/rate-limiter component with immutable fixed-window, GCRA leaky-bucket, unlimited, and exponential-backoff policies. Add atomic Redis Lua, Swoole numeric-table, database, and worker-array stores; typed decisions; named store management; pruning and table commands; default config and provider wiring; integration workflows; corruption checks; and concurrency coverage. Use native store operations instead of generic cache serialization and return complete one-call admission decisions. --- .github/workflows/redis.yml | 2 + composer.json | 3 + src/foundation/config/rate-limiter.php | 67 +++ .../src/Bootstrap/LoadConfiguration.php | 1 + src/rate-limiter/LICENSE.md | 23 + src/rate-limiter/README.md | 14 + src/rate-limiter/composer.json | 59 +++ src/rate-limiter/src/AdmissionPolicy.php | 163 +++++++ src/rate-limiter/src/Backoff.php | 97 +++++ src/rate-limiter/src/BackoffResult.php | 69 +++ .../src/Concerns/CalculatesRateLimits.php | 394 +++++++++++++++++ src/rate-limiter/src/Console/PruneCommand.php | 48 +++ .../src/Console/RateLimiterTableCommand.php | 45 ++ .../src/Console/stubs/rate-limits.stub | 31 ++ src/rate-limiter/src/Contracts/Decision.php | 23 + .../src/Contracts/PrunableStore.php | 13 + src/rate-limiter/src/Contracts/Store.php | 35 ++ src/rate-limiter/src/DatabaseStore.php | 330 ++++++++++++++ .../Exceptions/InvalidRateLimitException.php | 11 + .../Exceptions/SwooleTableFullException.php | 11 + src/rate-limiter/src/KeyResolver.php | 97 +++++ src/rate-limiter/src/LeakyBucket.php | 131 ++++++ src/rate-limiter/src/Limit.php | 98 +++++ src/rate-limiter/src/LimitResult.php | 99 +++++ src/rate-limiter/src/Limiter.php | 223 ++++++++++ .../src/Listeners/InitializeSwooleTables.php | 37 ++ .../src/Listeners/RegisterPruneTimer.php | 94 ++++ src/rate-limiter/src/RateLimiter.php | 245 +++++++++++ .../src/RateLimiterServiceProvider.php | 43 ++ src/rate-limiter/src/RedisStore.php | 403 ++++++++++++++++++ src/rate-limiter/src/Swoole/TableManager.php | 96 +++++ src/rate-limiter/src/Swoole/TableState.php | 49 +++ src/rate-limiter/src/SwooleStore.php | 260 +++++++++++ src/rate-limiter/src/Unlimited.php | 23 + src/rate-limiter/src/WorkerArrayStore.php | 122 ++++++ src/support/src/DefaultProviders.php | 1 + .../Fixtures/config/rate-limiter.php | 19 + .../Foundation/FoundationApplicationTest.php | 6 + .../RateLimiterTableCommandTest.php | 45 ++ .../Database/DatabaseStoreTestCase.php | 289 +++++++++++++ .../Database/MariaDb/DatabaseStoreTest.php | 15 + .../Database/MySql/DatabaseStoreTest.php | 15 + .../Database/Postgres/DatabaseStoreTest.php | 15 + .../Database/Sqlite/DatabaseStoreTest.php | 65 +++ .../RateLimiter/RedisStoreTest.php | 262 ++++++++++++ tests/RateLimiter/BackoffTest.php | 53 +++ tests/RateLimiter/DatabaseStoreTest.php | 321 ++++++++++++++ .../Fixtures/RateLimiterStoreContract.php | 197 +++++++++ .../InitializeSwooleTablesTest.php | 35 ++ tests/RateLimiter/KeyResolverTest.php | 129 ++++++ tests/RateLimiter/LeakyBucketTest.php | 94 ++++ tests/RateLimiter/LimitTest.php | 77 ++++ tests/RateLimiter/LimiterTest.php | 194 +++++++++ tests/RateLimiter/PackageMetadataTest.php | 67 +++ tests/RateLimiter/PruneCommandTest.php | 54 +++ .../RateLimiterServiceProviderTest.php | 55 +++ tests/RateLimiter/RateLimiterTest.php | 191 +++++++++ tests/RateLimiter/RedisStoreTest.php | 127 ++++++ tests/RateLimiter/RegisterPruneTimerTest.php | 220 ++++++++++ tests/RateLimiter/ResultTest.php | 55 +++ .../SwooleStoreConcurrencyTest.php | 298 +++++++++++++ tests/RateLimiter/SwooleStoreTest.php | 274 ++++++++++++ tests/RateLimiter/SwooleTableManagerTest.php | 140 ++++++ tests/RateLimiter/WorkerArrayStoreTest.php | 184 ++++++++ 64 files changed, 6956 insertions(+) create mode 100644 src/foundation/config/rate-limiter.php create mode 100644 src/rate-limiter/LICENSE.md create mode 100644 src/rate-limiter/README.md create mode 100644 src/rate-limiter/composer.json create mode 100644 src/rate-limiter/src/AdmissionPolicy.php create mode 100644 src/rate-limiter/src/Backoff.php create mode 100644 src/rate-limiter/src/BackoffResult.php create mode 100644 src/rate-limiter/src/Concerns/CalculatesRateLimits.php create mode 100644 src/rate-limiter/src/Console/PruneCommand.php create mode 100644 src/rate-limiter/src/Console/RateLimiterTableCommand.php create mode 100644 src/rate-limiter/src/Console/stubs/rate-limits.stub create mode 100644 src/rate-limiter/src/Contracts/Decision.php create mode 100644 src/rate-limiter/src/Contracts/PrunableStore.php create mode 100644 src/rate-limiter/src/Contracts/Store.php create mode 100644 src/rate-limiter/src/DatabaseStore.php create mode 100644 src/rate-limiter/src/Exceptions/InvalidRateLimitException.php create mode 100644 src/rate-limiter/src/Exceptions/SwooleTableFullException.php create mode 100644 src/rate-limiter/src/KeyResolver.php create mode 100644 src/rate-limiter/src/LeakyBucket.php create mode 100644 src/rate-limiter/src/Limit.php create mode 100644 src/rate-limiter/src/LimitResult.php create mode 100644 src/rate-limiter/src/Limiter.php create mode 100644 src/rate-limiter/src/Listeners/InitializeSwooleTables.php create mode 100644 src/rate-limiter/src/Listeners/RegisterPruneTimer.php create mode 100644 src/rate-limiter/src/RateLimiter.php create mode 100644 src/rate-limiter/src/RateLimiterServiceProvider.php create mode 100644 src/rate-limiter/src/RedisStore.php create mode 100644 src/rate-limiter/src/Swoole/TableManager.php create mode 100644 src/rate-limiter/src/Swoole/TableState.php create mode 100644 src/rate-limiter/src/SwooleStore.php create mode 100644 src/rate-limiter/src/Unlimited.php create mode 100644 src/rate-limiter/src/WorkerArrayStore.php create mode 100644 tests/Foundation/Fixtures/config/rate-limiter.php create mode 100644 tests/Integration/Generators/RateLimiterTableCommandTest.php create mode 100644 tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php create mode 100644 tests/Integration/RateLimiter/Database/MariaDb/DatabaseStoreTest.php create mode 100644 tests/Integration/RateLimiter/Database/MySql/DatabaseStoreTest.php create mode 100644 tests/Integration/RateLimiter/Database/Postgres/DatabaseStoreTest.php create mode 100644 tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php create mode 100644 tests/Integration/RateLimiter/RedisStoreTest.php create mode 100644 tests/RateLimiter/BackoffTest.php create mode 100644 tests/RateLimiter/DatabaseStoreTest.php create mode 100644 tests/RateLimiter/Fixtures/RateLimiterStoreContract.php create mode 100644 tests/RateLimiter/InitializeSwooleTablesTest.php create mode 100644 tests/RateLimiter/KeyResolverTest.php create mode 100644 tests/RateLimiter/LeakyBucketTest.php create mode 100644 tests/RateLimiter/LimitTest.php create mode 100644 tests/RateLimiter/LimiterTest.php create mode 100644 tests/RateLimiter/PackageMetadataTest.php create mode 100644 tests/RateLimiter/PruneCommandTest.php create mode 100644 tests/RateLimiter/RateLimiterServiceProviderTest.php create mode 100644 tests/RateLimiter/RateLimiterTest.php create mode 100644 tests/RateLimiter/RedisStoreTest.php create mode 100644 tests/RateLimiter/RegisterPruneTimerTest.php create mode 100644 tests/RateLimiter/ResultTest.php create mode 100644 tests/RateLimiter/SwooleStoreConcurrencyTest.php create mode 100644 tests/RateLimiter/SwooleStoreTest.php create mode 100644 tests/RateLimiter/SwooleTableManagerTest.php create mode 100644 tests/RateLimiter/WorkerArrayStoreTest.php diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index a22114dd1..34f604962 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -65,6 +65,7 @@ jobs: vendor/bin/phpunit tests/Integration/Auth vendor/bin/phpunit tests/Integration/Cache/Redis vendor/bin/phpunit tests/Integration/Horizon + vendor/bin/phpunit tests/Integration/RateLimiter vendor/bin/phpunit tests/Integration/Redis valkey_9: @@ -124,4 +125,5 @@ jobs: vendor/bin/phpunit tests/Integration/Auth vendor/bin/phpunit tests/Integration/Cache/Redis vendor/bin/phpunit tests/Integration/Horizon + vendor/bin/phpunit tests/Integration/RateLimiter vendor/bin/phpunit tests/Integration/Redis diff --git a/composer.json b/composer.json index e3c08e4da..946408b33 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ "Hypervel\\Process\\": "src/process/src/", "Hypervel\\Prompts\\": "src/prompts/src/", "Hypervel\\Queue\\": "src/queue/src/", + "Hypervel\\RateLimiter\\": "src/rate-limiter/src/", "Hypervel\\Redis\\": "src/redis/src/", "Hypervel\\Reverb\\": "src/reverb/src/", "Hypervel\\Routing\\": "src/routing/src/", @@ -257,6 +258,7 @@ "hypervel/process": "self.version", "hypervel/prompts": "self.version", "hypervel/queue": "self.version", + "hypervel/rate-limiter": "self.version", "hypervel/redis": "self.version", "hypervel/reflection": "self.version", "hypervel/routing": "self.version", @@ -344,6 +346,7 @@ "Hypervel\\Grpc\\GrpcServiceProvider", "Hypervel\\Pipeline\\PipelineServiceProvider", "Hypervel\\Queue\\QueueServiceProvider", + "Hypervel\\RateLimiter\\RateLimiterServiceProvider", "Hypervel\\Redis\\RedisServiceProvider", "Hypervel\\Reverb\\ReverbServiceProvider", "Hypervel\\Routing\\RoutingServiceProvider", diff --git a/src/foundation/config/rate-limiter.php b/src/foundation/config/rate-limiter.php new file mode 100644 index 000000000..be0f38ad0 --- /dev/null +++ b/src/foundation/config/rate-limiter.php @@ -0,0 +1,67 @@ + env('RATE_LIMITER_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Rate Limiter Stores + |-------------------------------------------------------------------------- + | + | Here you may configure the stores used to hold rate limiter state. Each + | store performs its decisions atomically using its native primitives. + | + | Supported drivers: "database", "redis", "swoole", "worker-array" + | + */ + + 'stores' => [ + 'database' => [ + 'driver' => 'database', + 'connection' => env('RATE_LIMITER_DB_CONNECTION'), + 'table' => env('RATE_LIMITER_DB_TABLE', 'rate_limits'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('RATE_LIMITER_REDIS_CONNECTION', 'default'), + ], + + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => (int) env('RATE_LIMITER_SWOOLE_ROWS', 65536), + 'conflict_proportion' => 0.2, + 'memory_limit_buffer' => 0.05, + 'prune_interval' => 60, // seconds + ], + + 'worker-array' => [ + 'driver' => 'worker-array', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiter Prefix + |-------------------------------------------------------------------------- + | + | This value namespaces limiter identities so applications sharing a store + | do not share rate limit state. It is included before keys are hashed. + | + */ + + 'prefix' => env('RATE_LIMITER_PREFIX', app_id() . '_rate_limiter'), +]; diff --git a/src/foundation/src/Bootstrap/LoadConfiguration.php b/src/foundation/src/Bootstrap/LoadConfiguration.php index 669065c0a..5c9a874ce 100644 --- a/src/foundation/src/Bootstrap/LoadConfiguration.php +++ b/src/foundation/src/Bootstrap/LoadConfiguration.php @@ -167,6 +167,7 @@ protected function mergeableOptions(string $name): array 'logging' => ['channels'], 'mail' => ['mailers'], 'queue' => ['connections'], + 'rate-limiter' => ['stores'], ][$name] ?? []; } diff --git a/src/rate-limiter/LICENSE.md b/src/rate-limiter/LICENSE.md new file mode 100644 index 000000000..670aace44 --- /dev/null +++ b/src/rate-limiter/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Taylor Otwell + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/rate-limiter/README.md b/src/rate-limiter/README.md new file mode 100644 index 000000000..a59dd2395 --- /dev/null +++ b/src/rate-limiter/README.md @@ -0,0 +1,14 @@ +Rate Limiter for Hypervel +=== + +Documentation: https://hypervel.org/docs/rate-limiting + +## Differences From Laravel + +Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and the `Hypervel\RateLimiter` namespace instead of Laravel's cache-bound limiter. Policies are consumed atomically through dedicated Redis, Swoole, database, or worker-array stores; the primitive counter methods under `Illuminate\Cache\RateLimiter` are not available. + +Hypervel uses immutable typed policies. `Limit` defines a fixed window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential lockout. Use `globally()` instead of Laravel's `GlobalLimit` class. + +Every physical limiter key is hashed and includes its policy parameters. Changing a policy starts new state, and `clear()` must receive the same policy parameters that created the state. Sequential stacked policies retain earlier successful charges when a later policy denies, weighted denials report the actual unused capacity, and `attempt()` consumes before the callback and retains the charge if the callback throws. + +Redis is selected as a regular rate-limiter store. Hypervel does not provide Redis-specific routing or queue middleware classes, a `throttleWithRedis()` switch, or an opt-out from canonical key hashing. diff --git a/src/rate-limiter/composer.json b/src/rate-limiter/composer.json new file mode 100644 index 000000000..4d33f9815 --- /dev/null +++ b/src/rate-limiter/composer.json @@ -0,0 +1,59 @@ +{ + "name": "hypervel/rate-limiter", + "type": "library", + "description": "The rate limiter package for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "rate limiter", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\RateLimiter\\": "src/" + } + }, + "require": { + "php": "^8.4", + "ext-swoole": "^6.2", + "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/coordinator": "^0.4", + "hypervel/core": "^0.4", + "hypervel/database": "^0.4", + "hypervel/redis": "^0.4", + "hypervel/support": "^0.4", + "psr/log": "^3.0", + "symfony/console": "^8.1" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\RateLimiter\\RateLimiterServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} diff --git a/src/rate-limiter/src/AdmissionPolicy.php b/src/rate-limiter/src/AdmissionPolicy.php new file mode 100644 index 000000000..39244397d --- /dev/null +++ b/src/rate-limiter/src/AdmissionPolicy.php @@ -0,0 +1,163 @@ +newInstance( + $this->normalizeKey($key), + $this->cost, + $this->global, + $this->afterCallback, + $this->responseCallback, + ); + } + + /** + * Set the capacity consumed by each operation. + */ + public function cost(int $cost): static + { + static::ensurePositive('cost', $cost); + + return $this->newInstance( + $this->key, + $cost, + $this->global, + $this->afterCallback, + $this->responseCallback, + ); + } + + // Laravel's separate GlobalLimit marker is replaced by this immutable + // modifier so every admission policy can opt out of named key scoping. + + /** + * Apply the policy without a named limiter scope. + */ + public function globally(bool $global = true): static + { + return $this->newInstance( + $this->key, + $this->cost, + $global, + $this->afterCallback, + $this->responseCallback, + ); + } + + /** + * Set the callback that determines whether the operation should be consumed. + */ + public function after(callable $callback): static + { + return $this->newInstance( + $this->key, + $this->cost, + $this->global, + Closure::fromCallable($callback), + $this->responseCallback, + ); + } + + /** + * Set the callback that generates a response when the limit is exceeded. + */ + public function response(callable $callback): static + { + return $this->newInstance( + $this->key, + $this->cost, + $this->global, + $this->afterCallback, + Closure::fromCallable($callback), + ); + } + + /** + * Create a copy with the given shared policy values. + */ + abstract protected function newInstance( + string $key, + int $cost, + bool $global, + ?Closure $afterCallback, + ?Closure $responseCallback, + ): static; + + /** + * Convert a caller key to its canonical string value. + */ + protected function normalizeKey(Stringable|UnitEnum|string|int|null $key): string + { + if ($key instanceof UnitEnum) { + $key = enum_value($key); + } + + return $key === null ? '' : (string) $key; + } + + /** + * Validate a positive shared integer. + */ + protected static function ensurePositive(string $name, int $value): void + { + if ($value < 1 || $value > self::MAX_INTEGER) { + throw new InvalidRateLimitException(sprintf( + 'The rate limit %s must be between 1 and %d.', + $name, + self::MAX_INTEGER, + )); + } + } + + /** + * Multiply positive rate limit values without losing integer precision. + */ + protected static function multiply(int $value, int $multiplier, string $name): int + { + static::ensurePositive($name, $value); + static::ensurePositive($name . ' multiplier', $multiplier); + + if ($value > intdiv(self::MAX_INTEGER, $multiplier)) { + throw new InvalidRateLimitException(sprintf( + 'The rate limit %s exceeds the maximum supported duration.', + $name, + )); + } + + return $value * $multiplier; + } +} diff --git a/src/rate-limiter/src/Backoff.php b/src/rate-limiter/src/Backoff.php new file mode 100644 index 000000000..4788bfd51 --- /dev/null +++ b/src/rate-limiter/src/Backoff.php @@ -0,0 +1,97 @@ +validate(); + } + + /** + * Create a new exponential backoff policy. + */ + public static function exponential( + int $after = 1, + int $initialDelay = 1, + int $maxDelay = 60, + int $resetAfter = 3600, + ): self { + return new self($after, $initialDelay, $maxDelay, $resetAfter); + } + + /** + * Set the key of the backoff policy. + */ + public function by(Stringable|UnitEnum|string|int|null $key): self + { + if ($key instanceof UnitEnum) { + $key = enum_value($key); + } + + return new self( + $this->after, + $this->initialDelay, + $this->maxDelay, + $this->resetAfter, + $key === null ? '' : (string) $key, + ); + } + + /** + * Validate the backoff parameters. + */ + private function validate(): void + { + foreach ([ + 'failure threshold' => $this->after, + 'initial delay' => $this->initialDelay, + 'maximum delay' => $this->maxDelay, + 'reset interval' => $this->resetAfter, + ] as $name => $value) { + if ($value < 1 || $value > AdmissionPolicy::MAX_INTEGER) { + throw new InvalidRateLimitException(sprintf( + 'The backoff %s must be between 1 and %d.', + $name, + AdmissionPolicy::MAX_INTEGER, + )); + } + } + + if ($this->initialDelay > $this->maxDelay) { + throw new InvalidRateLimitException( + 'The backoff initial delay may not exceed its maximum delay.' + ); + } + + if ($this->resetAfter < $this->maxDelay) { + throw new InvalidRateLimitException( + 'The backoff reset interval must be at least its maximum delay.' + ); + } + + if ($this->maxDelay > intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) + || $this->resetAfter > intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000)) { + throw new InvalidRateLimitException( + 'The backoff duration exceeds the maximum supported duration.' + ); + } + } +} diff --git a/src/rate-limiter/src/BackoffResult.php b/src/rate-limiter/src/BackoffResult.php new file mode 100644 index 000000000..8b00ae190 --- /dev/null +++ b/src/rate-limiter/src/BackoffResult.php @@ -0,0 +1,69 @@ +allowed; + } + + /** + * Determine if the operation was denied. + */ + public function denied(): bool + { + return ! $this->allowed; + } + + /** + * Get the recorded number of consecutive failures. + */ + public function failures(): int + { + return $this->failures; + } + + /** + * Get the number of seconds until the operation may be retried. + */ + public function retryAfter(): int + { + return intdiv($this->retryAfterMicroseconds, 1_000_000) + + ($this->retryAfterMicroseconds % 1_000_000 === 0 ? 0 : 1); + } +} diff --git a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php new file mode 100644 index 000000000..9710fc1e4 --- /dev/null +++ b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php @@ -0,0 +1,394 @@ + $this->calculateFixedWindow( + $policy, + $now, + $value, + $availableAt, + $expiresAt, + true, + ), + $policy instanceof LeakyBucket => $this->calculateLeakyBucket( + $policy, + $now, + $value, + $availableAt, + $expiresAt, + true, + ), + default => throw new InvalidRateLimitException(sprintf( + 'Admission policy [%s] is not supported.', + $policy::class, + )), + }; + } + + /** + * Calculate a non-mutating policy inspection. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + protected function calculateInspection( + AdmissionPolicy|Backoff $policy, + int $now, + int $value, + int $availableAt, + int $expiresAt, + ): LimitResult|BackoffResult { + return match (true) { + $policy instanceof Limit => $this->calculateFixedWindow( + $policy, + $now, + $value, + $availableAt, + $expiresAt, + false, + ), + $policy instanceof LeakyBucket => $this->calculateLeakyBucket( + $policy, + $now, + $value, + $availableAt, + $expiresAt, + false, + ), + $policy instanceof Backoff => $this->calculateBackoffInspection( + $now, + $value, + $availableAt, + $expiresAt, + ), + default => throw new InvalidRateLimitException(sprintf( + 'Admission policy [%s] is not supported.', + $policy::class, + )), + }; + } + + /** + * Calculate a failure transition and update its state values. + */ + protected function calculateFailure( + Backoff $backoff, + int $now, + int &$value, + int &$availableAt, + int &$expiresAt, + ): BackoffResult { + $this->resetExpiredState($now, $value, $availableAt, $expiresAt); + $this->validateBackoffState($value, $availableAt, $expiresAt); + + if ($value >= AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException('The stored backoff failure count cannot be incremented safely.'); + } + + ++$value; + + $delay = $value < $backoff->after + ? 0 + : $this->backoffDelay($backoff, $value - $backoff->after); + + $availableAt = $delay === 0 + ? 0 + : $this->addExact($now, $this->secondsToMicroseconds($delay)); + $expiresAt = $this->addExact($now, $this->secondsToMicroseconds($backoff->resetAfter)); + + return new BackoffResult( + $delay === 0, + $value, + $delay === 0 ? 0 : $availableAt - $now, + ); + } + + /** + * Calculate a fixed-window decision. + */ + private function calculateFixedWindow( + Limit $policy, + int $now, + int &$value, + int &$availableAt, + int &$expiresAt, + bool $consume, + ): LimitResult { + $this->resetExpiredState($now, $value, $availableAt, $expiresAt); + $this->validateFixedWindowState($policy, $value, $availableAt, $expiresAt); + + if ($expiresAt === 0) { + if (! $consume) { + return new LimitResult(true, $policy->maxAttempts, $policy->maxAttempts, 0, 0); + } + + $value = $policy->cost; + $availableAt = $expiresAt = $this->addExact( + $now, + $this->secondsToMicroseconds($policy->decaySeconds), + ); + + return new LimitResult( + true, + $policy->maxAttempts, + $policy->maxAttempts - $value, + 0, + $expiresAt - $now, + ); + } + + $resetAfter = $expiresAt - $now; + $allowed = $value <= $policy->maxAttempts - $policy->cost; + + if (! $allowed) { + return new LimitResult( + false, + $policy->maxAttempts, + $policy->maxAttempts - $value, + $resetAfter, + $resetAfter, + ); + } + + if ($consume) { + $value += $policy->cost; + } + + return new LimitResult( + true, + $policy->maxAttempts, + $policy->maxAttempts - $value, + 0, + $resetAfter, + ); + } + + /** + * Calculate a leaky-bucket decision. + */ + private function calculateLeakyBucket( + LeakyBucket $policy, + int $now, + int &$value, + int &$availableAt, + int &$expiresAt, + bool $consume, + ): LimitResult { + $this->resetExpiredState($now, $value, $availableAt, $expiresAt); + $this->validateLeakyBucketState($value, $availableAt, $expiresAt); + + $emission = intdiv($policy->periodMicroseconds, $policy->rate) + + ($policy->periodMicroseconds % $policy->rate === 0 ? 0 : 1); + $effectiveTat = max($value, $now); + $candidateTat = $this->addExact( + $effectiveTat, + $this->multiplyExact($emission, $policy->cost), + ); + $burstDuration = $this->multiplyExact($emission, $policy->burst); + $allowedAt = $candidateTat - $burstDuration; + $allowed = $now >= $allowedAt; + + if (! $allowed) { + return new LimitResult( + false, + $policy->burst, + $this->remainingCapacity($now, $effectiveTat, $emission, $policy->burst), + $allowedAt - $now, + max($effectiveTat - $now, 0), + ); + } + + if (! $consume) { + return new LimitResult( + true, + $policy->burst, + $this->remainingCapacity($now, $effectiveTat, $emission, $policy->burst), + 0, + max($effectiveTat - $now, 0), + ); + } + + $value = $candidateTat; + $availableAt = 0; + $expiresAt = $candidateTat; + + return new LimitResult( + true, + $policy->burst, + $this->remainingCapacity($now, $candidateTat, $emission, $policy->burst), + 0, + $candidateTat - $now, + ); + } + + /** + * Calculate a non-mutating backoff inspection. + */ + private function calculateBackoffInspection( + int $now, + int $value, + int $availableAt, + int $expiresAt, + ): BackoffResult { + $this->resetExpiredState($now, $value, $availableAt, $expiresAt); + $this->validateBackoffState($value, $availableAt, $expiresAt); + + $retryAfter = max($availableAt - $now, 0); + + return new BackoffResult($retryAfter === 0, $value, $retryAfter); + } + + /** + * Calculate the current whole-token capacity. + */ + private function remainingCapacity( + int $now, + int $effectiveTat, + int $emission, + int $burst, + ): int { + $fullTat = $this->addExact($now, $this->multiplyExact($emission, $burst)); + + return min($burst, max(0, intdiv($fullTat - $effectiveTat, $emission))); + } + + /** + * Calculate a capped exponential delay without overflowing. + */ + private function backoffDelay(Backoff $backoff, int $doublings): int + { + $delay = $backoff->initialDelay; + + while ($doublings > 0 && $delay < $backoff->maxDelay) { + $delay = $delay > intdiv($backoff->maxDelay, 2) + ? $backoff->maxDelay + : min($delay * 2, $backoff->maxDelay); + --$doublings; + } + + return $delay; + } + + /** + * Reset state whose expiration has passed. + */ + private function resetExpiredState( + int $now, + int &$value, + int &$availableAt, + int &$expiresAt, + ): void { + if ($expiresAt !== 0 && $expiresAt <= $now) { + $value = 0; + $availableAt = 0; + $expiresAt = 0; + } + } + + /** + * Validate fixed-window state loaded from a store. + */ + private function validateFixedWindowState( + Limit $policy, + int $value, + int $availableAt, + int $expiresAt, + ): void { + if ($value < 0 || $value > $policy->maxAttempts + || $availableAt < 0 || $expiresAt < 0 + || $availableAt !== $expiresAt + || ($expiresAt === 0 && $value !== 0)) { + throw new UnexpectedValueException('The stored fixed-window rate limiter state is invalid.'); + } + } + + /** + * Validate leaky-bucket state loaded from a store. + */ + private function validateLeakyBucketState(int $value, int $availableAt, int $expiresAt): void + { + if ($value < 0 || $availableAt !== 0 || $expiresAt < 0 || $value !== $expiresAt) { + throw new UnexpectedValueException('The stored leaky-bucket rate limiter state is invalid.'); + } + } + + /** + * Validate backoff state loaded from a store. + */ + private function validateBackoffState(int $value, int $availableAt, int $expiresAt): void + { + if ($value < 0 || $availableAt < 0 || $expiresAt < 0 + || ($value === 0 && ($availableAt !== 0 || $expiresAt !== 0)) + || ($value !== 0 && $expiresAt === 0) + || $availableAt > $expiresAt) { + throw new UnexpectedValueException('The stored backoff rate limiter state is invalid.'); + } + } + + /** + * Get the current epoch time in microseconds. + */ + protected function currentTimeInMicroseconds(): int + { + return CarbonImmutable::hasTestNow() + ? (int) CarbonImmutable::now()->getPreciseTimestamp(6) + : (int) (microtime(true) * 1_000_000); + } + + /** + * Convert seconds to exact microseconds. + */ + private function secondsToMicroseconds(int $seconds): int + { + return $this->multiplyExact($seconds, 1_000_000); + } + + /** + * Add exact shared-store integers without overflowing their common range. + */ + private function addExact(int $left, int $right): int + { + if ($left < 0 || $right < 0 || $left > AdmissionPolicy::MAX_INTEGER - $right) { + throw new InvalidRateLimitException('The rate limiter timestamp exceeds the supported integer range.'); + } + + return $left + $right; + } + + /** + * Multiply exact shared-store integers without overflowing their common range. + */ + private function multiplyExact(int $left, int $right): int + { + if ($left < 0 || $right < 0 + || ($left !== 0 && $right > intdiv(AdmissionPolicy::MAX_INTEGER, $left))) { + throw new InvalidRateLimitException('The rate limiter value exceeds the supported integer range.'); + } + + return $left * $right; + } +} diff --git a/src/rate-limiter/src/Console/PruneCommand.php b/src/rate-limiter/src/Console/PruneCommand.php new file mode 100644 index 000000000..d65cb9b60 --- /dev/null +++ b/src/rate-limiter/src/Console/PruneCommand.php @@ -0,0 +1,48 @@ +argument('store'); + $name = is_string($name) ? $name : null; + $store = $rateLimiter->store($name)->getStore(); + + if (! $store instanceof PrunableStore) { + $name ??= $rateLimiter->getDefaultInstance(); + $this->components->error("Rate limiter store [{$name}] does not support pruning."); + + return self::FAILURE; + } + + $pruned = $store->pruneExpired((int) $this->option('chunk')); + $this->components->info("Pruned {$pruned} expired rate limiter entries."); + + return self::SUCCESS; + } +} diff --git a/src/rate-limiter/src/Console/RateLimiterTableCommand.php b/src/rate-limiter/src/Console/RateLimiterTableCommand.php new file mode 100644 index 000000000..5baee6d1c --- /dev/null +++ b/src/rate-limiter/src/Console/RateLimiterTableCommand.php @@ -0,0 +1,45 @@ +hypervel->make('config')->string('rate-limiter.stores.database.table'); + } + + /** + * Get the path to the migration stub file. + */ + protected function migrationStubFile(): string + { + return __DIR__ . '/stubs/rate-limits.stub'; + } +} diff --git a/src/rate-limiter/src/Console/stubs/rate-limits.stub b/src/rate-limiter/src/Console/stubs/rate-limits.stub new file mode 100644 index 000000000..f2918afb5 --- /dev/null +++ b/src/rate-limiter/src/Console/stubs/rate-limits.stub @@ -0,0 +1,31 @@ +char('key', 32)->primary(); + $table->unsignedBigInteger('value')->default(0); + $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('expires_at')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('{{table}}'); + } +}; diff --git a/src/rate-limiter/src/Contracts/Decision.php b/src/rate-limiter/src/Contracts/Decision.php new file mode 100644 index 000000000..e6604b2b0 --- /dev/null +++ b/src/rate-limiter/src/Contracts/Decision.php @@ -0,0 +1,23 @@ +connections->connection($this->connectionName); + $this->ensureOutsideTransaction($connection); + + return $connection->transaction(function (ConnectionInterface $connection) use ($key, $policy): LimitResult { + [$value, $availableAt, $expiresAt] = $this->stateForUpdate($connection, $key); + $result = $this->calculateConsume( + $policy, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $availableAt, + $expiresAt, + ); + + if ($result->allowed()) { + $this->writeState($connection, $key, $value, $availableAt, $expiresAt); + } + + return $result; + }, attempts: 3); + } + + /** + * Inspect a policy without mutating its state. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + $connection = $this->connections->connection($this->connectionName); + $row = $connection->table($this->table) + ->useWritePdo() + ->where('key', $key) + ->first(); + [$value, $availableAt, $expiresAt] = $row === null + ? [0, 0, 0] + : $this->stateFromRow($row); + + return $this->calculateInspection( + $policy, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $availableAt, + $expiresAt, + ); + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + $connection = $this->connections->connection($this->connectionName); + $this->ensureOutsideTransaction($connection); + + return $connection->transaction(function (ConnectionInterface $connection) use ($key, $backoff): BackoffResult { + [$value, $availableAt, $expiresAt] = $this->stateForUpdate($connection, $key); + $result = $this->calculateFailure( + $backoff, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $availableAt, + $expiresAt, + ); + + $this->writeState($connection, $key, $value, $availableAt, $expiresAt); + + return $result; + }, attempts: 3); + } + + /** + * Clear the state for a physical limiter key. + */ + public function clear(string $key): bool + { + $connection = $this->connections->connection($this->connectionName); + $this->ensureOutsideTransaction($connection); + + return $connection + ->table($this->table) + ->where('key', $key) + ->delete() > 0; + } + + /** + * Prune expired state in bounded batches. + */ + public function pruneExpired(int $chunkSize = 1000): int + { + if ($chunkSize < 1 || $chunkSize > self::MAX_PRUNE_CHUNK_SIZE) { + throw new InvalidArgumentException(sprintf( + 'The rate limiter prune chunk size must be between 1 and %d.', + self::MAX_PRUNE_CHUNK_SIZE, + )); + } + + $connection = $this->connections->connection($this->connectionName); + $this->ensureOutsideTransaction($connection); + $cutoff = $this->currentDatabaseTimeInMicroseconds($connection); + $pruned = 0; + + do { + $keys = []; + + foreach ($connection->table($this->table) + ->useWritePdo() + ->where('expires_at', '<=', $cutoff) + ->limit($chunkSize) + ->pluck('key') as $key) { + if (! is_string($key)) { + throw new UnexpectedValueException('The stored database rate limiter key is invalid.'); + } + + $keys[] = $key; + } + + if ($keys === []) { + break; + } + + $pruned += $connection->table($this->table) + ->whereIn('key', $keys) + ->where('expires_at', '<=', $cutoff) + ->delete(); + } while (count($keys) === $chunkSize); + + return $pruned; + } + + /** + * Insert an empty state row if the key does not exist. + */ + protected function insertStateRow(ConnectionInterface $connection, string $key): void + { + $connection->table($this->table)->insertOrIgnore([ + 'key' => $key, + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]); + } + + /** + * Lock and read state, inserting an empty row when necessary. + * + * @return array{int, int, int} + */ + protected function stateForUpdate(ConnectionInterface $connection, string $key): array + { + if ($connection->getDriverName() === 'sqlite') { + // SQLite ignores FOR UPDATE, so writing first acquires its database writer lock. + $this->insertStateRow($connection, $key); + } else { + // Lock established rows first. Insert-first makes concurrent InnoDB transactions + // repeatedly deadlock while upgrading duplicate-key shared locks. + $state = $this->findStateForUpdate($connection, $key); + + if ($state !== null) { + return $state; + } + + $this->insertStateRow($connection, $key); + } + + $state = $this->findStateForUpdate($connection, $key); + + if ($state === null) { + throw new UnexpectedValueException('The database rate limiter state row could not be read after insertion.'); + } + + return $state; + } + + /** + * Find and lock numeric state for a physical limiter key. + * + * @return null|array{int, int, int} + */ + protected function findStateForUpdate(ConnectionInterface $connection, string $key): ?array + { + $row = $connection->table($this->table) + ->where('key', $key) + ->lockForUpdate() + ->first(); + + if ($row === null) { + return null; + } + + return $this->stateFromRow($row); + } + + /** + * Get and validate numeric state from a database row. + * + * @return array{int, int, int} + */ + protected function stateFromRow(object $row): array + { + return [ + $this->integerValue($row->value ?? null, 'value'), + $this->integerValue($row->available_at ?? null, 'available_at'), + $this->integerValue($row->expires_at ?? null, 'expires_at'), + ]; + } + + /** + * Write numeric state for a physical limiter key. + */ + protected function writeState( + ConnectionInterface $connection, + string $key, + int $value, + int $availableAt, + int $expiresAt, + ): void { + $connection->table($this->table) + ->where('key', $key) + ->update([ + 'value' => $value, + 'available_at' => $availableAt, + 'expires_at' => $expiresAt, + ]); + } + + /** + * Ensure limiter mutations own their database transaction. + */ + protected function ensureOutsideTransaction(ConnectionInterface $connection): void + { + if ($connection->transactionLevel() > 0) { + throw new LogicException( + 'Database rate limiter mutations cannot run inside an active transaction on the selected connection. ' + . 'Configure a dedicated rate limiter connection or call the limiter outside the transaction.' + ); + } + } + + /** + * Get the authoritative current time in epoch microseconds. + */ + private function currentDatabaseTimeInMicroseconds(ConnectionInterface $connection): int + { + $value = match ($connection->getDriverName()) { + 'mysql', 'mariadb' => $connection->scalar( + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)', + useReadPdo: false, + ), + 'pgsql' => $connection->scalar( + 'SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint', + useReadPdo: false, + ), + 'sqlite' => $this->currentTimeInMicroseconds(), + default => throw new InvalidArgumentException(sprintf( + 'Database driver [%s] is not supported by the rate limiter.', + $connection->getDriverName(), + )), + }; + + $timestamp = $this->integerValue($value, 'current_time'); + + if ($timestamp <= 0) { + throw new UnexpectedValueException('The database rate limiter clock returned an invalid timestamp.'); + } + + return $timestamp; + } + + /** + * Normalize an exact non-negative integer returned by a database driver. + */ + private function integerValue(mixed $value, string $column): int + { + if (is_string($value) && preg_match('/^(0|[1-9][0-9]*)$/D', $value) === 1) { + $integer = (int) $value; + + if ((string) $integer !== $value) { + throw new UnexpectedValueException( + "The stored database rate limiter [{$column}] value is invalid." + ); + } + + $value = $integer; + } + + if (! is_int($value) || $value < 0 || $value > AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException( + "The stored database rate limiter [{$column}] value is invalid." + ); + } + + return $value; + } +} diff --git a/src/rate-limiter/src/Exceptions/InvalidRateLimitException.php b/src/rate-limiter/src/Exceptions/InvalidRateLimitException.php new file mode 100644 index 000000000..6e64d6e0b --- /dev/null +++ b/src/rate-limiter/src/Exceptions/InvalidRateLimitException.php @@ -0,0 +1,11 @@ +hashSeed = (int) hexdec(substr( + hash('xxh128', 'rate-limiter|' . $prefix), + 0, + 15, + )); + } + + /** + * Resolve a stable fixed-length physical key. + */ + public function resolve(AdmissionPolicy|Backoff $policy, ?string $limiterName = null): string + { + $identity = $this->segment('domain', 'hypervel-rate-limiter-v1') + . $this->segment('prefix', $this->prefix); + + if ($limiterName !== null) { + $identity .= $this->segment('limiter', $limiterName); + + if (! ($policy instanceof AdmissionPolicy && $policy->global)) { + $scope = ($this->scopeResolver)($limiterName); + + if ($scope !== null) { + $identity .= $this->segment('scope', $scope); + } + } + } + + $identity .= $this->segment('key', $policy->key) + . $this->policyIdentity($policy); + + return hash('xxh128', $identity, false, ['seed' => $this->hashSeed]); + } + + /** + * Build the stable policy portion of an identity. + */ + protected function policyIdentity(AdmissionPolicy|Backoff $policy): string + { + // Laravel's fallbackKey() changes only colliding named keys. Hypervel + // always includes stable policy parameters so configuration changes + // start independent state on every store. + return match (true) { + $policy instanceof Limit => $this->segment('policy', 'fixed-window') + . $this->segment('max-attempts', (string) $policy->maxAttempts) + . $this->segment('decay-seconds', (string) $policy->decaySeconds) + . $this->segment('global', $policy->global ? '1' : '0'), + $policy instanceof LeakyBucket => $this->segment('policy', 'leaky-bucket') + . $this->segment('rate', (string) $policy->rate) + . $this->segment('period-microseconds', (string) $policy->periodMicroseconds) + . $this->segment('burst', (string) $policy->burst) + . $this->segment('global', $policy->global ? '1' : '0'), + $policy instanceof Backoff => $this->segment('policy', 'exponential-backoff') + . $this->segment('after', (string) $policy->after) + . $this->segment('initial-delay', (string) $policy->initialDelay) + . $this->segment('max-delay', (string) $policy->maxDelay) + . $this->segment('reset-after', (string) $policy->resetAfter), + default => throw new InvalidRateLimitException(sprintf( + 'Policy [%s] is not supported.', + $policy::class, + )), + }; + } + + /** + * Encode a domain-tagged length-prefixed identity segment. + */ + protected function segment(string $domain, string $value): string + { + return strlen($domain) . ':' . $domain . strlen($value) . ':' . $value; + } +} diff --git a/src/rate-limiter/src/LeakyBucket.php b/src/rate-limiter/src/LeakyBucket.php new file mode 100644 index 000000000..32d15c090 --- /dev/null +++ b/src/rate-limiter/src/LeakyBucket.php @@ -0,0 +1,131 @@ + $periodMicroseconds) { + throw new InvalidRateLimitException( + 'The leaky-bucket rate may not exceed its period in microseconds.' + ); + } + + $emission = intdiv($periodMicroseconds, $rate) + + ($periodMicroseconds % $rate === 0 ? 0 : 1); + + if ($burst > intdiv(self::MAX_INTEGER, $emission)) { + throw new InvalidRateLimitException( + 'The leaky-bucket burst exceeds the maximum supported refill duration.' + ); + } + + parent::__construct($key, $cost, $global, $afterCallback, $responseCallback); + } + + /** + * Create a new per-second leaky-bucket limit. + */ + public static function perSecond(int $rate, int $decaySeconds = 1): static + { + return new static( + $rate, + static::multiply($decaySeconds, 1_000_000, 'decay seconds'), + $rate, + ); + } + + /** + * Create a new per-minute leaky-bucket limit. + */ + public static function perMinute(int $rate, int $decayMinutes = 1): static + { + return static::perSecond($rate, static::multiply($decayMinutes, 60, 'decay minutes')); + } + + /** + * Create a new leaky-bucket limit using minutes as the period. + */ + public static function perMinutes(int $decayMinutes, int $rate): static + { + return static::perMinute($rate, $decayMinutes); + } + + /** + * Create a new per-hour leaky-bucket limit. + */ + public static function perHour(int $rate, int $decayHours = 1): static + { + return static::perSecond($rate, static::multiply($decayHours, 3600, 'decay hours')); + } + + /** + * Create a new per-day leaky-bucket limit. + */ + public static function perDay(int $rate, int $decayDays = 1): static + { + return static::perSecond($rate, static::multiply($decayDays, 86400, 'decay days')); + } + + /** + * Set the immediately available capacity. + */ + public function burst(int $capacity): static + { + static::ensurePositive('burst', $capacity); + + return new static( + rate: $this->rate, + periodMicroseconds: $this->periodMicroseconds, + burst: $capacity, + key: $this->key, + cost: $this->cost, + global: $this->global, + afterCallback: $this->afterCallback, + responseCallback: $this->responseCallback, + ); + } + + /** + * Create a copy with the given shared policy values. + */ + protected function newInstance( + string $key, + int $cost, + bool $global, + ?Closure $afterCallback, + ?Closure $responseCallback, + ): static { + return new static( + rate: $this->rate, + periodMicroseconds: $this->periodMicroseconds, + burst: $this->burst, + key: $key, + cost: $cost, + global: $global, + afterCallback: $afterCallback, + responseCallback: $responseCallback, + ); + } +} diff --git a/src/rate-limiter/src/Limit.php b/src/rate-limiter/src/Limit.php new file mode 100644 index 000000000..147d0125a --- /dev/null +++ b/src/rate-limiter/src/Limit.php @@ -0,0 +1,98 @@ +maxAttempts, + decaySeconds: $this->decaySeconds, + key: $key, + cost: $cost, + global: $global, + afterCallback: $afterCallback, + responseCallback: $responseCallback, + ); + } +} diff --git a/src/rate-limiter/src/LimitResult.php b/src/rate-limiter/src/LimitResult.php new file mode 100644 index 000000000..af7590602 --- /dev/null +++ b/src/rate-limiter/src/LimitResult.php @@ -0,0 +1,99 @@ + $limit) { + throw new InvalidArgumentException('The remaining rate limit must be between zero and the limit.'); + } + + if ($retryAfterMicroseconds < 0 || $resetAfterMicroseconds < 0) { + throw new InvalidArgumentException('Rate limit durations may not be negative.'); + } + + if ($allowed && $retryAfterMicroseconds !== 0) { + throw new InvalidArgumentException('An allowed rate limit result may not have a retry delay.'); + } + + if (! $allowed && $retryAfterMicroseconds === 0) { + throw new InvalidArgumentException('A denied rate limit result must have a retry delay.'); + } + } + + /** + * Determine if the operation was allowed. + */ + public function allowed(): bool + { + return $this->allowed; + } + + /** + * Determine if the operation was denied. + */ + public function denied(): bool + { + return ! $this->allowed; + } + + /** + * Get the configured capacity. + */ + public function limit(): int + { + return $this->limit; + } + + /** + * Get the immediately available capacity. + */ + public function remaining(): int + { + return $this->remaining; + } + + /** + * Get the number of seconds until the operation may be retried. + */ + public function retryAfter(): int + { + return $this->seconds($this->retryAfterMicroseconds); + } + + /** + * Get the number of seconds until the limiter is fully reset. + */ + public function resetAfter(): int + { + return $this->seconds($this->resetAfterMicroseconds); + } + + /** + * Round microseconds up to whole seconds. + */ + private function seconds(int $microseconds): int + { + return intdiv($microseconds, 1_000_000) + + ($microseconds % 1_000_000 === 0 ? 0 : 1); + } +} diff --git a/src/rate-limiter/src/Limiter.php b/src/rate-limiter/src/Limiter.php new file mode 100644 index 000000000..7af99085f --- /dev/null +++ b/src/rate-limiter/src/Limiter.php @@ -0,0 +1,223 @@ +store; + } + + // One-call decisions replace Laravel's split tooManyAttempts(), hit(), + // increment(), attempts(), resetAttempts(), retriesLeft(), availableIn(), + // cleanRateLimiterKey(), and Limit::fallbackKey() APIs. + + /** + * Atomically consume capacity from an admission policy. + */ + public function consume( + AdmissionPolicy $policy, + UnitEnum|string|null $limiterName = null, + ): LimitResult { + if ($policy instanceof Unlimited) { + return $this->unlimitedResult(); + } + + $this->validateAdmission($policy); + + return $this->store->consume( + $this->resolveKey($policy, $limiterName), + $policy, + ); + } + + /** + * Inspect a policy without mutating its state. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + public function inspect( + AdmissionPolicy|Backoff $policy, + UnitEnum|string|null $limiterName = null, + ): LimitResult|BackoffResult { + if ($policy instanceof Unlimited) { + return $this->unlimitedResult(); + } + + if ($policy instanceof AdmissionPolicy) { + $this->validateAdmission($policy); + } else { + $this->validateTimeRange($policy->resetAfter * 1_000_000); + } + + return $this->store->inspect( + $this->resolveKey($policy, $limiterName), + $policy, + ); + } + + // Laravel invokes the callback before recording a hit. Hypervel consumes + // atomically first, so concurrent calls cannot all enter the callback. + + /** + * Execute a callback when the policy allows it. + */ + public function attempt( + AdmissionPolicy $policy, + Closure $callback, + UnitEnum|string|null $limiterName = null, + ): mixed { + if ($this->consume($policy, $limiterName)->denied()) { + return false; + } + + $result = $callback(); + + return $result ?? true; + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure( + Backoff $backoff, + UnitEnum|string|null $limiterName = null, + ): BackoffResult { + $this->validateTimeRange($backoff->resetAfter * 1_000_000); + + return $this->store->recordFailure( + $this->resolveKey($backoff, $limiterName), + $backoff, + ); + } + + /** + * Clear the state for an identically parameterized policy. + */ + public function clear( + AdmissionPolicy|Backoff $policy, + UnitEnum|string|null $limiterName = null, + ): bool { + if ($policy instanceof Unlimited) { + return true; + } + + return $this->store->clear($this->resolveKey($policy, $limiterName)); + } + + /** + * Validate admission constraints shared by every store. + */ + protected function validateAdmission(AdmissionPolicy $policy): void + { + $duration = match (true) { + $policy instanceof Limit => $this->validateFixedWindow($policy), + $policy instanceof LeakyBucket => $this->validateLeakyBucket($policy), + default => throw new InvalidRateLimitException(sprintf( + 'Admission policy [%s] is not supported.', + $policy::class, + )), + }; + + $this->validateTimeRange($duration); + } + + /** + * Validate a fixed-window policy and return its maximum state duration. + */ + protected function validateFixedWindow(Limit $policy): int + { + if ($policy->cost > $policy->maxAttempts) { + throw new InvalidRateLimitException( + 'The rate limit cost may not exceed the fixed-window capacity.' + ); + } + + return $policy->decaySeconds * 1_000_000; + } + + /** + * Validate a leaky-bucket policy and return its maximum state duration. + */ + protected function validateLeakyBucket(LeakyBucket $policy): int + { + if ($policy->cost > $policy->burst) { + throw new InvalidRateLimitException( + 'The rate limit cost may not exceed the leaky-bucket burst capacity.' + ); + } + + $emission = intdiv($policy->periodMicroseconds, $policy->rate) + + ($policy->periodMicroseconds % $policy->rate === 0 ? 0 : 1); + + return $emission * $policy->burst; + } + + /** + * Ensure current-time arithmetic fits every first-party store. + */ + protected function validateTimeRange(int $durationMicroseconds): void + { + $now = CarbonImmutable::hasTestNow() + ? (int) CarbonImmutable::now()->getPreciseTimestamp(6) + : (int) (microtime(true) * 1_000_000); + + if ($now < 0 || $durationMicroseconds < 0 + || $now > AdmissionPolicy::MAX_INTEGER - $durationMicroseconds) { + throw new InvalidRateLimitException( + 'The rate limiter timestamp exceeds the supported integer range.' + ); + } + } + + /** + * Resolve the physical state key for a policy. + */ + protected function resolveKey( + AdmissionPolicy|Backoff $policy, + UnitEnum|string|null $limiterName, + ): string { + if ($limiterName instanceof UnitEnum) { + $limiterName = (string) enum_value($limiterName); + } + + return $this->keyResolver->resolve($policy, $limiterName); + } + + /** + * Create an unlimited admission result. + */ + protected function unlimitedResult(): LimitResult + { + return new LimitResult( + true, + AdmissionPolicy::MAX_INTEGER, + AdmissionPolicy::MAX_INTEGER, + 0, + 0, + ); + } +} diff --git a/src/rate-limiter/src/Listeners/InitializeSwooleTables.php b/src/rate-limiter/src/Listeners/InitializeSwooleTables.php new file mode 100644 index 000000000..5844921ce --- /dev/null +++ b/src/rate-limiter/src/Listeners/InitializeSwooleTables.php @@ -0,0 +1,37 @@ +config->array('rate-limiter.stores') as $name => $config) { + if (! is_array($config) || ($config['driver'] ?? null) !== 'swoole') { + continue; + } + + $this->tables->get((string) $name); + } + + $this->tables->seal(); + } +} diff --git a/src/rate-limiter/src/Listeners/RegisterPruneTimer.php b/src/rate-limiter/src/Listeners/RegisterPruneTimer.php new file mode 100644 index 000000000..116fb6669 --- /dev/null +++ b/src/rate-limiter/src/Listeners/RegisterPruneTimer.php @@ -0,0 +1,94 @@ +workerId !== 0 || $event->server->taskworker) { + return; + } + + $storeIntervals = []; + + foreach ($this->config->array('rate-limiter.stores') as $name => $config) { + if (! is_array($config) || ($config['driver'] ?? null) !== 'swoole') { + continue; + } + + $name = (string) $name; + $interval = $this->config->integer("rate-limiter.stores.{$name}.prune_interval"); + + if ($interval <= 0) { + throw new InvalidArgumentException( + "Configuration value for key [rate-limiter.stores.{$name}.prune_interval] must be greater than zero." + ); + } + + $storeIntervals[$name] = $interval; + } + + $registrations = []; + + foreach ($storeIntervals as $name => $interval) { + $registrations[] = [ + 'interval' => $interval, + 'store' => $this->store($name), + ]; + } + + $timerIds = []; + + try { + foreach ($registrations as $registration) { + $timerIds[] = $this->timer->tick( + $registration['interval'], + fn (): int => $registration['store']->maintain(), + ); + } + } catch (Throwable $throwable) { + for ($index = count($timerIds) - 1; $index >= 0; --$index) { + try { + $this->timer->clear($timerIds[$index]); + } catch (Throwable) { + // Preserve the timer registration failure. + } + } + + throw $throwable; + } + } + + /** + * Get a configured Swoole rate limiter store. + */ + protected function store(string $name): SwooleStore + { + /** @var SwooleStore */ + return $this->rateLimiter->store($name)->getStore(); + } +} diff --git a/src/rate-limiter/src/RateLimiter.php b/src/rate-limiter/src/RateLimiter.php new file mode 100644 index 000000000..58c0d5aef --- /dev/null +++ b/src/rate-limiter/src/RateLimiter.php @@ -0,0 +1,245 @@ + + */ + protected array $limiters = []; + + /** + * The optional store selected for each named limiter. + * + * @var array + */ + protected array $limiterStores = []; + + /** + * The callback used to resolve the scope for named limiter keys. + */ + protected ?Closure $keyScopeResolver = null; + + /** + * Get a limiter store by name. + */ + public function store(UnitEnum|string|null $name = null): Limiter + { + if ($name instanceof UnitEnum) { + $name = (string) enum_value($name); + } + + /** @var Limiter */ + return $this->instance($name); + } + + /** + * Register a named limiter configuration. + * + * Boot-only. The callback and store selection persist on the singleton + * manager for the worker lifetime and affect every subsequent request. + */ + public function for( + UnitEnum|string $name, + Closure $callback, + UnitEnum|string|null $store = null, + ): static { + $name = $this->normalizeName($name); + + $this->limiters[$name] = $callback; + + if ($store === null) { + unset($this->limiterStores[$name]); + } else { + $this->limiterStores[$name] = $this->normalizeName($store); + } + + return $this; + } + + /** + * Get the given named rate limiter. + */ + public function limiter(UnitEnum|string $name): ?Closure + { + return $this->limiters[$this->normalizeName($name)] ?? null; + } + + /** + * Get the store registered for a named rate limiter. + */ + public function limiterStore(UnitEnum|string $name): ?string + { + return $this->limiterStores[$this->normalizeName($name)] ?? null; + } + + /** + * Register the named limiter key scope resolver. + * + * Boot-only. The callback persists on the singleton manager for the worker + * lifetime and affects every subsequent named limiter operation. + */ + public function resolveKeyScopeUsing(?Closure $resolver): void + { + $this->keyScopeResolver = $resolver; + } + + /** + * Get the default rate limiter store name. + */ + public function getDefaultInstance(): string + { + return $this->config->string('rate-limiter.default'); + } + + /** + * Set the default rate limiter store name. + * + * Boot-only. Mutates process-global config; per-request use races across coroutines. + */ + public function setDefaultInstance(string $name): void + { + $this->config->set('rate-limiter.default', $name); + } + + /** + * Get the store-specific configuration. + */ + public function getInstanceConfig(string $name): array + { + $config = $this->config->get('rate-limiter.stores.' . $name); + + if (! is_array($config)) { + throw new InvalidArgumentException("Rate limiter store [{$name}] is not defined."); + } + + return [...$config, 'name' => $name]; + } + + /** + * Resolve a store and wrap it in the public limiter API. + */ + protected function resolve(string $name): Limiter + { + $store = parent::resolve($name); + + if (! $store instanceof Store) { + throw new InvalidArgumentException(sprintf( + 'Rate limiter driver [%s] must return an instance of [%s].', + get_debug_type($store), + Store::class, + )); + } + + return new Limiter( + $store, + new KeyResolver( + $this->config->string('rate-limiter.prefix'), + fn (string $limiterName): ?string => $this->keyScopeResolver?->__invoke($limiterName), + ), + ); + } + + /** + * Create a worker-lifetime array store. + */ + protected function createWorkerArrayDriver(): Store + { + return new WorkerArrayStore; + } + + /** + * Create a database store. + */ + protected function createDatabaseDriver(array $config): Store + { + $connection = $config['connection'] ?? null; + $table = $config['table'] ?? null; + + if ($connection !== null && (! is_string($connection) || $connection === '')) { + throw new InvalidArgumentException('The rate limiter database connection must be null or a non-empty string.'); + } + + if (! is_string($table) || $table === '') { + throw new InvalidArgumentException('The rate limiter database table must be a non-empty string.'); + } + + return new DatabaseStore( + $this->app->make(ConnectionResolverInterface::class), + $connection, + $table, + ); + } + + /** + * Create a Redis store. + */ + protected function createRedisDriver(array $config): Store + { + $connection = $config['connection'] ?? null; + + if (! is_string($connection) || $connection === '') { + throw new InvalidArgumentException('The rate limiter Redis connection must be a non-empty string.'); + } + + return new RedisStore( + $this->app->make(RedisFactory::class), + $connection, + ); + } + + /** + * Create a Swoole store. + */ + protected function createSwooleDriver(array $config): Store + { + $name = $config['name'] ?? null; + $memoryLimitBuffer = $config['memory_limit_buffer'] ?? null; + + if (! is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'The resolved Swoole rate limiter store configuration is missing its manager-supplied name.' + ); + } + + if (! is_float($memoryLimitBuffer) && ! is_int($memoryLimitBuffer)) { + throw new InvalidArgumentException('The Swoole rate limiter memory limit buffer must be numeric.'); + } + + return new SwooleStore( + $this->app->make(TableManager::class)->get($name), + (float) $memoryLimitBuffer, + $this->app->make(LoggerInterface::class), + ); + } + + /** + * Normalize an enum or string manager name. + */ + protected function normalizeName(UnitEnum|string $name): string + { + return $name instanceof UnitEnum + ? (string) enum_value($name) + : $name; + } +} diff --git a/src/rate-limiter/src/RateLimiterServiceProvider.php b/src/rate-limiter/src/RateLimiterServiceProvider.php new file mode 100644 index 000000000..bcb4aaced --- /dev/null +++ b/src/rate-limiter/src/RateLimiterServiceProvider.php @@ -0,0 +1,43 @@ +commands([ + PruneCommand::class, + RateLimiterTableCommand::class, + ]); + } + + /** + * Bootstrap the service provider. + */ + public function boot(): void + { + $events = $this->app->make('events'); + + $events->listen(BeforeServerStart::class, function (BeforeServerStart $event): void { + $this->app->make(InitializeSwooleTables::class)->handle($event); + }); + + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + $this->app->make(RegisterPruneTimer::class)->handle($event); + }); + } +} diff --git a/src/rate-limiter/src/RedisStore.php b/src/rate-limiter/src/RedisStore.php new file mode 100644 index 000000000..497e82bc5 --- /dev/null +++ b/src/rate-limiter/src/RedisStore.php @@ -0,0 +1,403 @@ + limit or current % 1 ~= 0 then + return redis.error_reply('ERR corrupt rate limiter counter') +end + +local ttl = redis.call('PTTL', KEYS[1]) +if ttl == -1 then + return redis.error_reply('ERR corrupt rate limiter counter has no expiry') +end +if ttl <= 0 then + return empty_result() +end + +local ttlMicroseconds = ttl * 1000 + +if cost > limit - current then + return {0, limit, limit - current, ttlMicroseconds, ttlMicroseconds} +end + +if mode == 'inspect' then + return {1, limit, limit - current, 0, ttlMicroseconds} +end + +local incremented = redis.call('INCRBY', KEYS[1], ARGV[2]) +return {1, limit, limit - incremented, 0, ttlMicroseconds} +LUA; + + private const string LEAKY_BUCKET_SCRIPT = <<<'LUA' +local MAX_INTEGER = 9007199254740991 +local mode = ARGV[1] +local cost = tonumber(ARGV[2]) +local rate = tonumber(ARGV[3]) +local period = tonumber(ARGV[4]) +local burst = tonumber(ARGV[5]) +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000000 + tonumber(time[2]) +local emission = math.floor(period / rate) + +if period % rate ~= 0 then + emission = emission + 1 +end + +local burstDuration = emission * burst +local costDuration = emission * cost +local storedTat = now +local raw = redis.call('GET', KEYS[1]) + +if raw then + if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then + return redis.error_reply('ERR corrupt rate limiter TAT') + end + + storedTat = tonumber(raw) + if not storedTat or storedTat < 0 or storedTat > MAX_INTEGER or storedTat % 1 ~= 0 then + return redis.error_reply('ERR corrupt rate limiter TAT') + end + + local ttl = redis.call('PTTL', KEYS[1]) + if ttl == -1 then + return redis.error_reply('ERR corrupt rate limiter TAT has no expiry') + end + if ttl <= 0 then + storedTat = now + end +end + +local effectiveTat = math.max(storedTat, now) +if effectiveTat > MAX_INTEGER - costDuration then + return redis.error_reply('ERR rate limiter TAT overflow') +end + +local candidateTat = effectiveTat + costDuration +local allowedAt = candidateTat - burstDuration +local allowed = now >= allowedAt + +if now > MAX_INTEGER - burstDuration then + return redis.error_reply('ERR rate limiter capacity overflow') +end + +local remaining = math.floor((now + burstDuration - effectiveTat) / emission) +remaining = math.max(0, math.min(burst, remaining)) +local reset = math.max(effectiveTat - now, 0) + +if not allowed then + return {0, burst, remaining, allowedAt - now, reset} +end + +if mode == 'inspect' then + return {1, burst, remaining, 0, reset} +end + +local nextRemaining = math.floor((now + burstDuration - candidateTat) / emission) +nextRemaining = math.max(0, math.min(burst, nextRemaining)) +local nextReset = candidateTat - now +local ttl = math.max(1, math.floor((nextReset + 999) / 1000)) + +redis.call('SET', KEYS[1], candidateTat, 'PX', ttl) + +return {1, burst, nextRemaining, 0, nextReset} +LUA; + + private const string BACKOFF_SCRIPT = <<<'LUA' +local MAX_INTEGER = 9007199254740991 +local mode = ARGV[1] +local after = tonumber(ARGV[2]) +local initialDelay = tonumber(ARGV[3]) +local maxDelay = tonumber(ARGV[4]) +local resetAfter = tonumber(ARGV[5]) +local time = redis.call('TIME') +local now = tonumber(time[1]) * 1000000 + tonumber(time[2]) +local failures = 0 +local availableAt = 0 +local state = redis.call('HMGET', KEYS[1], 'failures', 'available_at') + +if state[1] or state[2] then + if not state[1] or not state[2] then + return redis.error_reply('ERR corrupt rate limiter backoff state') + end + + for _, raw in ipairs(state) do + if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then + return redis.error_reply('ERR corrupt rate limiter backoff state') + end + end + + failures = tonumber(state[1]) + availableAt = tonumber(state[2]) + + if not failures or failures < 0 or failures > MAX_INTEGER or failures % 1 ~= 0 + or not availableAt or availableAt < 0 or availableAt > MAX_INTEGER or availableAt % 1 ~= 0 then + return redis.error_reply('ERR corrupt rate limiter backoff state') + end + + local ttl = redis.call('PTTL', KEYS[1]) + if ttl == -1 then + return redis.error_reply('ERR corrupt rate limiter backoff state has no expiry') + end + if ttl <= 0 then + failures = 0 + availableAt = 0 + elseif failures == 0 then + return redis.error_reply('ERR corrupt rate limiter backoff state') + end +end + +if mode == 'inspect' then + local retry = math.max(availableAt - now, 0) + return {retry == 0 and 1 or 0, failures, 0, retry, 0} +end + +if failures >= MAX_INTEGER then + return redis.error_reply('ERR rate limiter failure count overflow') +end + +failures = failures + 1 +local delay = 0 + +if failures >= after then + delay = initialDelay + local doublings = failures - after + + while doublings > 0 and delay < maxDelay do + if delay > math.floor(maxDelay / 2) then + delay = maxDelay + else + delay = math.min(delay * 2, maxDelay) + end + + doublings = doublings - 1 + end +end + +if now > MAX_INTEGER - resetAfter or now > MAX_INTEGER - delay then + return redis.error_reply('ERR rate limiter backoff timestamp overflow') +end + +availableAt = delay == 0 and 0 or now + delay + +redis.call('HSET', KEYS[1], 'failures', failures, 'available_at', availableAt) +redis.call('PEXPIRE', KEYS[1], math.max(1, math.floor((resetAfter + 999) / 1000))) + +return {delay == 0 and 1 or 0, failures, 0, delay, 0} +LUA; + + /** + * Create a new Redis rate limiter store. + */ + public function __construct( + protected RedisFactory $redis, + protected string $connection, + ) { + } + + /** + * Atomically consume capacity from an admission policy. + */ + public function consume(string $key, AdmissionPolicy $policy): LimitResult + { + return match (true) { + $policy instanceof Limit => $this->executeFixedWindow($key, $policy, 'consume'), + $policy instanceof LeakyBucket => $this->executeLeakyBucket($key, $policy, 'consume'), + default => throw new InvalidRateLimitException(sprintf( + 'Admission policy [%s] is not supported.', + $policy::class, + )), + }; + } + + /** + * Inspect a policy without mutating its state. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + return match (true) { + $policy instanceof Limit => $this->executeFixedWindow($key, $policy, 'inspect'), + $policy instanceof LeakyBucket => $this->executeLeakyBucket($key, $policy, 'inspect'), + $policy instanceof Backoff => $this->executeBackoff($key, $policy, 'inspect'), + default => throw new InvalidRateLimitException(sprintf( + 'Admission policy [%s] is not supported.', + $policy::class, + )), + }; + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + return $this->executeBackoff($key, $backoff, 'failure'); + } + + /** + * Clear the state for a physical limiter key. + */ + public function clear(string $key): bool + { + return $this->redis->connection($this->connection)->withConnection( + static fn (RedisConnection $connection): bool => (int) $connection->del($key) > 0, + transform: false, + ); + } + + /** + * Execute a fixed-window operation. + */ + protected function executeFixedWindow(string $key, Limit $policy, string $mode): LimitResult + { + $result = $this->execute(self::FIXED_WINDOW_SCRIPT, $key, [ + $mode, + (string) $policy->cost, + (string) $policy->maxAttempts, + (string) ($policy->decaySeconds * 1000), + ]); + + return $this->limitResult($result, $policy->maxAttempts); + } + + /** + * Execute a leaky-bucket operation. + */ + protected function executeLeakyBucket(string $key, LeakyBucket $policy, string $mode): LimitResult + { + $result = $this->execute(self::LEAKY_BUCKET_SCRIPT, $key, [ + $mode, + (string) $policy->cost, + (string) $policy->rate, + (string) $policy->periodMicroseconds, + (string) $policy->burst, + ]); + + return $this->limitResult($result, $policy->burst); + } + + /** + * Execute an exponential-backoff operation. + */ + protected function executeBackoff(string $key, Backoff $backoff, string $mode): BackoffResult + { + $result = $this->execute(self::BACKOFF_SCRIPT, $key, [ + $mode, + (string) $backoff->after, + (string) ($backoff->initialDelay * 1_000_000), + (string) ($backoff->maxDelay * 1_000_000), + (string) ($backoff->resetAfter * 1_000_000), + ]); + $values = $this->integerTuple($result); + + if ($values[1] < 0 || $values[1] > AdmissionPolicy::MAX_INTEGER + || $values[2] !== 0 || $values[4] !== 0) { + throw new UnexpectedValueException('Redis returned an invalid rate limiter backoff result.'); + } + + return new BackoffResult($values[0] === 1, $values[1], $values[3]); + } + + /** + * Execute a rate limiter script through Redis's SHA cache. + */ + protected function execute(string $script, string $key, array $arguments): mixed + { + return $this->redis->connection($this->connection)->withConnection( + static fn (RedisConnection $connection): mixed => $connection->evalWithShaCache( + $script, + [$key], + $arguments, + ), + transform: false, + ); + } + + /** + * Convert a Redis tuple to an admission result. + */ + protected function limitResult(mixed $result, int $expectedLimit): LimitResult + { + $values = $this->integerTuple($result); + + if ($values[1] !== $expectedLimit + || $values[2] < 0 || $values[2] > $expectedLimit) { + throw new UnexpectedValueException('Redis returned an invalid rate limiter admission result.'); + } + + return new LimitResult( + $values[0] === 1, + $values[1], + $values[2], + $values[3], + $values[4], + ); + } + + /** + * Validate and return a five-integer Redis result tuple. + * + * @return array{int, int, int, int, int} + */ + protected function integerTuple(mixed $result): array + { + if (! is_array($result) || ! array_is_list($result) || count($result) !== 5) { + throw new UnexpectedValueException('Redis returned a malformed rate limiter result.'); + } + + foreach ($result as $value) { + if (! is_int($value) || $value < 0 || $value > AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException('Redis returned a malformed rate limiter result.'); + } + } + + if ($result[0] !== 0 && $result[0] !== 1) { + throw new UnexpectedValueException('Redis returned an invalid rate limiter decision flag.'); + } + + /** @var array{int, int, int, int, int} $result */ + return $result; + } +} diff --git a/src/rate-limiter/src/Swoole/TableManager.php b/src/rate-limiter/src/Swoole/TableManager.php new file mode 100644 index 000000000..8803048c5 --- /dev/null +++ b/src/rate-limiter/src/Swoole/TableManager.php @@ -0,0 +1,96 @@ + + */ + protected array $states = []; + + protected bool $sealed = false; + + /** + * Create a new Swoole table manager. + */ + public function __construct(protected Repository $config) + { + } + + /** + * Get a Swoole rate limiter table by store name. + */ + public function get(string $name): TableState + { + if (isset($this->states[$name])) { + return $this->states[$name]; + } + + if ($this->sealed) { + throw new LogicException( + "Swoole rate limiter table [{$name}] was not initialized before the server fork." + ); + } + + return $this->states[$name] = $this->resolve($name); + } + + /** + * Prevent tables from being created after the server initialization phase. + * + * Boot-only. Creating a table after the server forks would give each worker + * private state instead of one shared rate limiter. + */ + public function seal(): void + { + $this->sealed = true; + } + + /** + * Resolve a configured Swoole rate limiter table. + */ + protected function resolve(string $name): TableState + { + $config = $this->config->get("rate-limiter.stores.{$name}"); + + if (! is_array($config) || ($config['driver'] ?? null) !== 'swoole') { + throw new InvalidArgumentException("Swoole rate limiter store [{$name}] is not defined."); + } + + $rows = $this->config->integer("rate-limiter.stores.{$name}.rows"); + $conflictProportion = $this->config->float("rate-limiter.stores.{$name}.conflict_proportion"); + + if ($rows <= 0) { + throw new InvalidArgumentException('The Swoole rate limiter row count must be a positive integer.'); + } + + if ($conflictProportion < 0.2 || $conflictProportion > 1.0) { + throw new InvalidArgumentException( + 'The Swoole rate limiter conflict proportion must be between 0.2 and 1.0, inclusive.' + ); + } + + $table = new Table($rows, $conflictProportion); + $table->column('value', Table::TYPE_INT, 8); + $table->column('available_at', Table::TYPE_INT, 8); + $table->column('expires_at', Table::TYPE_INT, 8); + + if (! $table->create()) { + throw new RuntimeException("Unable to create Swoole rate limiter table [{$name}]."); + } + + return new TableState($name, $table, new StripedLock); + } +} diff --git a/src/rate-limiter/src/Swoole/TableState.php b/src/rate-limiter/src/Swoole/TableState.php new file mode 100644 index 000000000..80d2dcfcb --- /dev/null +++ b/src/rate-limiter/src/Swoole/TableState.php @@ -0,0 +1,49 @@ +name; + } + + /** + * Get the shared Swoole table. + */ + public function table(): Table + { + return $this->table; + } + + /** + * Run the callback while holding the lock for a physical limiter key. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function withLock(string $key, callable $callback): mixed + { + return $this->locks->withLock($key, $callback); + } +} diff --git a/src/rate-limiter/src/SwooleStore.php b/src/rate-limiter/src/SwooleStore.php new file mode 100644 index 000000000..a16df8630 --- /dev/null +++ b/src/rate-limiter/src/SwooleStore.php @@ -0,0 +1,260 @@ += 1) { + throw new InvalidArgumentException( + 'The Swoole rate limiter memory limit buffer must be greater than zero and less than one.' + ); + } + } + + /** + * Atomically consume capacity from an admission policy. + */ + public function consume(string $key, AdmissionPolicy $policy): LimitResult + { + for ($attempt = 0; $attempt < 2; ++$attempt) { + /** @var array{LimitResult, bool} $outcome */ + $outcome = $this->state->withLock($key, function () use ($key, $policy): array { + [$value, $availableAt, $expiresAt] = $this->storedState($key); + + $result = $this->calculateConsume( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + + if ($result->denied()) { + return [$result, true]; + } + + return [ + $result, + $this->writeState($key, $value, $availableAt, $expiresAt), + ]; + }); + + [$result, $stored] = $outcome; + + if ($stored) { + return $result; + } + + if ($attempt === 0) { + $this->pruneExpiredRows(); + } + } + + throw new SwooleTableFullException( + "Swoole rate limiter table [{$this->state->name()}] cannot allocate a new entry after pruning expired state." + ); + } + + /** + * Inspect a policy without mutating its state. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + return $this->state->withLock($key, function () use ($key, $policy): LimitResult|BackoffResult { + [$value, $availableAt, $expiresAt] = $this->storedState($key); + + return $this->calculateInspection( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + }); + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + for ($attempt = 0; $attempt < 2; ++$attempt) { + /** @var array{BackoffResult, bool} $outcome */ + $outcome = $this->state->withLock($key, function () use ($key, $backoff): array { + [$value, $availableAt, $expiresAt] = $this->storedState($key); + + $result = $this->calculateFailure( + $backoff, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + + return [ + $result, + $this->writeState($key, $value, $availableAt, $expiresAt), + ]; + }); + + [$result, $stored] = $outcome; + + if ($stored) { + return $result; + } + + if ($attempt === 0) { + $this->pruneExpiredRows(); + } + } + + throw new SwooleTableFullException( + "Swoole rate limiter table [{$this->state->name()}] cannot allocate a new entry after pruning expired state." + ); + } + + /** + * Clear the state for a physical limiter key. + */ + public function clear(string $key): bool + { + return $this->state->withLock( + $key, + fn (): bool => $this->state->table()->del($key), + ); + } + + /** + * Prune every expired entry. + */ + public function pruneExpiredRows(): int + { + $now = $this->currentTimeInMicroseconds(); + $expiredKeys = []; + $pruned = 0; + + foreach ($this->state->table() as $key => $row) { + $expiresAt = $row['expires_at'] ?? null; + + if (! is_int($expiresAt) || $expiresAt <= 0 || $expiresAt > $now) { + continue; + } + + $expiredKeys[] = (string) $key; + } + + // Deleting during Swoole's positional collision-chain iteration skips rows. + foreach ($expiredKeys as $key) { + $pruned += $this->state->withLock($key, function () use ($key, $now): int { + $row = $this->state->table()->get($key); + $expiresAt = $row === false ? null : ($row['expires_at'] ?? null); + + if (! is_int($expiresAt) || $expiresAt <= 0 || $expiresAt > $now) { + return 0; + } + + return $this->state->table()->del($key) ? 1 : 0; + }); + } + + return $pruned; + } + + /** + * Prune expired entries and report table pressure. + */ + public function maintain(): int + { + $pruned = $this->pruneExpiredRows(); + $this->reportTablePressure(); + + return $pruned; + } + + /** + * Get and validate numeric state for a physical limiter key. + * + * @return array{int, int, int} + */ + protected function storedState(string $key): array + { + $row = $this->state->table()->get($key); + + if ($row === false) { + return [0, 0, 0]; + } + + $value = $row['value'] ?? null; + $availableAt = $row['available_at'] ?? null; + $expiresAt = $row['expires_at'] ?? null; + + if (! is_int($value) || ! is_int($availableAt) || ! is_int($expiresAt) + || $value < 0 || $availableAt < 0 || $expiresAt < 0 + || $value > AdmissionPolicy::MAX_INTEGER + || $availableAt > AdmissionPolicy::MAX_INTEGER + || $expiresAt > AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException('The stored Swoole rate limiter state is invalid.'); + } + + return [$value, $availableAt, $expiresAt]; + } + + /** + * Write numeric state for a physical limiter key. + */ + protected function writeState(string $key, int $value, int $availableAt, int $expiresAt): bool + { + return $this->state->table()->set($key, [ + 'value' => $value, + 'available_at' => $availableAt, + 'expires_at' => $expiresAt, + ]); + } + + /** + * Report exhausted table headroom after periodic pruning. + */ + protected function reportTablePressure(): void + { + $table = $this->state->table(); + $stats = $table->stats(); + $conflictRate = 1 - ((int) $stats['available_slice_num'] / (int) $stats['total_slice_num']); + $fillRate = (int) $stats['num'] / $table->getSize(); + $threshold = 1 - $this->memoryLimitBuffer; + + if ($conflictRate <= $threshold && $fillRate <= $threshold) { + return; + } + + $this->logger->warning( + "Swoole rate limiter table [{$this->state->name()}] is nearing capacity.", + [ + 'conflict_rate' => $conflictRate, + 'fill_rate' => $fillRate, + 'threshold' => $threshold, + ], + ); + } +} diff --git a/src/rate-limiter/src/Unlimited.php b/src/rate-limiter/src/Unlimited.php new file mode 100644 index 000000000..f8f78e1cc --- /dev/null +++ b/src/rate-limiter/src/Unlimited.php @@ -0,0 +1,23 @@ + + */ + protected array $states = []; + + /** + * Atomically consume capacity from an admission policy. + */ + public function consume(string $key, AdmissionPolicy $policy): LimitResult + { + [$value, $availableAt, $expiresAt] = $this->state($key); + + $result = $this->calculateConsume( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + + if ($result->allowed()) { + $this->states[$key] = [ + 'value' => $value, + 'available_at' => $availableAt, + 'expires_at' => $expiresAt, + ]; + } + + return $result; + } + + /** + * Inspect a policy without mutating its state. + * + * @return ($policy is Backoff ? BackoffResult : LimitResult) + */ + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + [$value, $availableAt, $expiresAt] = $this->state($key); + + return $this->calculateInspection( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + [$value, $availableAt, $expiresAt] = $this->state($key); + + $result = $this->calculateFailure( + $backoff, + $this->currentTimeInMicroseconds(), + $value, + $availableAt, + $expiresAt, + ); + + $this->states[$key] = [ + 'value' => $value, + 'available_at' => $availableAt, + 'expires_at' => $expiresAt, + ]; + + return $result; + } + + /** + * Clear the state for a physical limiter key. + */ + public function clear(string $key): bool + { + if (! array_key_exists($key, $this->states)) { + return false; + } + + unset($this->states[$key]); + + return true; + } + + /** + * Get the numeric state for a physical key. + * + * @return array{int, int, int} + */ + protected function state(string $key): array + { + $state = $this->states[$key] ?? null; + + return $state === null + ? [0, 0, 0] + : [$state['value'], $state['available_at'], $state['expires_at']]; + } +} diff --git a/src/support/src/DefaultProviders.php b/src/support/src/DefaultProviders.php index b564681ba..1615c0d43 100644 --- a/src/support/src/DefaultProviders.php +++ b/src/support/src/DefaultProviders.php @@ -53,6 +53,7 @@ public function __construct(?array $providers = null) \Hypervel\Pagination\PaginationServiceProvider::class, \Hypervel\Pipeline\PipelineServiceProvider::class, \Hypervel\Queue\QueueServiceProvider::class, + \Hypervel\RateLimiter\RateLimiterServiceProvider::class, \Hypervel\Redis\RedisServiceProvider::class, \Hypervel\Server\ServerServiceProvider::class, \Hypervel\ServerProcess\ServerProcessServiceProvider::class, diff --git a/tests/Foundation/Fixtures/config/rate-limiter.php b/tests/Foundation/Fixtures/config/rate-limiter.php new file mode 100644 index 000000000..d24e93b3f --- /dev/null +++ b/tests/Foundation/Fixtures/config/rate-limiter.php @@ -0,0 +1,19 @@ + 'rate-limiter', + + 'default' => 'overwrite', + + 'stores' => [ + 'database' => [ + 'overwrite' => true, + ], + + 'new' => [ + 'merge' => true, + ], + ], +]; diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index aa699faed..ba258cad8 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -734,6 +734,12 @@ public function testMergingConfig(): void $this->assertSame(['merge' => true], $config->get('queue.connections.new')); $this->assertSame(['table' => 'custom_batches'], $config->get('queue.batching')); $this->assertSame(['driver' => 'file'], $config->get('queue.failed')); + + $this->assertSame('overwrite', $config->get('rate-limiter.default')); + $this->assertSame('rate-limiter', $config->get('rate-limiter.custom_option')); + $this->assertIsArray($config->get('rate-limiter.stores.redis')); + $this->assertSame(['overwrite' => true], $config->get('rate-limiter.stores.database')); + $this->assertSame(['merge' => true], $config->get('rate-limiter.stores.new')); } protected function assertExpectationCount(int $times): void diff --git a/tests/Integration/Generators/RateLimiterTableCommandTest.php b/tests/Integration/Generators/RateLimiterTableCommandTest.php new file mode 100644 index 000000000..65a6951ea --- /dev/null +++ b/tests/Integration/Generators/RateLimiterTableCommandTest.php @@ -0,0 +1,45 @@ +artisan(RateLimiterTableCommand::class)->assertExitCode(0); + + $this->assertMigrationFileContains([ + 'use Hypervel\Database\Migrations\Migration;', + 'return new class extends Migration', + "Schema::create('rate_limits', function (Blueprint \$table) {", + "\$table->char('key', 32)->primary();", + "\$table->unsignedBigInteger('value')->default(0);", + "\$table->unsignedBigInteger('available_at')->default(0);", + "\$table->unsignedBigInteger('expires_at')->index();", + "Schema::dropIfExists('rate_limits');", + ], 'create_rate_limits_table.php'); + } finally { + Date::setTestNow(); + } + } + + public function testCreateUsesTheConfiguredTableName(): void + { + config(['rate-limiter.stores.database.table' => 'custom_rate_limits']); + + $this->artisan(RateLimiterTableCommand::class)->assertExitCode(0); + + $this->assertMigrationFileContains([ + "Schema::create('custom_rate_limits', function (Blueprint \$table) {", + "Schema::dropIfExists('custom_rate_limits');", + ], 'create_custom_rate_limits_table.php'); + } +} diff --git a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php new file mode 100644 index 000000000..a535a1a11 --- /dev/null +++ b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php @@ -0,0 +1,289 @@ +make('config'); + $connection = $config->string('database.default'); + + $config->set("database.connections.{$connection}.pool.testing_enabled", true); + $config->set("database.connections.{$connection}.pool.max_connections", 10); + $config->set("database.connections.{$connection}.pool.heartbeat", -1); + } + + public function testFixedWindowOperationsUseNumericDatabaseState(): void + { + $store = $this->store(); + $key = str_repeat('a', 32); + $policy = Limit::perMinute(2); + + $first = $store->consume($key, $policy); + $second = $store->consume($key, $policy); + $denied = $store->consume($key, $policy); + $row = DB::table('rate_limits')->where('key', $key)->first(); + + $this->assertTrue($first->allowed()); + $this->assertSame(1, $first->remaining()); + $this->assertTrue($second->allowed()); + $this->assertSame(0, $second->remaining()); + $this->assertTrue($denied->denied()); + $this->assertSame(0, $denied->remaining()); + $this->assertSame(2, (int) $row->value); + $this->assertSame((int) $row->available_at, (int) $row->expires_at); + } + + public function testInspectingMissingStateDoesNotCreateARow(): void + { + $store = $this->store(); + $key = str_repeat('b', 32); + + $result = $store->inspect($key, Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + $this->assertSame(0, $result->resetAfter()); + $this->assertFalse(DB::table('rate_limits')->where('key', $key)->exists()); + } + + public function testLeakyBucketAndBackoffUseTheSharedCalculator(): void + { + $store = $this->store(); + $bucketKey = str_repeat('c', 32); + $bucket = LeakyBucket::perMinute(60)->burst(1); + + $this->assertTrue($store->consume($bucketKey, $bucket)->allowed()); + $this->assertTrue($store->consume($bucketKey, $bucket)->denied()); + + $backoffKey = str_repeat('d', 32); + $backoff = Backoff::exponential( + after: 2, + initialDelay: 1, + maxDelay: 4, + resetAfter: 10, + ); + + $this->assertTrue($store->recordFailure($backoffKey, $backoff)->allowed()); + $this->assertTrue($store->recordFailure($backoffKey, $backoff)->denied()); + $this->assertTrue($store->inspect($backoffKey, $backoff)->denied()); + } + + public function testClearDeletesOnlyTheRequestedState(): void + { + $store = $this->store(); + $firstKey = str_repeat('e', 32); + $secondKey = str_repeat('f', 32); + $policy = Limit::perMinute(1); + $store->consume($firstKey, $policy); + $store->consume($secondKey, $policy); + + $this->assertTrue($store->clear($firstKey)); + $this->assertFalse($store->clear($firstKey)); + $this->assertFalse(DB::table('rate_limits')->where('key', $firstKey)->exists()); + $this->assertTrue(DB::table('rate_limits')->where('key', $secondKey)->exists()); + } + + public function testConfiguredTableUsesTheConnectionPrefix(): void + { + DB::setTablePrefix('limiter_'); + + try { + Schema::create('custom_rate_limits', function (Blueprint $table): void { + $table->char('key', 32)->primary(); + $table->unsignedBigInteger('value')->default(0); + $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('expires_at')->index(); + }); + + $store = new DatabaseStore( + $this->app->make(ConnectionResolverInterface::class), + null, + 'custom_rate_limits', + ); + $key = str_repeat('p', 32); + + $this->assertTrue($store->consume($key, Limit::perMinute(1))->allowed()); + $this->assertSame(1, (int) DB::table('custom_rate_limits')->where('key', $key)->value('value')); + } finally { + Schema::dropIfExists('custom_rate_limits'); + DB::setTablePrefix(''); + } + } + + public function testPrunesExpiredStateInBoundedBatches(): void + { + $store = $this->store(); + + foreach (range(1, 5) as $index) { + DB::table('rate_limits')->insert([ + 'key' => str_pad("expired{$index}", 32, 'x'), + 'value' => 1, + 'available_at' => 1, + 'expires_at' => 1, + ]); + } + + DB::table('rate_limits')->insert([ + 'key' => str_repeat('l', 32), + 'value' => 1, + 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER, + ]); + + $this->assertSame(5, $store->pruneExpired(2)); + $this->assertSame([str_repeat('l', 32)], DB::table('rate_limits')->pluck('key')->all()); + } + + public function testPruningDoesNotDeleteStateRenewedAfterSelection(): void + { + $key = str_repeat('r', 32); + DB::table('rate_limits')->insert([ + 'key' => $key, + 'value' => 1, + 'available_at' => 1, + 'expires_at' => 1, + ]); + $renewed = false; + + DB::listen(function (QueryExecuted $event) use ($key, &$renewed): void { + $sql = strtolower($event->sql); + + if ($renewed || ! str_contains($sql, 'rate_limits') || ! str_contains($sql, 'select')) { + return; + } + + $renewed = true; + + DB::table('rate_limits')->where('key', $key)->update([ + 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER, + ]); + }); + + $this->assertSame(0, $this->store()->pruneExpired(1)); + $this->assertTrue($renewed); + $this->assertSame( + AdmissionPolicy::MAX_INTEGER, + (int) DB::table('rate_limits')->where('key', $key)->value('expires_at'), + ); + } + + public function testCorruptStateFailsClosed(): void + { + $key = str_repeat('g', 32); + DB::table('rate_limits')->insert([ + 'key' => $key, + 'value' => 1, + 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER - 1, + ]); + + $this->expectException(UnexpectedValueException::class); + + $this->store()->consume($key, Limit::perMinute(10)); + } + + public function testConcurrentFirstUseAdmitsExactlyTheConfiguredCapacity(): void + { + $store = $this->store(); + $key = str_repeat('h', 32); + $policy = Limit::perMinute(5); + $operations = []; + + for ($index = 0; $index < 10; ++$index) { + $operations[] = static fn (): bool => $store->consume($key, $policy)->allowed(); + } + + $results = parallel($operations); + + $this->assertSame(5, count(array_filter($results))); + $this->assertSame(5, (int) DB::table('rate_limits')->where('key', $key)->value('value')); + } + + public function testConcurrentExistingStateDoesNotLoseUpdates(): void + { + $store = $this->store(); + $key = str_repeat('i', 32); + $policy = Limit::perMinute(5); + $store->consume($key, $policy); + $operations = []; + + for ($index = 0; $index < 8; ++$index) { + $operations[] = static fn (): bool => $store->consume($key, $policy)->allowed(); + } + + $results = parallel($operations); + + $this->assertSame(4, count(array_filter($results))); + $this->assertSame(5, (int) DB::table('rate_limits')->where('key', $key)->value('value')); + } + + #[DataProvider('invalidPruneChunkSizes')] + public function testRejectsInvalidPruneChunkSizes(int $chunkSize): void + { + $this->expectException(InvalidArgumentException::class); + + $this->store()->pruneExpired($chunkSize); + } + + /** + * @return array + */ + public static function invalidPruneChunkSizes(): array + { + return [ + 'zero' => [0], + 'negative' => [-1], + 'above maximum' => [10_001], + ]; + } + + /** + * Create a database rate limiter store for the configured integration connection. + */ + protected function store(): DatabaseStore + { + return new DatabaseStore( + $this->app->make(ConnectionResolverInterface::class), + null, + 'rate_limits', + ); + } + + protected function rateLimiterStoreContract(): Limiter + { + return new Limiter( + $this->store(), + new KeyResolver('database-contract', static fn (): ?string => null), + ); + } +} diff --git a/tests/Integration/RateLimiter/Database/MariaDb/DatabaseStoreTest.php b/tests/Integration/RateLimiter/Database/MariaDb/DatabaseStoreTest.php new file mode 100644 index 000000000..bb41aafe4 --- /dev/null +++ b/tests/Integration/RateLimiter/Database/MariaDb/DatabaseStoreTest.php @@ -0,0 +1,15 @@ +deleteDirectory(static::$databaseDirectory); + $filesystem->ensureDirectoryExists(static::$databaseDirectory); + + static::$databasePath = static::$databaseDirectory . '/database.sqlite'; + touch(static::$databasePath); + } + + public static function tearDownAfterClass(): void + { + (new Filesystem)->deleteDirectory(static::$databaseDirectory); + + parent::tearDownAfterClass(); + } + + // @TODO Remove these overrides when the first tagged Swoole release containing + // https://github.com/swoole/swoole-src/pull/6140 is the minimum supported version. + public function testConcurrentFirstUseAdmitsExactlyTheConfiguredCapacity(): void + { + $this->markTestSkipped('Requires the Swoole AIO scheduler fix from PR #6140.'); + } + + public function testConcurrentExistingStateDoesNotLoseUpdates(): void + { + $this->markTestSkipped('Requires the Swoole AIO scheduler fix from PR #6140.'); + } + + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->make('config'); + $connection = $config->string('database.default'); + + // This worker-scoped path intentionally supersedes Testbench's earlier + // parallel-database rewrite and must exist before its database probe. + $config->set("database.connections.{$connection}.database", static::$databasePath); + } +} diff --git a/tests/Integration/RateLimiter/RedisStoreTest.php b/tests/Integration/RateLimiter/RedisStoreTest.php new file mode 100644 index 000000000..17713b33f --- /dev/null +++ b/tests/Integration/RateLimiter/RedisStoreTest.php @@ -0,0 +1,262 @@ +make('config'); + + $config->set('rate-limiter.default', 'redis'); + $config->set('rate-limiter.prefix', 'integration'); + $config->set('database.redis.default.options.prefix', 'rate-limiter-test:'); + } + + public function testFixedWindowIsAtomicAndRetainsItsOriginalTtl(): void + { + $limiter = $this->limiter(); + $policy = Limit::perSecond(3, 10)->by('fixed'); + + $this->assertSame(2, $limiter->consume($policy)->remaining()); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + $before = $redis->pttl($physicalKey); + + $this->assertSame(1, $limiter->consume($policy)->remaining()); + $after = $redis->pttl($physicalKey); + + $this->assertGreaterThan(9000, $before); + $this->assertGreaterThan($before - 500, $after); + + $denied = $limiter->consume($policy->cost(2)); + $this->assertTrue($denied->denied()); + $this->assertSame(1, $denied->remaining()); + $this->assertSame('2', $redis->get($physicalKey)); + } + + public function testInspectingMissingStateDoesNotCreateAKey(): void + { + $policy = Limit::perMinute(10)->by('inspect'); + $result = $this->limiter()->inspect($policy); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + $this->assertSame(0, $result->resetAfter()); + $this->assertSame(0, $this->redisClient()->exists($this->physicalKey($policy))); + } + + public function testLeakyBucketUsesRedisTimeAndRecoversCapacity(): void + { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); + $policy = LeakyBucket::perSecond(2)->by('leaky'); + $limiter = $this->limiter(); + + $this->assertSame(1, $limiter->consume($policy)->remaining()); + $this->assertSame(0, $limiter->consume($policy)->remaining()); + $this->assertTrue($limiter->consume($policy)->denied()); + + $storedTat = $this->redisClient()->get($this->physicalKey($policy)); + + $this->assertIsString($storedTat); + $this->assertGreaterThan(1_700_000_000_000_000, (int) $storedTat); + } + + public function testExponentialBackoffUsesOneRedisStateEntry(): void + { + $backoff = Backoff::exponential( + after: 2, + initialDelay: 1, + maxDelay: 4, + resetAfter: 10, + )->by('backoff'); + $limiter = $this->limiter(); + + $this->assertTrue($limiter->recordFailure($backoff)->allowed()); + + $blocked = $limiter->recordFailure($backoff); + $this->assertTrue($blocked->denied()); + $this->assertSame(2, $blocked->failures()); + $this->assertSame(1, $blocked->retryAfter()); + $this->assertTrue($limiter->inspect($backoff)->denied()); + $this->assertTrue($limiter->clear($backoff)); + $this->assertTrue($limiter->inspect($backoff)->allowed()); + } + + public function testLeadingZeroAndMissingExpiryStateFailClosed(): void + { + $policy = Limit::perMinute(10)->by('corrupt'); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + + $redis->set($physicalKey, '010', ['px' => 60_000]); + + try { + $this->limiter()->consume($policy); + $this->fail('Expected corrupt leading-zero state to fail.'); + } catch (LuaScriptException) { + $this->addToAssertionCount(1); + } + + $redis->set($physicalKey, '1'); + + $this->expectException(LuaScriptException::class); + + $this->limiter()->consume($policy); + } + + public function testPresentBackoffStateWithZeroFailuresFailsClosed(): void + { + $backoff = Backoff::exponential()->by('corrupt-backoff'); + $physicalKey = $this->physicalKey($backoff); + $redis = $this->redisClient(); + + $redis->hMSet($physicalKey, [ + 'failures' => '0', + 'available_at' => '0', + ]); + $redis->pExpire($physicalKey, 60_000); + + $this->expectException(LuaScriptException::class); + + $this->limiter()->inspect($backoff); + } + + public function testMaximumExactIntegerSurvivesSetAndIncrementArguments(): void + { + $policy = Limit::perSecond(AdmissionPolicy::MAX_INTEGER)->by('maximum'); + $limiter = $this->limiter(); + + $first = $limiter->consume($policy->cost(AdmissionPolicy::MAX_INTEGER - 1)); + $second = $limiter->consume($policy); + + $this->assertSame(1, $first->remaining()); + $this->assertSame(0, $second->remaining()); + $this->assertSame( + (string) AdmissionPolicy::MAX_INTEGER, + $this->redisClient()->get($this->physicalKey($policy)), + ); + } + + public function testConfiguredRedisPrefixIsAppliedExactlyOnce(): void + { + $policy = Limit::perMinute(1)->by('prefix'); + $this->limiter()->consume($policy); + + $physicalKey = $this->physicalKey($policy); + $redis = $this->rawRedisClientWithoutPrefix(); + + $this->assertSame(1, $redis->exists('rate-limiter-test:' . $physicalKey)); + $this->assertSame(0, $redis->exists('rate-limiter-test:rate-limiter-test:' . $physicalKey)); + } + + public function testConcurrentClientsNeverAdmitBeyondCapacity(): void + { + $limiter = $this->limiter(); + $policy = Limit::perMinute(10)->by('concurrent'); + + $results = parallel(array_fill(0, 50, static fn () => $limiter->consume($policy)->allowed())); + + $this->assertSame(10, count(array_filter($results))); + $this->assertSame(0, $limiter->inspect($policy)->remaining()); + } + + public function testConcurrentWeightedClientsNeverAdmitBeyondCapacity(): void + { + $limiter = $this->limiter(); + $policies = [ + Limit::perMinute(20)->cost(3)->by('concurrent-weighted-fixed'), + LeakyBucket::perMinute(1)->burst(20)->cost(3)->by('concurrent-weighted-leaky'), + ]; + + foreach ($policies as $policy) { + $results = parallel(array_fill(0, 50, static fn () => $limiter->consume($policy)->allowed())); + + $this->assertSame(6, count(array_filter($results))); + $this->assertSame(2, $limiter->inspect($policy)->remaining()); + } + } + + public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): void + { + $connection = $this->createRedisConnectionWithOptions('rate_limiter_encoded', [ + 'prefix' => 'rate-limiter-encoded:', + 'serializer' => PhpRedis::SERIALIZER_PHP, + 'compression' => PhpRedis::COMPRESSION_LZF, + ]); + config([ + 'rate-limiter.stores.encoded' => [ + 'driver' => 'redis', + 'connection' => $connection, + ], + ]); + + $limiter = $this->app->make(RateLimiter::class)->store('encoded'); + $fixed = Limit::perMinute(2)->by('encoded-fixed'); + $leaky = LeakyBucket::perMinute(1)->burst(1)->by('encoded-leaky'); + $backoff = Backoff::exponential( + after: 1, + initialDelay: 1, + maxDelay: 2, + resetAfter: 5, + )->by('encoded-backoff'); + + $this->assertSame(1, $limiter->consume($fixed)->remaining()); + $this->assertTrue($limiter->consume($leaky)->allowed()); + $this->assertTrue($limiter->consume($leaky)->denied()); + $this->assertSame(1, $limiter->recordFailure($backoff)->failures()); + + $redis = $this->rawRedisClientWithoutPrefix($connection); + + try { + $this->assertSame('1', $redis->get('rate-limiter-encoded:' . $this->physicalKey($fixed))); + $this->assertMatchesRegularExpression( + '/^[1-9][0-9]*$/D', + (string) $redis->get('rate-limiter-encoded:' . $this->physicalKey($leaky)), + ); + $this->assertSame( + '1', + $redis->hGet('rate-limiter-encoded:' . $this->physicalKey($backoff), 'failures'), + ); + } finally { + $redis->close(); + } + } + + private function limiter(): Limiter + { + return $this->app->make(RateLimiter::class)->store('redis'); + } + + protected function rateLimiterStoreContract(): Limiter + { + return $this->limiter(); + } + + private function physicalKey(AdmissionPolicy|Backoff $policy): string + { + return (new KeyResolver('integration', static fn (): ?string => null))->resolve($policy); + } +} diff --git a/tests/RateLimiter/BackoffTest.php b/tests/RateLimiter/BackoffTest.php new file mode 100644 index 000000000..1dd23a78e --- /dev/null +++ b/tests/RateLimiter/BackoffTest.php @@ -0,0 +1,53 @@ +by(123); + + $this->assertSame(5, $original->after); + $this->assertSame(2, $original->initialDelay); + $this->assertSame(120, $original->maxDelay); + $this->assertSame(600, $original->resetAfter); + $this->assertSame('', $original->key); + $this->assertNotSame($original, $modified); + $this->assertSame('123', $modified->key); + } + + public function testInvalidSettingsAreRejected(): void + { + foreach ([ + static fn () => Backoff::exponential(after: 0), + static fn () => Backoff::exponential(initialDelay: 0), + static fn () => Backoff::exponential(initialDelay: 10, maxDelay: 5), + static fn () => Backoff::exponential(maxDelay: 10, resetAfter: 9), + static fn () => Backoff::exponential( + maxDelay: intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) + 1, + resetAfter: intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) + 1, + ), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/RateLimiter/DatabaseStoreTest.php b/tests/RateLimiter/DatabaseStoreTest.php new file mode 100644 index 000000000..c404b2529 --- /dev/null +++ b/tests/RateLimiter/DatabaseStoreTest.php @@ -0,0 +1,321 @@ +shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('table')->once()->with('custom_rate_limits')->andReturn($query); + $query->shouldReceive('useWritePdo')->once()->andReturnSelf(); + $query->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $query->shouldReceive('first')->once()->andReturnNull(); + $connection->shouldReceive('getDriverName')->once()->andReturn('pgsql'); + $connection->shouldReceive('scalar') + ->once() + ->with( + 'SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint', + [], + false, + ) + ->andReturn('1000000'); + + $result = (new DatabaseStore($connections, 'limiter', 'custom_rate_limits')) + ->inspect('physical-key', Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + } + + public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithoutInserting(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $locked = m::mock(Builder::class); + $update = m::mock(Builder::class); + $operations = []; + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->with(m::type(Closure::class), 3) + ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); + $connection->shouldReceive('getDriverName') + ->twice() + ->andReturnUsing(static function () use (&$operations): string { + $operations[] = 'driver'; + + return 'mysql'; + }); + $connection->shouldReceive('table') + ->twice() + ->with('custom_rate_limits') + ->andReturn($locked, $update); + $locked->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $locked->shouldReceive('lockForUpdate')->once()->andReturnSelf(); + $locked->shouldReceive('first')->once()->andReturnUsing(static function () use (&$operations): object { + $operations[] = 'lock'; + + return (object) [ + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]; + }); + $connection->shouldReceive('scalar') + ->once() + ->with( + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)', + [], + false, + ) + ->andReturnUsing(static function () use (&$operations): string { + $operations[] = 'clock'; + + return '1000000'; + }); + $update->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $update->shouldReceive('update') + ->once() + ->with([ + 'value' => 1, + 'available_at' => 61_000_000, + 'expires_at' => 61_000_000, + ]) + ->andReturnUsing(static function () use (&$operations): int { + $operations[] = 'update'; + + return 1; + }); + + $result = (new DatabaseStore($connections, 'limiter', 'custom_rate_limits')) + ->consume('physical-key', Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(9, $result->remaining()); + $this->assertSame(['driver', 'lock', 'driver', 'clock', 'update'], $operations); + } + + public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLocks(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $missing = m::mock(Builder::class); + $insert = m::mock(Builder::class); + $locked = m::mock(Builder::class); + $update = m::mock(Builder::class); + $operations = []; + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->with(m::type(Closure::class), 3) + ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); + $connection->shouldReceive('getDriverName') + ->twice() + ->andReturnUsing(static function () use (&$operations): string { + $operations[] = 'driver'; + + return 'pgsql'; + }); + $connection->shouldReceive('table') + ->times(4) + ->with('custom_rate_limits') + ->andReturn($missing, $insert, $locked, $update); + $missing->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $missing->shouldReceive('lockForUpdate')->once()->andReturnSelf(); + $missing->shouldReceive('first')->once()->andReturnUsing(static function () use (&$operations): null { + $operations[] = 'missing-lock'; + + return null; + }); + $insert->shouldReceive('insertOrIgnore') + ->once() + ->with([ + 'key' => 'physical-key', + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]) + ->andReturnUsing(static function () use (&$operations): int { + $operations[] = 'insert'; + + return 1; + }); + $locked->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $locked->shouldReceive('lockForUpdate')->once()->andReturnSelf(); + $locked->shouldReceive('first')->once()->andReturnUsing(static function () use (&$operations): object { + $operations[] = 'final-lock'; + + return (object) [ + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]; + }); + $connection->shouldReceive('scalar') + ->once() + ->with( + 'SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint', + [], + false, + ) + ->andReturnUsing(static function () use (&$operations): string { + $operations[] = 'clock'; + + return '1000000'; + }); + $update->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $update->shouldReceive('update') + ->once() + ->with([ + 'value' => 1, + 'available_at' => 61_000_000, + 'expires_at' => 61_000_000, + ]) + ->andReturnUsing(static function () use (&$operations): int { + $operations[] = 'update'; + + return 1; + }); + + $result = (new DatabaseStore($connections, 'limiter', 'custom_rate_limits')) + ->consume('physical-key', Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(9, $result->remaining()); + $this->assertSame( + ['driver', 'missing-lock', 'insert', 'final-lock', 'driver', 'clock', 'update'], + $operations, + ); + } + + public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $insert = m::mock(Builder::class); + $locked = m::mock(Builder::class); + $update = m::mock(Builder::class); + $operations = []; + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->with(m::type(Closure::class), 3) + ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); + $connection->shouldReceive('getDriverName') + ->twice() + ->andReturnUsing(static function () use (&$operations): string { + $operations[] = 'driver'; + + return 'sqlite'; + }); + $connection->shouldReceive('table') + ->times(3) + ->with('custom_rate_limits') + ->andReturn($insert, $locked, $update); + $insert->shouldReceive('insertOrIgnore') + ->once() + ->with([ + 'key' => 'physical-key', + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]) + ->andReturnUsing(static function () use (&$operations): int { + $operations[] = 'insert'; + + return 1; + }); + $locked->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $locked->shouldReceive('lockForUpdate')->once()->andReturnSelf(); + $locked->shouldReceive('first')->once()->andReturnUsing(static function () use (&$operations): object { + $operations[] = 'lock'; + + return (object) [ + 'value' => 0, + 'available_at' => 0, + 'expires_at' => 0, + ]; + }); + $update->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $update->shouldReceive('update') + ->once() + ->with(m::on(static function (array $state) use (&$operations): bool { + $operations[] = 'update'; + + return $state['value'] === 1 + && $state['available_at'] > 60_000_000 + && $state['expires_at'] === $state['available_at']; + })) + ->andReturn(1); + + $result = (new DatabaseStore($connections, 'limiter', 'custom_rate_limits')) + ->consume('physical-key', Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(9, $result->remaining()); + $this->assertSame(['driver', 'insert', 'lock', 'driver', 'update'], $operations); + } + + #[DataProvider('mutatingOperations')] + public function testMutatingOperationsRejectAnActiveTransactionBeforeLimiterSql(string $operation): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldNotReceive('transaction'); + $connection->shouldNotReceive('table'); + $connection->shouldNotReceive('getDriverName'); + $connection->shouldNotReceive('scalar'); + + $store = new DatabaseStore($connections, 'limiter', 'custom_rate_limits'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Database rate limiter mutations cannot run inside an active transaction on the selected connection.' + ); + + match ($operation) { + 'consume' => $store->consume('physical-key', Limit::perMinute(10)), + 'recordFailure' => $store->recordFailure('physical-key', Backoff::exponential()), + 'clear' => $store->clear('physical-key'), + 'pruneExpired' => $store->pruneExpired(), + }; + } + + public static function mutatingOperations(): array + { + return [ + 'consume' => ['consume'], + 'record failure' => ['recordFailure'], + 'clear' => ['clear'], + 'prune expired' => ['pruneExpired'], + ]; + } +} diff --git a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php new file mode 100644 index 000000000..3c22738ec --- /dev/null +++ b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php @@ -0,0 +1,197 @@ +rateLimiterStoreContract(); + $key = $this->rateLimiterStoreContractKey('fixed'); + $policy = Limit::perSecond(5, 5)->cost(2)->by($key); + + $missing = $limiter->inspect($policy); + $this->assertTrue($missing->allowed()); + $this->assertSame(5, $missing->limit()); + $this->assertSame(5, $missing->remaining()); + $this->assertSame(0, $missing->retryAfter()); + $this->assertSame(0, $missing->resetAfter()); + + $accepted = $limiter->consume($policy); + $this->assertTrue($accepted->allowed()); + $this->assertSame(3, $accepted->remaining()); + $this->assertGreaterThan(0, $accepted->resetAfter()); + $this->assertLessThanOrEqual(5, $accepted->resetAfter()); + + $denied = $limiter->consume($policy->cost(4)); + $this->assertTrue($denied->denied()); + $this->assertSame(3, $denied->remaining()); + $this->assertGreaterThan(0, $denied->retryAfter()); + + $inspection = $limiter->inspect($policy); + $this->assertTrue($inspection->allowed()); + $this->assertSame(3, $inspection->remaining()); + + $exact = $limiter->consume($policy->cost(3)); + $this->assertTrue($exact->allowed()); + $this->assertSame(0, $exact->remaining()); + + $secondDenial = $limiter->consume($policy->cost(1)); + $this->assertTrue($secondDenial->denied()); + $this->assertSame(0, $secondDenial->remaining()); + $this->assertLessThanOrEqual($denied->retryAfter(), $secondDenial->retryAfter()); + + $this->assertFalse($limiter->clear(Limit::perSecond(6, 5)->by($key))); + $this->assertTrue($limiter->clear($policy)); + $this->assertSame(5, $limiter->inspect($policy)->remaining()); + } + + public function testStoreContractFixedWindowExpiresWithoutBeingExtendedByDenials(): void + { + $limiter = $this->rateLimiterStoreContract(); + $policy = Limit::perSecond(1)->by($this->rateLimiterStoreContractKey('fixed-expiry')); + + $this->assertTrue($limiter->consume($policy)->allowed()); + $this->assertTrue($limiter->consume($policy)->denied()); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($policy)->resetAfter() === 0, + 1, + ); + + $expired = $limiter->inspect($policy); + $this->assertTrue($expired->allowed()); + $this->assertSame(1, $expired->remaining()); + } + + public function testStoreContractLeakyBucketRecoversWithoutMutatingDenials(): void + { + $limiter = $this->rateLimiterStoreContract(); + $policy = LeakyBucket::perSecond(2) + ->burst(3) + ->by($this->rateLimiterStoreContractKey('leaky')); + + $accepted = $limiter->consume($policy->cost(2)); + $this->assertTrue($accepted->allowed()); + $this->assertSame(1, $accepted->remaining()); + + $denied = $limiter->consume($policy->cost(2)); + $this->assertTrue($denied->denied()); + $this->assertSame(1, $denied->remaining()); + $this->assertGreaterThan(0, $denied->retryAfter()); + + $inspection = $limiter->inspect($policy); + $this->assertTrue($inspection->allowed()); + $this->assertSame(1, $inspection->remaining()); + + $this->assertTrue($limiter->consume($policy)->allowed()); + $this->assertSame(0, $limiter->inspect($policy)->remaining()); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($policy)->remaining() === 3, + 2, + ); + + $full = $limiter->inspect($policy); + $this->assertTrue($full->allowed()); + $this->assertSame(3, $full->remaining()); + $this->assertSame(0, $full->resetAfter()); + } + + public function testStoreContractBackoffThresholdDoublingCapClearAndInactivityReset(): void + { + $limiter = $this->rateLimiterStoreContract(); + $backoff = Backoff::exponential( + after: 2, + initialDelay: 1, + maxDelay: 2, + resetAfter: 2, + )->by($this->rateLimiterStoreContractKey('backoff')); + + $missing = $limiter->inspect($backoff); + $this->assertTrue($missing->allowed()); + $this->assertSame(0, $missing->failures()); + + $first = $limiter->recordFailure($backoff); + $this->assertTrue($first->allowed()); + $this->assertSame(1, $first->failures()); + + $threshold = $limiter->recordFailure($backoff); + $this->assertTrue($threshold->denied()); + $this->assertSame(2, $threshold->failures()); + $this->assertSame(1, $threshold->retryAfter()); + + $doubled = $limiter->recordFailure($backoff); + $this->assertSame(3, $doubled->failures()); + $this->assertSame(2, $doubled->retryAfter()); + + $capped = $limiter->recordFailure($backoff); + $this->assertSame(4, $capped->failures()); + $this->assertSame(2, $capped->retryAfter()); + + $this->assertTrue($limiter->clear($backoff)); + $this->assertSame(0, $limiter->inspect($backoff)->failures()); + + $inactivity = $backoff->by($this->rateLimiterStoreContractKey('backoff-inactivity')); + $this->assertSame(1, $limiter->recordFailure($inactivity)->failures()); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($inactivity)->failures() === 0, + 2, + ); + + $this->assertTrue($limiter->inspect($inactivity)->allowed()); + } + + abstract protected function rateLimiterStoreContract(): Limiter; + + /** + * Advance a deterministic store clock and return whether time was advanced. + */ + protected function advanceRateLimiterStoreContractClock(int $seconds): bool + { + return false; + } + + /** + * Wait until a backend-time condition becomes true. + */ + protected function waitForRateLimiterStoreContract(Closure $condition, int $seconds): void + { + if ($this->advanceRateLimiterStoreContractClock($seconds)) { + $this->assertTrue($condition()); + + return; + } + + $deadline = microtime(true) + $seconds + 2; + + do { + if ($condition()) { + $this->addToAssertionCount(1); + + return; + } + + usleep(20_000); + } while (microtime(true) < $deadline); + + $this->fail('The rate limiter state did not expire before the test deadline.'); + } + + /** + * Create a unique logical key for a contract case. + */ + protected function rateLimiterStoreContractKey(string $suffix): string + { + return static::class . ':' . $suffix; + } +} diff --git a/tests/RateLimiter/InitializeSwooleTablesTest.php b/tests/RateLimiter/InitializeSwooleTablesTest.php new file mode 100644 index 000000000..81d1e267f --- /dev/null +++ b/tests/RateLimiter/InitializeSwooleTablesTest.php @@ -0,0 +1,35 @@ + [ + 'stores' => [ + 'first' => ['driver' => 'swoole'], + 'redis' => ['driver' => 'redis'], + 'second' => ['driver' => 'swoole'], + ], + ], + ]); + $tables = m::mock(TableManager::class); + $tables->shouldReceive('get')->once()->with('first')->andReturn(m::mock(TableState::class)); + $tables->shouldReceive('get')->once()->with('second')->andReturn(m::mock(TableState::class)); + $tables->shouldReceive('seal')->once(); + + (new InitializeSwooleTables($config, $tables))->handle(new BeforeServerStart('server')); + } +} diff --git a/tests/RateLimiter/KeyResolverTest.php b/tests/RateLimiter/KeyResolverTest.php new file mode 100644 index 000000000..f97a28268 --- /dev/null +++ b/tests/RateLimiter/KeyResolverTest.php @@ -0,0 +1,129 @@ + 'tenant:7'); + + $this->assertSame( + 'd91bec8237651325e9d9bc0c89d9119b', + $resolver->resolve(Limit::perMinute(60)->by('user:1'), 'api'), + ); + $this->assertSame( + '72519c9daf2298e61f4ce018cacde4ac', + $resolver->resolve(LeakyBucket::perSecond(100)->burst(200)->by('user:1'), 'api'), + ); + $this->assertSame( + 'c80de4e32af3dae58519b7d54ceb3c12', + $resolver->resolve(Backoff::exponential( + after: 5, + initialDelay: 1, + maxDelay: 300, + resetAfter: 3600, + )->by('login')), + ); + } + + // REMOVED: Laravel's fallback-key collision handling is replaced by + // parameter-sensitive canonical policy identities. + + public function testIdentityIncludesEveryStableDomain(): void + { + $resolver = new KeyResolver('app', static fn (string $name): ?string => 'tenant:7'); + $policy = Limit::perMinute(60)->by('user:1'); + $key = $resolver->resolve($policy, 'api'); + + $this->assertNotSame($key, (new KeyResolver('other', static fn (): ?string => 'tenant:7'))->resolve($policy, 'api')); + $this->assertNotSame($key, $resolver->resolve($policy, 'web')); + $this->assertNotSame($key, (new KeyResolver('app', static fn (): ?string => 'tenant:8'))->resolve($policy, 'api')); + $this->assertNotSame($key, $resolver->resolve($policy->by('user:2'), 'api')); + $this->assertNotSame($key, $resolver->resolve(Limit::perMinute(61)->by('user:1'), 'api')); + $this->assertNotSame($key, $resolver->resolve(LeakyBucket::perMinute(60)->by('user:1'), 'api')); + $this->assertNotSame($key, $resolver->resolve($policy->globally(), 'api')); + } + + public function testRequestCostAndCallbacksDoNotChangeIdentity(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + $policy = Limit::perMinute(60)->by('user:1'); + $key = $resolver->resolve($policy); + + $this->assertSame($key, $resolver->resolve($policy->cost(5))); + $this->assertSame($key, $resolver->resolve($policy->after(static fn (): bool => true))); + $this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited'))); + } + + public function testEquivalentCallerKeysNormalizeToTheSameIdentity(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + $stringable = new class implements Stringable { + public function __toString(): string + { + return '1'; + } + }; + + $keys = [ + $resolver->resolve(Limit::perMinute(1)->by(1)), + $resolver->resolve(Limit::perMinute(1)->by('1')), + $resolver->resolve(Limit::perMinute(1)->by($stringable)), + $resolver->resolve(Limit::perMinute(1)->by(KeyResolverKey::One)), + ]; + + $this->assertCount(1, array_unique($keys)); + $this->assertSame( + $resolver->resolve(Limit::perMinute(1)->by(null)), + $resolver->resolve(Limit::perMinute(1)->by('')), + ); + } + + public function testArbitrarySegmentsCannotCreateAmbiguousIdentities(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + + $this->assertNotSame( + $resolver->resolve(Limit::perMinute(1)->by('bc'), 'a'), + $resolver->resolve(Limit::perMinute(1)->by('c'), 'ab'), + ); + $this->assertNotSame( + $resolver->resolve(Limit::perMinute(1)->by('1:key'), 'limiter'), + $resolver->resolve(Limit::perMinute(1)->by('key'), 'limiter1:'), + ); + } + + public function testDirectAndGlobalPoliciesDoNotInvokeTheNamedScopeResolver(): void + { + $calls = 0; + $resolver = new KeyResolver('app', static function () use (&$calls): ?string { + ++$calls; + + return 'scope'; + }); + + $resolver->resolve(Limit::perMinute(1)); + $resolver->resolve(Limit::perMinute(1)->globally(), 'api'); + + $this->assertSame(0, $calls); + + $resolver->resolve(Limit::perMinute(1), 'api'); + + $this->assertSame(1, $calls); + } +} diff --git a/tests/RateLimiter/LeakyBucketTest.php b/tests/RateLimiter/LeakyBucketTest.php new file mode 100644 index 000000000..1be97b9fc --- /dev/null +++ b/tests/RateLimiter/LeakyBucketTest.php @@ -0,0 +1,94 @@ +assertPolicy(LeakyBucket::perSecond(2, 3), 2, 3_000_000, 2); + $this->assertPolicy(LeakyBucket::perMinute(4, 5), 4, 300_000_000, 4); + $this->assertPolicy(LeakyBucket::perMinutes(6, 7), 7, 360_000_000, 7); + $this->assertPolicy(LeakyBucket::perHour(8, 2), 8, 7_200_000_000, 8); + $this->assertPolicy(LeakyBucket::perDay(10, 2), 10, 172_800_000_000, 10); + } + + public function testBurstAndCostMayBeConfiguredInEitherOrder(): void + { + $costThenBurst = LeakyBucket::perSecond(100)->cost(150)->burst(200); + $burstThenCost = LeakyBucket::perSecond(100)->burst(200)->cost(150); + + $this->assertSame(150, $costThenBurst->cost); + $this->assertSame(200, $costThenBurst->burst); + $this->assertSame(150, $burstThenCost->cost); + $this->assertSame(200, $burstThenCost->burst); + } + + public function testBurstPreservesSharedPolicyValues(): void + { + $after = static fn (): bool => true; + $response = static fn (): string => 'limited'; + $original = LeakyBucket::perSecond(100) + ->by('api') + ->cost(2) + ->globally() + ->after($after) + ->response($response); + + $modified = $original->burst(200); + + $this->assertNotSame($original, $modified); + $this->assertSame(100, $original->burst); + $this->assertSame(200, $modified->burst); + $this->assertSame('api', $modified->key); + $this->assertSame(2, $modified->cost); + $this->assertTrue($modified->global); + $this->assertSame($after, $modified->afterCallback); + $this->assertSame($response, $modified->responseCallback); + $this->assertSame(100, $modified->rate); + $this->assertSame(1_000_000, $modified->periodMicroseconds); + } + + public function testStrictSmoothingUsesABurstOfOne(): void + { + $policy = LeakyBucket::perSecond(100)->burst(1); + + $this->assertSame(100, $policy->rate); + $this->assertSame(1, $policy->burst); + } + + public function testInvalidScalarValuesAreRejected(): void + { + foreach ([ + static fn () => LeakyBucket::perSecond(0), + static fn () => LeakyBucket::perSecond(1, 0), + static fn () => LeakyBucket::perSecond(1)->burst(0), + static fn () => new LeakyBucket(2, 1, 2), + static fn () => new LeakyBucket(1, 2, LeakyBucket::MAX_INTEGER), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + } + + private function assertPolicy( + LeakyBucket $limit, + int $rate, + int $periodMicroseconds, + int $burst, + ): void { + $this->assertSame($rate, $limit->rate); + $this->assertSame($periodMicroseconds, $limit->periodMicroseconds); + $this->assertSame($burst, $limit->burst); + } +} diff --git a/tests/RateLimiter/LimitTest.php b/tests/RateLimiter/LimitTest.php new file mode 100644 index 000000000..9ee6ac001 --- /dev/null +++ b/tests/RateLimiter/LimitTest.php @@ -0,0 +1,77 @@ +assertPolicy(Limit::perSecond(2, 3), 2, 3); + $this->assertPolicy(Limit::perMinute(4, 5), 4, 300); + $this->assertPolicy(Limit::perMinutes(6, 7), 7, 360); + $this->assertPolicy(Limit::perHour(8, 9), 8, 32400); + $this->assertPolicy(Limit::perDay(10, 11), 10, 950400); + $this->assertInstanceOf(Unlimited::class, Limit::none()); + } + + // REMOVED: Laravel's GlobalLimit constructor coverage is replaced by the + // immutable AdmissionPolicy::globally() modifier coverage below. + + public function testFluentModifiersReturnImmutableCopies(): void + { + $after = static fn (): bool => true; + $response = static fn (): string => 'limited'; + $original = Limit::perMinute(10); + $modified = $original + ->by(123) + ->cost(3) + ->globally() + ->after($after) + ->response($response); + + $this->assertNotSame($original, $modified); + $this->assertSame('', $original->key); + $this->assertSame(1, $original->cost); + $this->assertFalse($original->global); + $this->assertNull($original->afterCallback); + $this->assertNull($original->responseCallback); + + $this->assertSame('123', $modified->key); + $this->assertSame(3, $modified->cost); + $this->assertTrue($modified->global); + $this->assertSame($after, $modified->afterCallback); + $this->assertSame($response, $modified->responseCallback); + $this->assertSame(10, $modified->maxAttempts); + $this->assertSame(60, $modified->decaySeconds); + } + + public function testInvalidScalarValuesAreRejected(): void + { + foreach ([ + static fn () => Limit::perMinute(0), + static fn () => Limit::perSecond(1, 0), + static fn () => Limit::perMinute(1)->cost(0), + static fn () => Limit::perSecond(1, intdiv(Limit::MAX_INTEGER, 1_000_000) + 1), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + } + + private function assertPolicy(Limit $limit, int $attempts, int $seconds): void + { + $this->assertSame($attempts, $limit->maxAttempts); + $this->assertSame($seconds, $limit->decaySeconds); + } +} diff --git a/tests/RateLimiter/LimiterTest.php b/tests/RateLimiter/LimiterTest.php new file mode 100644 index 000000000..66f1adfeb --- /dev/null +++ b/tests/RateLimiter/LimiterTest.php @@ -0,0 +1,194 @@ + null)); + $policy = Limit::none(); + + $this->assertTrue($limiter->consume($policy)->allowed()); + $this->assertTrue($limiter->inspect($policy)->allowed()); + $this->assertTrue($limiter->clear($policy)); + $this->assertSame('executed', $limiter->attempt($policy, static fn (): string => 'executed')); + $this->assertSame(0, $store->calls); + } + + public function testCrossFieldValidationHappensBeforeKeyOrStoreAccess(): void + { + $store = new LimiterCountingStore; + $scopeCalls = 0; + $limiter = new Limiter($store, new KeyResolver('app', static function () use (&$scopeCalls): ?string { + ++$scopeCalls; + + return 'scope'; + })); + + foreach ([ + Limit::perMinute(1)->cost(2), + LeakyBucket::perSecond(1)->cost(2), + ] as $policy) { + try { + $limiter->consume($policy, 'api'); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + + $this->assertSame(0, $scopeCalls); + $this->assertSame(0, $store->calls); + } + + public function testTimeRangeValidationHappensBeforeKeyOrStoreAccess(): void + { + $store = new LimiterCountingStore; + $scopeCalls = 0; + $limiter = new Limiter($store, new KeyResolver('app', static function () use (&$scopeCalls): ?string { + ++$scopeCalls; + + return 'scope'; + })); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC( + intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) - 30, + )); + + try { + $limiter->consume(Limit::perMinute(1), 'api'); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } finally { + CarbonImmutable::setTestNow(); + } + + $this->assertSame(0, $scopeCalls); + $this->assertSame(0, $store->calls); + } + + public function testUnsupportedAdmissionPoliciesFailBeforeKeyOrStoreAccess(): void + { + $store = new LimiterCountingStore; + $scopeCalls = 0; + $limiter = new Limiter($store, new KeyResolver('app', static function () use (&$scopeCalls): ?string { + ++$scopeCalls; + + return 'scope'; + })); + + try { + $limiter->consume(new UnsupportedAdmissionPolicy, 'api'); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + + $this->assertSame(0, $scopeCalls); + $this->assertSame(0, $store->calls); + } + + // REMOVED: Laravel's primitive counter and fallback-key tests are replaced + // by atomic policy decisions and canonical identity coverage. + + // REMOVED: Laravel's callback-before-hit attempt() coverage is replaced by + // one atomic consume before the callback. + + public function testAttemptConsumesBeforeInvokingTheCallback(): void + { + $limiter = new Limiter( + new WorkerArrayStore, + new KeyResolver('app', static fn (): ?string => null), + ); + $policy = Limit::perMinute(1)->by('attempt'); + + $this->assertTrue($limiter->attempt($policy, static fn (): null => null)); + $this->assertFalse($limiter->attempt($policy, static fn (): string => 'not executed')); + } + + public function testAttemptRetainsTheChargeWhenTheCallbackThrows(): void + { + $limiter = new Limiter( + new WorkerArrayStore, + new KeyResolver('app', static fn (): ?string => null), + ); + $policy = Limit::perMinute(1)->by('exception'); + + try { + $limiter->attempt($policy, static fn (): never => throw new RuntimeException('failed')); + $this->fail('Expected callback exception.'); + } catch (RuntimeException $exception) { + $this->assertSame('failed', $exception->getMessage()); + } + + $this->assertTrue($limiter->inspect($policy)->denied()); + } +} + +class LimiterCountingStore implements Store +{ + public int $calls = 0; + + public function consume(string $key, AdmissionPolicy $policy): LimitResult + { + ++$this->calls; + + return new LimitResult(true, 1, 0, 0, 1_000_000); + } + + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + ++$this->calls; + + return $policy instanceof Backoff + ? new BackoffResult(true, 0, 0) + : new LimitResult(true, 1, 1, 0, 0); + } + + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + ++$this->calls; + + return new BackoffResult(true, 1, 0); + } + + public function clear(string $key): bool + { + ++$this->calls; + + return true; + } +} + +readonly class UnsupportedAdmissionPolicy extends AdmissionPolicy +{ + protected function newInstance( + string $key, + int $cost, + bool $global, + ?Closure $afterCallback, + ?Closure $responseCallback, + ): static { + return new static($key, $cost, $global, $afterCallback, $responseCallback); + } +} diff --git a/tests/RateLimiter/PackageMetadataTest.php b/tests/RateLimiter/PackageMetadataTest.php new file mode 100644 index 000000000..54a6d8772 --- /dev/null +++ b/tests/RateLimiter/PackageMetadataTest.php @@ -0,0 +1,67 @@ +assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + } + + /** + * Ensure standalone installations discover the package provider. + * + * @throws JsonException + */ + public function testServiceProviderIsDiscoverable(): void + { + $composer = json_decode( + file_get_contents(__DIR__ . '/../../src/rate-limiter/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR, + ); + + $this->assertSame( + [RateLimiterServiceProvider::class], + $composer['extra']['hypervel']['providers'], + ); + } +} diff --git a/tests/RateLimiter/PruneCommandTest.php b/tests/RateLimiter/PruneCommandTest.php new file mode 100644 index 000000000..1485e2861 --- /dev/null +++ b/tests/RateLimiter/PruneCommandTest.php @@ -0,0 +1,54 @@ + [ + 'driver' => 'prunable', + ], + ]); + $store = new PrunableWorkerArrayStore; + $this->app->make(RateLimiter::class)->extend( + 'prunable', + static fn (): PrunableWorkerArrayStore => $store, + ); + + $this->artisan('rate-limiter:prune', [ + 'store' => 'prunable', + '--chunk' => 17, + ])->expectsOutputToContain('Pruned 7 expired rate limiter entries.') + ->assertSuccessful(); + + $this->assertSame(17, $store->chunkSize); + } + + public function testRejectsAStoreThatDoesNotSupportPruning(): void + { + $this->artisan('rate-limiter:prune', ['store' => 'worker-array']) + ->expectsOutputToContain('Rate limiter store [worker-array] does not support pruning.') + ->assertExitCode(1); + } +} + +class PrunableWorkerArrayStore extends WorkerArrayStore implements PrunableStore +{ + public int $chunkSize = 0; + + public function pruneExpired(int $chunkSize = 1000): int + { + $this->chunkSize = $chunkSize; + + return 7; + } +} diff --git a/tests/RateLimiter/RateLimiterServiceProviderTest.php b/tests/RateLimiter/RateLimiterServiceProviderTest.php new file mode 100644 index 000000000..eeb9c7640 --- /dev/null +++ b/tests/RateLimiter/RateLimiterServiceProviderTest.php @@ -0,0 +1,55 @@ +assertContains( + RateLimiterServiceProvider::class, + (new DefaultProviders)->toArray(), + ); + } + + public function testLifecycleListenersAreResolvedAtEventTime(): void + { + $events = m::mock(Dispatcher::class); + $listeners = []; + $events->shouldReceive('listen') + ->twice() + ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { + $listeners[$event] = $listener; + }); + $tables = m::mock(InitializeSwooleTables::class); + $timers = m::mock(RegisterPruneTimer::class); + $application = m::mock(Application::class); + $application->shouldReceive('make')->once()->with('events')->andReturn($events); + $application->shouldReceive('make')->once()->with(InitializeSwooleTables::class)->andReturn($tables); + $application->shouldReceive('make')->once()->with(RegisterPruneTimer::class)->andReturn($timers); + $beforeServerStart = new BeforeServerStart('server'); + $server = m::mock(SwooleServer::class); + $afterWorkerStart = new AfterWorkerStart($server, 0); + $tables->shouldReceive('handle')->once()->with($beforeServerStart); + $timers->shouldReceive('handle')->once()->with($afterWorkerStart); + + (new RateLimiterServiceProvider($application))->boot(); + + $listeners[BeforeServerStart::class]($beforeServerStart); + $listeners[AfterWorkerStart::class]($afterWorkerStart); + } +} diff --git a/tests/RateLimiter/RateLimiterTest.php b/tests/RateLimiter/RateLimiterTest.php new file mode 100644 index 000000000..175a5731c --- /dev/null +++ b/tests/RateLimiter/RateLimiterTest.php @@ -0,0 +1,191 @@ +assertSame( + $this->app->make(RateLimiter::class), + RateLimiterFacade::getFacadeRoot(), + ); + } + + public function testManagerResolvesAndCachesWrappedStores(): void + { + $manager = $this->app->make(RateLimiter::class); + + $first = $manager->store(); + $second = $manager->store(LimiterStore::Local); + + $this->assertInstanceOf(Limiter::class, $first); + $this->assertSame($first, $second); + $this->assertInstanceOf(WorkerArrayStore::class, $first->getStore()); + + $manager->purge('worker-array'); + + $this->assertNotSame($first, $manager->store()); + } + + public function testManagerForgetsResolvedStores(): void + { + $manager = $this->app->make(RateLimiter::class); + $resolved = $manager->store('worker-array'); + + $this->assertSame($manager, $manager->forgetInstance('worker-array')); + $this->assertNotSame($resolved, $manager->store('worker-array')); + } + + public function testNamedLimiterStoresAreRegisteredAndNormalized(): void + { + $manager = $this->app->make(RateLimiter::class); + $callback = static fn (): Limit => Limit::perMinute(10); + + $result = $manager->for(NamedLimiter::Api, $callback, LimiterStore::Local); + + $this->assertSame($manager, $result); + $this->assertSame($callback, $manager->limiter('api')); + $this->assertSame('worker-array', $manager->limiterStore(NamedLimiter::Api)); + $this->assertNull($manager->limiter('missing')); + $this->assertNull($manager->limiterStore('missing')); + } + + public function testScopeResolverRegisteredAfterStoreResolutionAffectsNamedOperations(): void + { + $manager = $this->app->make(RateLimiter::class); + $store = $manager->store('worker-array'); + $policy = Limit::perMinute(1)->by('user'); + + $this->assertTrue($store->consume($policy, 'api')->allowed()); + $this->assertTrue($store->inspect($policy, 'api')->denied()); + + $manager->resolveKeyScopeUsing(static fn (string $name): string => 'tenant:' . $name); + + $this->assertTrue($store->consume($policy, 'api')->allowed()); + $this->assertTrue($store->inspect($policy, 'api')->denied()); + } + + public function testCustomDriverReturnsAStoreThatIsWrappedOnce(): void + { + config([ + 'rate-limiter.stores.custom' => [ + 'driver' => 'custom', + 'name' => 'spoofed', + ], + ]); + + $manager = $this->app->make(RateLimiter::class); + $created = 0; + $test = $this; + + $manager->extend('custom', function ($app, array $config) use (&$created, $test): WorkerArrayStore { + ++$created; + + $test->assertSame('custom', $config['name']); + + return new WorkerArrayStore; + }); + + $first = $manager->store('custom'); + $second = $manager->store('custom'); + + $this->assertSame($first, $second); + $this->assertSame(1, $created); + } + + public function testCustomDriversMustReturnAStore(): void + { + config([ + 'rate-limiter.stores.invalid' => [ + 'driver' => 'invalid', + ], + ]); + + $manager = $this->app->make(RateLimiter::class); + $manager->extend('invalid', static fn (): stdClass => new stdClass); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must return an instance of [Hypervel\RateLimiter\Contracts\Store]'); + + $manager->store('invalid'); + } + + #[DataProvider('invalidStoreConfigurations')] + public function testInvalidStoreConfigurationsFailWhenResolved( + array $config, + string $exception, + string $message, + ): void { + config(['rate-limiter.stores.invalid' => $config]); + + $this->expectException($exception); + $this->expectExceptionMessage($message); + + $this->app->make(RateLimiter::class)->store('invalid'); + } + + public static function invalidStoreConfigurations(): array + { + return [ + 'missing driver' => [ + [], + RuntimeException::class, + 'does not specify a driver', + ], + 'invalid database connection' => [ + ['driver' => 'database', 'connection' => false, 'table' => 'rate_limits'], + InvalidArgumentException::class, + 'database connection must be null or a non-empty string', + ], + 'invalid database table' => [ + ['driver' => 'database', 'connection' => null, 'table' => ''], + InvalidArgumentException::class, + 'database table must be a non-empty string', + ], + 'invalid Redis connection' => [ + ['driver' => 'redis', 'connection' => null], + InvalidArgumentException::class, + 'Redis connection must be a non-empty string', + ], + 'invalid Swoole memory buffer' => [ + ['driver' => 'swoole', 'memory_limit_buffer' => '0.05'], + InvalidArgumentException::class, + 'memory limit buffer must be numeric', + ], + ]; + } + + public function testDefaultStoreMayBeChangedAtBootTime(): void + { + $manager = $this->app->make(RateLimiter::class); + + $manager->setDefaultInstance('worker-array'); + + $this->assertSame('worker-array', $manager->getDefaultInstance()); + $this->assertSame($manager->store('worker-array'), $manager->store()); + } +} diff --git a/tests/RateLimiter/RedisStoreTest.php b/tests/RateLimiter/RedisStoreTest.php new file mode 100644 index 000000000..74fd013e1 --- /dev/null +++ b/tests/RateLimiter/RedisStoreTest.php @@ -0,0 +1,127 @@ +store([1, 10, 7, 0, 60_000_000], $captured); + + $result = $store->consume('physical-key', Limit::perMinute(10)->cost(3)); + + $this->assertTrue($result->allowed()); + $this->assertSame(7, $result->remaining()); + $this->assertSame(['physical-key'], $captured['keys']); + $this->assertSame(['consume', '3', '10', '60000'], $captured['arguments']); + $this->assertStringContainsString("redis.call('INCRBY', KEYS[1], ARGV[2])", $captured['script']); + $this->assertStringNotContainsString('KEEPTTL', $captured['script']); + } + + public function testLeakyBucketUsesOneKeyAndMicrosecondArguments(): void + { + $captured = []; + $store = $this->store([0, 20, 5, 250_000, 1_000_000], $captured); + $policy = LeakyBucket::perSecond(10)->burst(20)->cost(3); + + $result = $store->inspect('physical-key', $policy); + + $this->assertTrue($result->denied()); + $this->assertSame(5, $result->remaining()); + $this->assertSame(['physical-key'], $captured['keys']); + $this->assertSame(['inspect', '3', '10', '1000000', '20'], $captured['arguments']); + $this->assertStringContainsString("redis.call('TIME')", $captured['script']); + } + + public function testBackoffReturnsItsFailureCountAndDelay(): void + { + $captured = []; + $store = $this->store([0, 5, 0, 8_000_000, 0], $captured); + $backoff = Backoff::exponential( + after: 3, + initialDelay: 2, + maxDelay: 8, + resetAfter: 20, + ); + + $result = $store->recordFailure('physical-key', $backoff); + + $this->assertTrue($result->denied()); + $this->assertSame(5, $result->failures()); + $this->assertSame(8, $result->retryAfter()); + $this->assertSame(['failure', '3', '2000000', '8000000', '20000000'], $captured['arguments']); + } + + public function testMalformedTuplesFailExplicitly(): void + { + foreach ([ + false, + null, + [1, 10, 9], + [2, 10, 9, 0, 1], + [1, 10, -1, 0, 1], + ['1', 10, 9, 0, 1], + [1, 11, 9, 0, 1], + ] as $response) { + try { + $captured = []; + $this->store($response, $captured)->consume('key', Limit::perMinute(10)); + $this->fail('Expected an invalid Redis result exception.'); + } catch (UnexpectedValueException) { + $this->addToAssertionCount(1); + } + } + } + + public function testClearDeletesThePhysicalKeyOnTheConfiguredConnection(): void + { + $redis = m::mock(RedisFactory::class); + $proxy = m::mock(RedisProxy::class); + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('connection')->once()->with('limiter')->andReturn($proxy); + $proxy->shouldReceive('withConnection') + ->once() + ->with(m::type('callable'), false) + ->andReturnUsing(static fn (callable $callback): mixed => $callback($connection)); + $connection->shouldReceive('del')->once()->with('physical-key')->andReturn(1); + + $this->assertTrue((new RedisStore($redis, 'limiter'))->clear('physical-key')); + } + + private function store(mixed $response, array &$captured): RedisStore + { + $redis = m::mock(RedisFactory::class); + $proxy = m::mock(RedisProxy::class); + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('connection')->once()->with('limiter')->andReturn($proxy); + $proxy->shouldReceive('withConnection') + ->once() + ->with(m::type('callable'), false) + ->andReturnUsing(static fn (callable $callback): mixed => $callback($connection)); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturnUsing(static function (string $script, array $keys, array $arguments) use ($response, &$captured): mixed { + $captured = compact('script', 'keys', 'arguments'); + + return $response; + }); + + return new RedisStore($redis, 'limiter'); + } +} diff --git a/tests/RateLimiter/RegisterPruneTimerTest.php b/tests/RateLimiter/RegisterPruneTimerTest.php new file mode 100644 index 000000000..d78f45686 --- /dev/null +++ b/tests/RateLimiter/RegisterPruneTimerTest.php @@ -0,0 +1,220 @@ +shouldReceive('maintain')->once()->andReturn(2); + $secondStore = m::mock(SwooleStore::class); + $secondStore->shouldReceive('maintain')->once()->andReturn(3); + $rateLimiter = m::mock(RateLimiter::class); + $rateLimiter->shouldReceive('store')->once()->with('first')->andReturn($this->limiter($firstStore)); + $rateLimiter->shouldReceive('store')->once()->with('second')->andReturn($this->limiter($secondStore)); + $timer = new FakeRateLimiterTimer; + + (new RegisterPruneTimer($this->config([ + 'first' => ['driver' => 'swoole', 'prune_interval' => 5], + 'redis' => ['driver' => 'redis'], + 'second' => ['driver' => 'swoole', 'prune_interval' => 30], + ]), $rateLimiter, $timer))->handle($this->workerEvent(workerId: 0)); + + $this->assertSame([5.0, 30.0], array_column($timer->ticks, 'seconds')); + $this->assertSame(2, $timer->ticks[0]['callback']()); + $this->assertSame(3, $timer->ticks[1]['callback']()); + } + + public function testDoesNotRegisterATimerOnOtherWorkers(): void + { + $timer = new FakeRateLimiterTimer; + + (new RegisterPruneTimer($this->config([]), m::mock(RateLimiter::class), $timer)) + ->handle($this->workerEvent(workerId: 1)); + + $this->assertSame([], $timer->ticks); + } + + public function testDoesNotRegisterATimerOnTaskWorkers(): void + { + $timer = new FakeRateLimiterTimer; + + (new RegisterPruneTimer($this->config([]), m::mock(RateLimiter::class), $timer)) + ->handle($this->workerEvent(workerId: 0, taskworker: true)); + + $this->assertSame([], $timer->ticks); + } + + public function testRollsBackEarlierTimersWhenRegistrationFails(): void + { + $firstStore = m::mock(SwooleStore::class); + $secondStore = m::mock(SwooleStore::class); + $rateLimiter = m::mock(RateLimiter::class); + $rateLimiter->shouldReceive('store')->once()->with('first')->andReturn($this->limiter($firstStore)); + $rateLimiter->shouldReceive('store')->once()->with('second')->andReturn($this->limiter($secondStore)); + $failure = new RuntimeException('Timer registration failed.'); + $timer = new FakeRateLimiterTimer([41, $failure]); + + try { + (new RegisterPruneTimer($this->config([ + 'first' => ['driver' => 'swoole', 'prune_interval' => 5], + 'second' => ['driver' => 'swoole', 'prune_interval' => 30], + ]), $rateLimiter, $timer))->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected timer registration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([41], $timer->cleared); + } + + /** + * @param array $intervalConfig + */ + #[DataProvider('invalidIntervals')] + public function testRejectsInvalidIntervalsBeforeTimerRegistration(array $intervalConfig): void + { + $timer = new FakeRateLimiterTimer; + + try { + (new RegisterPruneTimer($this->config([ + 'invalid' => ['driver' => 'swoole', ...$intervalConfig], + ]), m::mock(RateLimiter::class), $timer))->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected invalid timer configuration to fail.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString( + 'rate-limiter.stores.invalid.prune_interval', + $exception->getMessage(), + ); + } + + $this->assertSame([], $timer->ticks); + } + + /** + * @return array}> + */ + public static function invalidIntervals(): array + { + return [ + 'missing' => [[]], + 'wrong type' => [['prune_interval' => '60']], + 'zero' => [['prune_interval' => 0]], + 'negative' => [['prune_interval' => -1]], + ]; + } + + public function testRejectsAnInvalidLaterStoreBeforeRegisteringEarlierTimers(): void + { + $rateLimiter = m::mock(RateLimiter::class); + $rateLimiter->shouldNotReceive('store'); + $timer = new FakeRateLimiterTimer; + + try { + (new RegisterPruneTimer($this->config([ + 'first' => ['driver' => 'swoole', 'prune_interval' => 5], + 'second' => ['driver' => 'swoole', 'prune_interval' => 0], + ]), $rateLimiter, $timer))->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected invalid timer configuration to fail.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString( + 'rate-limiter.stores.second.prune_interval', + $exception->getMessage(), + ); + } + + $this->assertSame([], $timer->ticks); + } + + /** + * @param array> $stores + */ + private function config(array $stores): Repository + { + return new Repository([ + 'rate-limiter' => [ + 'stores' => $stores, + ], + ]); + } + + private function limiter(SwooleStore $store): Limiter + { + $limiter = m::mock(Limiter::class); + $limiter->shouldReceive('getStore')->once()->andReturn($store); + + return $limiter; + } + + private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart + { + $server = m::mock(SwooleServer::class); + $server->taskworker = $taskworker; + + return new AfterWorkerStart($server, $workerId); + } +} + +class FakeRateLimiterTimer extends Timer +{ + /** @var list */ + public array $ticks = []; + + /** @var list */ + public array $cleared = []; + + /** + * @param list $results + */ + public function __construct(protected array $results = []) + { + parent::__construct(); + } + + public function tick( + float $seconds, + callable $callback, + string $identifier = Constants::WORKER_EXIT, + ): int { + $this->ticks[] = compact('seconds', 'callback', 'identifier'); + + if ($this->results !== []) { + $result = array_shift($this->results); + + if ($result instanceof Throwable) { + throw $result; + } + + return $result; + } + + return count($this->ticks); + } + + public function clear(int $timerId): void + { + $this->cleared[] = $timerId; + } +} diff --git a/tests/RateLimiter/ResultTest.php b/tests/RateLimiter/ResultTest.php new file mode 100644 index 000000000..8175fe3e9 --- /dev/null +++ b/tests/RateLimiter/ResultTest.php @@ -0,0 +1,55 @@ +assertFalse($result->allowed()); + $this->assertTrue($result->denied()); + $this->assertSame(10, $result->limit()); + $this->assertSame(3, $result->remaining()); + $this->assertSame(2, $result->retryAfter()); + $this->assertSame(3, $result->resetAfter()); + } + + public function testBackoffResultExposesRoundedPublicDelay(): void + { + $result = new BackoffResult(false, 7, 1_000_001); + + $this->assertFalse($result->allowed()); + $this->assertTrue($result->denied()); + $this->assertSame(7, $result->failures()); + $this->assertSame(2, $result->retryAfter()); + } + + public function testInvalidResultInvariantsAreRejected(): void + { + foreach ([ + static fn () => new LimitResult(true, 0, 0, 0, 0), + static fn () => new LimitResult(true, 1, 2, 0, 0), + static fn () => new LimitResult(true, 1, 0, 1, 0), + static fn () => new LimitResult(false, 1, 0, 0, 0), + static fn () => new BackoffResult(true, -1, 0), + static fn () => new BackoffResult(true, 0, 1), + static fn () => new BackoffResult(false, 0, 0), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid result exception.'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/RateLimiter/SwooleStoreConcurrencyTest.php b/tests/RateLimiter/SwooleStoreConcurrencyTest.php new file mode 100644 index 000000000..a6b33f4b6 --- /dev/null +++ b/tests/RateLimiter/SwooleStoreConcurrencyTest.php @@ -0,0 +1,298 @@ +state(); + $policy = Limit::perMinute(50); + $processCount = 8; + $attemptsPerProcess = 25; + $ready = new Atomic(0); + $start = new Atomic(0); + $completed = new Atomic(0); + $allowed = new Atomic(0); + $processes = []; + $pids = []; + $deadline = hrtime(true) + 5_000_000_000; + + try { + for ($processIndex = 0; $processIndex < $processCount; ++$processIndex) { + $process = new Process(function (Process $process) use ( + $state, + $policy, + $attemptsPerProcess, + $ready, + $start, + $completed, + $allowed, + ): void { + $ready->add(1); + + try { + while ($start->get() === 0) { + usleep(100); + } + + $store = $this->store($state); + + for ($attempt = 0; $attempt < $attemptsPerProcess; ++$attempt) { + if ($store->consume('workers', $policy)->allowed()) { + $allowed->add(1); + } + } + + $payload = ['ok' => true]; + } catch (Throwable $throwable) { + $payload = [ + 'ok' => false, + 'class' => $throwable::class, + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]; + } finally { + try { + $this->writeChildPayload($process, $payload ?? [ + 'ok' => false, + 'class' => RuntimeException::class, + 'message' => 'Child exited without producing a result.', + 'trace' => '', + ]); + } finally { + $completed->add(1); + + // Avoid PHPUnit/Testbench shutdown handlers inherited from the parent. + posix_kill(getmypid(), SIGKILL); + } + } + }, false, SOCK_STREAM); + + $pid = $process->start(); + + if ($pid === false) { + throw new RuntimeException('Unable to start a Swoole rate limiter concurrency child.'); + } + + $processes[$pid] = $process; + $pids[] = $pid; + } + + $this->waitForChildren($ready, $processCount, $deadline, 'start'); + $start->set(1); + $this->waitForChildren($completed, $processCount, $deadline, 'finish'); + + foreach ($processes as $pid => $process) { + $payload = $this->readChildPayload($process, $pid, $deadline); + + if (($payload['ok'] ?? false) !== true) { + throw new RuntimeException(sprintf( + "Swoole rate limiter concurrency child [%d] failed: %s: %s\n%s", + $pid, + $payload['class'] ?? 'unknown exception', + $payload['message'] ?? 'unknown error', + $payload['trace'] ?? '', + )); + } + } + + $store = $this->store($state); + + $this->assertSame(50, $allowed->get()); + $this->assertSame(0, $store->inspect('workers', $policy)->remaining()); + } finally { + $start->set(1); + $this->cleanupProcesses($processes, $pids); + } + } + + /** + * Wait until every child reaches a synchronization point. + */ + private function waitForChildren( + Atomic $counter, + int $expected, + int $deadline, + string $stage, + ): void { + while ($counter->get() < $expected) { + if (hrtime(true) >= $deadline) { + throw new RuntimeException("Timed out waiting for Swoole rate limiter children to {$stage}."); + } + + usleep(100); + } + } + + /** + * Write one length-prefixed payload to the parent process. + */ + private function writeChildPayload(Process $process, array $payload): void + { + $serialized = serialize($payload); + + if (strlen($serialized) > self::MAX_FRAME_BYTES) { + throw new RuntimeException('Swoole rate limiter concurrency child payload exceeds the maximum frame size.'); + } + + $frame = pack('N', strlen($serialized)) . $serialized; + $offset = 0; + + while ($offset < strlen($frame)) { + $written = $process->write(substr($frame, $offset)); + + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write Swoole rate limiter concurrency child payload.'); + } + + $offset += $written; + } + } + + /** + * Read one complete length-prefixed payload from a child process. + */ + private function readChildPayload(Process $process, int $pid, int $deadline): array + { + $buffer = ''; + $frameLength = null; + + while (hrtime(true) < $deadline) { + $chunk = $process->read(8192); + + if (is_string($chunk) && $chunk !== '') { + $buffer .= $chunk; + + if ($frameLength === null && strlen($buffer) >= self::FRAME_HEADER_BYTES) { + $header = unpack('Nlength', substr($buffer, 0, self::FRAME_HEADER_BYTES)); + $frameLength = $header['length']; + + if ($frameLength > self::MAX_FRAME_BYTES) { + throw new RuntimeException( + "Swoole rate limiter concurrency child [{$pid}] sent an oversized payload.", + ); + } + } + + if ($frameLength !== null + && strlen($buffer) >= self::FRAME_HEADER_BYTES + $frameLength + ) { + $frameSize = self::FRAME_HEADER_BYTES + $frameLength; + + if (strlen($buffer) !== $frameSize) { + throw new RuntimeException( + "Swoole rate limiter concurrency child [{$pid}] sent trailing payload data.", + ); + } + + $payload = unserialize( + substr($buffer, self::FRAME_HEADER_BYTES, $frameLength), + ['allowed_classes' => false], + ); + + if (! is_array($payload)) { + throw new RuntimeException( + "Swoole rate limiter concurrency child [{$pid}] sent an invalid payload.", + ); + } + + return $payload; + } + } + + usleep(1_000); + } + + throw new RuntimeException("Timed out reading Swoole rate limiter concurrency child [{$pid}]."); + } + + /** + * Stop and reap every child process owned by the test. + * + * @param array $processes + * @param list $pids + */ + private function cleanupProcesses(array $processes, array $pids): void + { + foreach ($processes as $pid => $process) { + if (Process::kill($pid, 0)) { + Process::kill($pid, SIGKILL); + } + + $process->close(); + } + + foreach ($pids as $pid) { + $deadline = hrtime(true) + 1_000_000_000; + + do { + $status = 0; + $result = pcntl_waitpid($pid, $status, WNOHANG); + + if ($result === $pid || ($result === -1 && pcntl_get_last_error() === PCNTL_ECHILD)) { + continue 2; + } + + if ($result === -1 && pcntl_get_last_error() !== PCNTL_EINTR) { + throw new RuntimeException( + "Unable to reap Swoole rate limiter child [{$pid}]: " + . pcntl_strerror(pcntl_get_last_error()), + ); + } + + usleep(1000); + } while (hrtime(true) < $deadline); + + throw new RuntimeException("Timed out reaping Swoole rate limiter child [{$pid}]."); + } + } + + /** + * Create shared table state before any child process is forked. + */ + private function state(): TableState + { + $manager = new TableManager(new Repository([ + 'rate-limiter' => [ + 'stores' => [ + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => 128, + 'conflict_proportion' => 0.2, + ], + ], + ], + ])); + + return $manager->get('swoole'); + } + + /** + * Create a store over the pre-fork shared state. + */ + private function store(TableState $state): SwooleStore + { + return new SwooleStore($state, 0.05, new NullLogger); + } +} diff --git a/tests/RateLimiter/SwooleStoreTest.php b/tests/RateLimiter/SwooleStoreTest.php new file mode 100644 index 000000000..1429e1e5e --- /dev/null +++ b/tests/RateLimiter/SwooleStoreTest.php @@ -0,0 +1,274 @@ +store(); + $policy = Limit::perMinute(5)->cost(2); + + $first = $store->consume('fixed', $policy); + $second = $store->consume('fixed', $policy); + $denied = $store->consume('fixed', $policy); + + $this->assertTrue($first->allowed()); + $this->assertSame(3, $first->remaining()); + $this->assertSame(1, $second->remaining()); + $this->assertTrue($denied->denied()); + $this->assertSame(1, $denied->remaining()); + $this->assertSame(4, $state->table()->get('fixed', 'value')); + $this->assertTrue($store->clear('fixed')); + $this->assertFalse($store->clear('fixed')); + } + + public function testInspectingMissingStateDoesNotCreateARow(): void + { + [$store, $state] = $this->store(); + + $result = $store->inspect('missing', Limit::perMinute(10)); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + $this->assertSame(0, $result->resetAfter()); + $this->assertFalse($state->table()->exist('missing')); + } + + public function testLeakyBucketAndBackoffUseTheSharedCalculator(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store] = $this->store(); + + $bucket = LeakyBucket::perSecond(2); + $this->assertTrue($store->consume('bucket', $bucket)->allowed()); + $this->assertTrue($store->consume('bucket', $bucket)->allowed()); + $this->assertTrue($store->consume('bucket', $bucket)->denied()); + + CarbonImmutable::setTestNow(CarbonImmutable::now()->addMilliseconds(500)); + $this->assertTrue($store->consume('bucket', $bucket)->allowed()); + + $backoff = Backoff::exponential( + after: 2, + initialDelay: 1, + maxDelay: 4, + resetAfter: 10, + ); + $this->assertTrue($store->recordFailure('backoff', $backoff)->allowed()); + $this->assertTrue($store->recordFailure('backoff', $backoff)->denied()); + $this->assertTrue($store->inspect('backoff', $backoff)->denied()); + $this->assertTrue($store->clear('backoff')); + } + + public function testSwitchingToTestTimeKeepsTheEpochClockScale(): void + { + [$store] = $this->store(); + $policy = Limit::perSecond(1); + + $this->assertTrue($store->consume('clock', $policy)->allowed()); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestamp(time() + 2)); + + $this->assertTrue($store->consume('clock', $policy)->allowed()); + } + + public function testPrunesExpiredRows(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(); + + $store->consume('expired', Limit::perSecond(1)); + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds(2)); + + $this->assertSame(1, $store->pruneExpiredRows()); + $this->assertFalse($state->table()->exist('expired')); + } + + public function testPrunesEveryExpiredCollisionRowInOnePass(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(rows: 64); + $now = (int) CarbonImmutable::now()->getPreciseTimestamp(6); + $capacity = $this->fillUntilAllocationFails($state, $now - 1); + + $this->assertGreaterThan(1, $capacity['inserted']); + $this->assertSame($capacity['inserted'], $store->pruneExpiredRows()); + $this->assertSame(0, $state->table()->count()); + } + + public function testPeriodicMaintenanceReportsPostPrunePressure(): void + { + $logger = m::mock(LoggerInterface::class); + $logger->shouldReceive('warning') + ->once() + ->with( + 'Swoole rate limiter table [swoole] is nearing capacity.', + m::on(fn (array $context): bool => $context['threshold'] === 0.0010000000000000009), + ); + [$store] = $this->store(memoryLimitBuffer: 0.999, logger: $logger); + + $store->consume('live', Limit::perMinute(1)); + + $this->assertSame(0, $store->maintain()); + } + + public function testFullTablePrunesExpiredRowsAndRetriesOnce(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(rows: 64); + $table = $state->table(); + $now = (int) CarbonImmutable::now()->getPreciseTimestamp(6); + $capacity = $this->fillUntilAllocationFails($state, $now + 60_000_000); + $this->assertTrue($table->set($capacity['conflict_key'], [ + 'value' => 1, + 'available_at' => $now - 1, + 'expires_at' => $now - 1, + ])); + + $result = @$store->consume($capacity['failed_key'], Limit::perMinute(1)); + + $this->assertTrue($result->allowed()); + $this->assertFalse($table->exist($capacity['conflict_key'])); + $this->assertTrue($table->exist($capacity['failed_key'])); + } + + public function testFullTableOfLiveRowsFailsClosed(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(rows: 64); + $now = (int) CarbonImmutable::now()->getPreciseTimestamp(6); + $capacity = $this->fillUntilAllocationFails($state, $now + 60_000_000); + + $this->expectException(SwooleTableFullException::class); + $this->expectExceptionMessage('cannot allocate a new entry after pruning expired state'); + + @$store->consume($capacity['failed_key'], Limit::perMinute(1)); + } + + public function testCorruptStateFailsClosed(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(); + $expiresAt = (int) CarbonImmutable::now()->getPreciseTimestamp(6) + 60_000_000; + $this->assertTrue($state->table()->set('corrupt', [ + 'value' => 1, + 'available_at' => $expiresAt + 1, + 'expires_at' => $expiresAt, + ])); + + $this->expectException(UnexpectedValueException::class); + + $store->consume('corrupt', Limit::perMinute(10)); + } + + /** + * @return array{SwooleStore, TableState} + */ + private function store( + int $rows = 128, + float $memoryLimitBuffer = 0.05, + ?LoggerInterface $logger = null, + ): array { + $manager = new TableManager(new Repository([ + 'rate-limiter' => [ + 'stores' => [ + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => $rows, + 'conflict_proportion' => 0.2, + ], + ], + ], + ])); + $state = $manager->get('swoole'); + + return [ + new SwooleStore($state, $memoryLimitBuffer, $logger ?? new NullLogger), + $state, + ]; + } + + /** + * Fill the collision pool and return an exact key that cannot be allocated. + * + * @return array{failed_key: string, conflict_key: string, inserted: int} + */ + private function fillUntilAllocationFails(TableState $state, int $expiresAt): array + { + $table = $state->table(); + $availableSlices = (int) $table->stats()['available_slice_num']; + $conflictKey = null; + $inserted = 0; + + for ($index = 0; $index < 10_000; ++$index) { + $key = "capacity:{$index}"; + $stored = @$table->set($key, [ + 'value' => 1, + 'available_at' => $expiresAt, + 'expires_at' => $expiresAt, + ]); + + if (! $stored) { + if ($conflictKey === null) { + $this->fail('The test table failed before allocating a conflict row.'); + } + + return [ + 'failed_key' => $key, + 'conflict_key' => $conflictKey, + 'inserted' => $inserted, + ]; + } + + ++$inserted; + $remainingSlices = (int) $table->stats()['available_slice_num']; + + if ($remainingSlices < $availableSlices) { + $conflictKey = $key; + } + + $availableSlices = $remainingSlices; + } + + $this->fail('The test table did not reach capacity.'); + } + + protected function rateLimiterStoreContract(): Limiter + { + [$store] = $this->store(); + + return new Limiter( + $store, + new KeyResolver('swoole-contract', static fn (): ?string => null), + ); + } + + protected function advanceRateLimiterStoreContractClock(int $seconds): bool + { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds($seconds)); + + return true; + } +} diff --git a/tests/RateLimiter/SwooleTableManagerTest.php b/tests/RateLimiter/SwooleTableManagerTest.php new file mode 100644 index 000000000..c6c54a372 --- /dev/null +++ b/tests/RateLimiter/SwooleTableManagerTest.php @@ -0,0 +1,140 @@ +manager([ + 'swoole' => $this->storeConfig(), + ]); + + $state = $manager->get('swoole'); + $table = $state->table(); + + $this->assertSame($state, $manager->get('swoole')); + $this->assertTrue($table->set('maximum', [ + 'value' => AdmissionPolicy::MAX_INTEGER, + 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER, + ])); + $this->assertSame([ + 'value' => AdmissionPolicy::MAX_INTEGER, + 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER, + ], $table->get('maximum')); + } + + public function testSealingRetainsExistingTablesAndRejectsLateCreation(): void + { + $manager = $this->manager([ + 'first' => $this->storeConfig(), + 'second' => $this->storeConfig(), + ]); + $first = $manager->get('first'); + + $manager->seal(); + + $this->assertSame($first, $manager->get('first')); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('was not initialized before the server fork'); + + $manager->get('second'); + } + + public function testRejectsAnUndefinedSwooleStore(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Swoole rate limiter store [missing] is not defined.'); + + $this->manager([])->get('missing'); + } + + #[DataProvider('validConflictProportions')] + public function testAcceptsConflictProportionsThatSwooleHonorsExactly(float $conflictProportion): void + { + $manager = $this->manager([ + 'swoole' => $this->storeConfig(['conflict_proportion' => $conflictProportion]), + ]); + + $this->assertSame(64, $manager->get('swoole')->table()->getSize()); + } + + /** + * @return array + */ + public static function validConflictProportions(): array + { + return [ + 'minimum' => [0.2], + 'maximum' => [1.0], + ]; + } + + /** + * @param array $overrides + */ + #[DataProvider('invalidConfigurations')] + public function testRejectsInvalidStructuralConfiguration(array $overrides): void + { + $this->expectException(InvalidArgumentException::class); + + $this->manager([ + 'swoole' => [...$this->storeConfig(), ...$overrides], + ])->get('swoole'); + } + + /** + * @return array}> + */ + public static function invalidConfigurations(): array + { + return [ + 'string rows' => [['rows' => '64']], + 'zero rows' => [['rows' => 0]], + 'string conflict proportion' => [['conflict_proportion' => '0.2']], + 'integer conflict proportion' => [['conflict_proportion' => 1]], + 'zero conflict proportion' => [['conflict_proportion' => 0.0]], + 'below Swoole minimum' => [['conflict_proportion' => 0.1]], + 'above Swoole maximum' => [['conflict_proportion' => 1.5]], + ]; + } + + /** + * @param array> $stores + */ + private function manager(array $stores): TableManager + { + return new TableManager(new Repository([ + 'rate-limiter' => [ + 'stores' => $stores, + ], + ])); + } + + /** + * @param array $overrides + * @return array + */ + private function storeConfig(array $overrides = []): array + { + return [ + 'driver' => 'swoole', + 'rows' => 64, + 'conflict_proportion' => 0.2, + ...$overrides, + ]; + } +} diff --git a/tests/RateLimiter/WorkerArrayStoreTest.php b/tests/RateLimiter/WorkerArrayStoreTest.php new file mode 100644 index 000000000..0e25feca2 --- /dev/null +++ b/tests/RateLimiter/WorkerArrayStoreTest.php @@ -0,0 +1,184 @@ +limiter(); + $policy = Limit::perSecond(5, 10)->cost(2)->by('fixed'); + + $first = $limiter->consume($policy); + $this->assertTrue($first->allowed()); + $this->assertSame(3, $first->remaining()); + $this->assertSame(10, $first->resetAfter()); + + $deniedPolicy = $policy->cost(4); + $denied = $limiter->consume($deniedPolicy); + $this->assertTrue($denied->denied()); + $this->assertSame(3, $denied->remaining()); + $this->assertSame(10, $denied->retryAfter()); + + $inspection = $limiter->inspect($policy); + $this->assertTrue($inspection->allowed()); + $this->assertSame(3, $inspection->remaining()); + + CarbonImmutable::setTestNow($now->addSeconds(10)); + + $expired = $limiter->inspect($policy); + $this->assertTrue($expired->allowed()); + $this->assertSame(5, $expired->remaining()); + $this->assertSame(0, $expired->resetAfter()); + } + + public function testLeakyBucketRecoversContinuouslyWithoutMutatingDenials(): void + { + $now = CarbonImmutable::parse('2026-08-04 12:00:00.000000'); + CarbonImmutable::setTestNow($now); + $limiter = $this->limiter(); + $policy = LeakyBucket::perSecond(2)->by('leaky'); + + $this->assertSame(1, $limiter->consume($policy)->remaining()); + $this->assertSame(0, $limiter->consume($policy)->remaining()); + + $denied = $limiter->consume($policy); + $this->assertTrue($denied->denied()); + $this->assertSame(0, $denied->remaining()); + $this->assertSame(1, $denied->retryAfter()); + + CarbonImmutable::setTestNow($now->addMicroseconds(500_000)); + + $inspection = $limiter->inspect($policy); + $this->assertTrue($inspection->allowed()); + $this->assertSame(1, $inspection->remaining()); + + $accepted = $limiter->consume($policy); + $this->assertTrue($accepted->allowed()); + $this->assertSame(0, $accepted->remaining()); + } + + public function testExponentialBackoffUsesThresholdDoublingCapAndInactivityReset(): void + { + $now = CarbonImmutable::parse('2026-08-04 12:00:00.000000'); + CarbonImmutable::setTestNow($now); + $limiter = $this->limiter(); + $backoff = Backoff::exponential( + after: 3, + initialDelay: 2, + maxDelay: 8, + resetAfter: 20, + )->by('backoff'); + + $this->assertTrue($limiter->recordFailure($backoff)->allowed()); + $this->assertTrue($limiter->recordFailure($backoff)->allowed()); + + $third = $limiter->recordFailure($backoff); + $this->assertTrue($third->denied()); + $this->assertSame(3, $third->failures()); + $this->assertSame(2, $third->retryAfter()); + $this->assertTrue($limiter->inspect($backoff)->denied()); + + CarbonImmutable::setTestNow($now->addSeconds(2)); + $this->assertTrue($limiter->inspect($backoff)->allowed()); + $this->assertSame(4, $limiter->recordFailure($backoff)->retryAfter()); + + CarbonImmutable::setTestNow($now->addSeconds(6)); + $this->assertSame(8, $limiter->recordFailure($backoff)->retryAfter()); + + CarbonImmutable::setTestNow($now->addSeconds(14)); + $this->assertSame(8, $limiter->recordFailure($backoff)->retryAfter()); + + CarbonImmutable::setTestNow($now->addSeconds(35)); + $reset = $limiter->inspect($backoff); + $this->assertTrue($reset->allowed()); + $this->assertSame(0, $reset->failures()); + } + + public function testClearIsParameterSensitive(): void + { + CarbonImmutable::setTestNow('2026-08-04 12:00:00'); + $limiter = $this->limiter(); + $policy = Limit::perMinute(1)->by('clear'); + + $limiter->consume($policy); + + $this->assertFalse($limiter->clear(Limit::perMinute(2)->by('clear'))); + $this->assertTrue($limiter->inspect($policy)->denied()); + $this->assertTrue($limiter->clear($policy)); + $this->assertTrue($limiter->inspect($policy)->allowed()); + } + + public function testClockOriginDoesNotChangeWhenTestTimeStartsAfterStateCreation(): void + { + $limiter = $this->limiter(); + $policy = Limit::perSecond(1, 1)->by('clock'); + $before = CarbonImmutable::now(); + + $limiter->consume($policy); + CarbonImmutable::setTestNow($before->addSeconds(2)); + + $this->assertTrue($limiter->inspect($policy)->allowed()); + $this->assertSame(0, $limiter->inspect($policy)->resetAfter()); + } + + public function testCorruptNumericStateFailsExplicitly(): void + { + CarbonImmutable::setTestNow('2026-08-04 12:00:00'); + $store = new CorruptibleWorkerArrayStore; + $store->putState('corrupt', 11, 2_000_000_000_000_000, 2_000_000_000_000_000); + + $this->expectException(UnexpectedValueException::class); + + $store->inspect('corrupt', Limit::perMinute(10)); + } + + private function limiter(): Limiter + { + return new Limiter( + new WorkerArrayStore, + new KeyResolver('test', static fn (): ?string => null), + ); + } + + protected function rateLimiterStoreContract(): Limiter + { + return $this->limiter(); + } + + protected function advanceRateLimiterStoreContractClock(int $seconds): bool + { + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSeconds($seconds)); + + return true; + } +} + +class CorruptibleWorkerArrayStore extends WorkerArrayStore +{ + public function putState(string $key, int $value, int $availableAt, int $expiresAt): void + { + $this->states[$key] = [ + 'value' => $value, + 'available_at' => $availableAt, + 'expires_at' => $expiresAt, + ]; + } +} From 693ec403eb5c9908e83f1904d7edbb2158ddcf90 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:34 +0000 Subject: [PATCH 09/41] Ship rate limiter defaults in Testbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Testbench’s worker-array rate-limiter default and standard rate_limits migration. Update migration inventory, refresh and rollback coverage, and default-config assertions so tests can opt into database limiting without forcing ordinary framework tests through SQLite. --- .../hypervel/config/rate-limiter.php | 67 +++++++++++++++++++ ...008_testbench_create_rate_limits_table.php | 30 +++++++++ tests/Testbench/CommanderTest.php | 1 + .../Databases/WithMigrationAttributeTest.php | 5 ++ tests/Testbench/DefaultConfigurationTest.php | 12 +++- 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/testbench/hypervel/config/rate-limiter.php create mode 100644 src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php diff --git a/src/testbench/hypervel/config/rate-limiter.php b/src/testbench/hypervel/config/rate-limiter.php new file mode 100644 index 000000000..4d0009d12 --- /dev/null +++ b/src/testbench/hypervel/config/rate-limiter.php @@ -0,0 +1,67 @@ + env('RATE_LIMITER_STORE', 'worker-array'), + + /* + |-------------------------------------------------------------------------- + | Rate Limiter Stores + |-------------------------------------------------------------------------- + | + | Here you may configure the stores used to hold rate limiter state. Each + | store performs its decisions atomically using its native primitives. + | + | Supported drivers: "database", "redis", "swoole", "worker-array" + | + */ + + 'stores' => [ + 'database' => [ + 'driver' => 'database', + 'connection' => env('RATE_LIMITER_DB_CONNECTION'), + 'table' => env('RATE_LIMITER_DB_TABLE', 'rate_limits'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('RATE_LIMITER_REDIS_CONNECTION', 'default'), + ], + + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => (int) env('RATE_LIMITER_SWOOLE_ROWS', 65536), + 'conflict_proportion' => 0.2, + 'memory_limit_buffer' => 0.05, + 'prune_interval' => 60, // seconds + ], + + 'worker-array' => [ + 'driver' => 'worker-array', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiter Prefix + |-------------------------------------------------------------------------- + | + | This value namespaces limiter identities so applications sharing a store + | do not share rate limit state. It is included before keys are hashed. + | + */ + + 'prefix' => env('RATE_LIMITER_PREFIX', app_id() . '_rate_limiter'), +]; diff --git a/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php b/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php new file mode 100644 index 000000000..75642986e --- /dev/null +++ b/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php @@ -0,0 +1,30 @@ +char('key', 32)->primary(); + $table->unsignedBigInteger('value')->default(0); + $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('expires_at')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('rate_limits'); + } +}; diff --git a/tests/Testbench/CommanderTest.php b/tests/Testbench/CommanderTest.php index fab52b6c4..392d6982b 100644 --- a/tests/Testbench/CommanderTest.php +++ b/tests/Testbench/CommanderTest.php @@ -127,6 +127,7 @@ public function itCanCallCommanderUsingCliAndRunMigration(): void '0001_01_01_000005_testbench_create_job_batches_table', '0001_01_01_000006_testbench_create_jobs_table', '0001_01_01_000007_testbench_create_failed_jobs_table', + '0001_01_01_000008_testbench_create_rate_limits_table', '2013_07_26_182750_create_testbench_users_table', ], DB::connection('sqlite')->table('migrations')->pluck('migration')->all()); }); diff --git a/tests/Testbench/Databases/WithMigrationAttributeTest.php b/tests/Testbench/Databases/WithMigrationAttributeTest.php index 13f0fb484..4f8999453 100644 --- a/tests/Testbench/Databases/WithMigrationAttributeTest.php +++ b/tests/Testbench/Databases/WithMigrationAttributeTest.php @@ -26,6 +26,7 @@ public function itLoadsDefaultMigrations(): void $this->assertTrue(Schema::hasTable('cache_locks')); $this->assertTrue(Schema::hasTable('jobs')); $this->assertTrue(Schema::hasTable('job_batches')); + $this->assertTrue(Schema::hasTable('rate_limits')); } #[Test] @@ -39,6 +40,7 @@ public function itLoadsCachesMigrations(): void $this->assertTrue(Schema::hasTable('jobs')); $this->assertTrue(Schema::hasTable('job_batches')); $this->assertTrue(Schema::hasTable('failed_jobs')); + $this->assertTrue(Schema::hasTable('rate_limits')); $this->assertFalse(Schema::hasTable('notifications')); $this->assertTrue(Schema::hasTable('sessions')); } @@ -54,6 +56,7 @@ public function itLoadsNotificationsMigrations(): void $this->assertTrue(Schema::hasTable('jobs')); $this->assertTrue(Schema::hasTable('job_batches')); $this->assertTrue(Schema::hasTable('failed_jobs')); + $this->assertTrue(Schema::hasTable('rate_limits')); $this->assertTrue(Schema::hasTable('notifications')); $this->assertTrue(Schema::hasTable('sessions')); } @@ -69,6 +72,7 @@ public function itLoadsQueueMigrations(): void $this->assertTrue(Schema::hasTable('jobs')); $this->assertTrue(Schema::hasTable('job_batches')); $this->assertTrue(Schema::hasTable('failed_jobs')); + $this->assertTrue(Schema::hasTable('rate_limits')); $this->assertFalse(Schema::hasTable('notifications')); $this->assertTrue(Schema::hasTable('sessions')); } @@ -84,6 +88,7 @@ public function itLoadsSessionMigrations(): void $this->assertTrue(Schema::hasTable('jobs')); $this->assertTrue(Schema::hasTable('job_batches')); $this->assertTrue(Schema::hasTable('failed_jobs')); + $this->assertTrue(Schema::hasTable('rate_limits')); $this->assertFalse(Schema::hasTable('notifications')); $this->assertTrue(Schema::hasTable('sessions')); } diff --git a/tests/Testbench/DefaultConfigurationTest.php b/tests/Testbench/DefaultConfigurationTest.php index 508de7b5f..e5205af13 100644 --- a/tests/Testbench/DefaultConfigurationTest.php +++ b/tests/Testbench/DefaultConfigurationTest.php @@ -78,11 +78,19 @@ public function itFallsBackToTheTestingConnectionWhenRuntimeSqliteIsMissing(): v public function itPopulatesExpectedCacheDefaults(): void { $this->assertSame(Env::has('TESTBENCH_PACKAGE_TESTER') ? 'database' : 'array', $this->app['config']['cache.default']); - $this->assertTrue($this->app['config']->has('cache.limiter')); - $this->assertNull($this->app['config']['cache.limiter']); $this->assertFalse($this->app['config']['cache.serializable_classes']); } + #[Test] + public function itPopulatesExpectedRateLimiterDefaults(): void + { + $this->assertSame('worker-array', $this->app['config']['rate-limiter.default']); + $this->assertSame( + ['database', 'redis', 'swoole', 'worker-array'], + array_keys($this->app['config']['rate-limiter.stores']), + ); + } + #[Test] public function itPopulatesExpectedSessionDefaults(): void { From a948c6352b456a9001a62a0d7a488417d91e099d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:54 +0000 Subject: [PATCH 10/41] Unify HTTP throttling on rate limiter stores Rewrite route throttling around atomic typed decisions and one backend-neutral middleware path. Preserve Laravel-facing helpers, headers, named limiters, stacked policies, response predicates, and custom responses while removing Redis-specific middleware state and switches. Keep decision data request-local and select Redis as an ordinary named store. --- .../src/Configuration/Middleware.php | 26 +- src/foundation/src/Http/Kernel.php | 1 - src/routing/composer.json | 2 +- .../src/Middleware/ThrottleRequests.php | 157 ++++---- .../Middleware/ThrottleRequestsWithRedis.php | 110 ------ src/support/src/Facades/RateLimiter.php | 34 +- src/testbench/src/Foundation/Application.php | 2 - .../src/PHPUnit/AfterEachTestSubscriber.php | 1 - .../Configuration/MiddlewareTest.php | 24 +- tests/Inertia/InertiaServiceProviderTest.php | 5 +- .../Http/ThrottleRequestsRedisStoreTest.php | 63 ++++ .../Integration/Http/ThrottleRequestsTest.php | 339 ++++++++++++++++++ .../Http/ThrottleRequestsWithRedisTest.php | 50 --- tests/Routing/RoutingStaticStateTest.php | 14 - tests/Routing/ThrottleRequestsTest.php | 133 +++---- 15 files changed, 541 insertions(+), 420 deletions(-) delete mode 100644 src/routing/src/Middleware/ThrottleRequestsWithRedis.php create mode 100644 tests/Integration/Http/ThrottleRequestsRedisStoreTest.php create mode 100644 tests/Integration/Http/ThrottleRequestsTest.php delete mode 100644 tests/Integration/Http/ThrottleRequestsWithRedisTest.php diff --git a/src/foundation/src/Configuration/Middleware.php b/src/foundation/src/Configuration/Middleware.php index cb4db5e99..4eed780cb 100644 --- a/src/foundation/src/Configuration/Middleware.php +++ b/src/foundation/src/Configuration/Middleware.php @@ -85,11 +85,6 @@ class Middleware */ protected ?string $apiLimiter = null; - /** - * Indicates if Redis throttling should be applied. - */ - protected bool $throttleWithRedis = false; - /** * Indicates if sessions should be authenticated for the "web" middleware group. */ @@ -575,26 +570,15 @@ public function statefulApi(): static /** * Indicate that the API middleware group's throttling middleware should be enabled. */ - public function throttleApi(string $limiter = 'api', bool $redis = false): static + public function throttleApi(string $limiter = 'api'): static { $this->apiLimiter = $limiter; - if ($redis) { - $this->throttleWithRedis(); - } - return $this; } - /** - * Indicate that Hypervel's throttling middleware should use Redis. - */ - public function throttleWithRedis(): static - { - $this->throttleWithRedis = true; - - return $this; - } + // Hypervel selects the rate-limiter store through its config or named + // limiter registration, so Laravel's middleware-level Redis switch is omitted. /** * Indicate that sessions should be authenticated for the "web" middleware group. @@ -630,9 +614,7 @@ protected function defaultAliases(): array 'password.confirm' => \Hypervel\Auth\Middleware\RequirePassword::class, 'precognitive' => \Hypervel\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'signed' => \Hypervel\Routing\Middleware\ValidateSignature::class, - 'throttle' => $this->throttleWithRedis - ? \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class - : \Hypervel\Routing\Middleware\ThrottleRequests::class, + 'throttle' => \Hypervel\Routing\Middleware\ThrottleRequests::class, 'verified' => \Hypervel\Auth\Middleware\EnsureEmailIsVerified::class, ]; } diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 6d7a177d1..beee21f0b 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -106,7 +106,6 @@ class Kernel implements KernelContract \Hypervel\Auth\Middleware\UseGuard::class, \Hypervel\Contracts\Auth\Middleware\AuthenticatesRequests::class, \Hypervel\Routing\Middleware\ThrottleRequests::class, - \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class, \Hypervel\Contracts\Session\Middleware\AuthenticatesSessions::class, \Hypervel\Routing\Middleware\SubstituteBindings::class, \Hypervel\Auth\Middleware\Authorize::class, diff --git a/src/routing/composer.json b/src/routing/composer.json index bcf2ee990..395225ffc 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.4", - "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/console": "^0.4", @@ -42,6 +41,7 @@ "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/pipeline": "^0.4", + "hypervel/rate-limiter": "^0.4", "hypervel/reflection": "^0.4", "hypervel/session": "^0.4", "hypervel/support": "^0.4", diff --git a/src/routing/src/Middleware/ThrottleRequests.php b/src/routing/src/Middleware/ThrottleRequests.php index 7796b223b..2b273de26 100644 --- a/src/routing/src/Middleware/ThrottleRequests.php +++ b/src/routing/src/Middleware/ThrottleRequests.php @@ -5,11 +5,16 @@ namespace Hypervel\Routing\Middleware; use Closure; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Unlimited; use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Http\Exceptions\ThrottleRequestsException; use Hypervel\Http\Request; +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\LimitResult; +use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\Unlimited; use Hypervel\Routing\Exceptions\MissingRateLimiterException; use Hypervel\Support\Collection; use Hypervel\Support\InteractsWithTime; @@ -28,11 +33,6 @@ class ThrottleRequests */ protected RateLimiter $limiter; - /** - * Indicates if the rate limiter keys should be hashed. - */ - protected static bool $shouldHashKeys = true; - /** * Create a new request throttler. */ @@ -77,14 +77,13 @@ public function handle(Request $request, Closure $next, int|string $maxAttempts $request, $next, [ - (object) [ - 'key' => $prefix . $this->resolveRequestSignature($request), - 'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts), - 'decaySeconds' => 60 * $decayMinutes, - 'afterCallback' => null, - 'responseCallback' => null, - ], - ] + new Limit( + maxAttempts: $this->resolveMaxAttempts($request, $maxAttempts), + decaySeconds: $this->resolveDecaySeconds($decayMinutes), + key: $prefix . $this->resolveRequestSignature($request), + ), + ], + $this->limiter->store(), ); } @@ -107,58 +106,72 @@ protected function handleRequestUsingNamedLimiter(Request $request, Closure $nex return $this->handleRequest( $request, $next, - Collection::wrap($limiterResponse)->map(function ($limit) use ($limiterName) { - return (object) [ - 'key' => $this->limiter->resolveNamedLimiterKey( - $limiterName, - $limit, - self::$shouldHashKeys, - ), - 'maxAttempts' => $limit->maxAttempts, - 'decaySeconds' => $limit->decaySeconds, - 'afterCallback' => $limit->afterCallback, - 'responseCallback' => $limit->responseCallback, - ]; - })->all() + Collection::wrap($limiterResponse)->all(), + $this->limiter->store($this->limiter->limiterStore($limiterName)), + $limiterName, ); } /** * Handle an incoming request. * + * @param array $limits + * * @throws \Hypervel\Http\Exceptions\ThrottleRequestsException */ - protected function handleRequest(Request $request, Closure $next, array $limits): Response - { + protected function handleRequest( + Request $request, + Closure $next, + array $limits, + Limiter $limiter, + ?string $limiterName = null, + ): Response { + /** @var list $decisions */ + $decisions = []; + + // Laravel preflights every policy before recording hits. Atomic stores + // consume in order, so an earlier accepted decision is never rolled back. foreach ($limits as $limit) { - if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { - throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); - } - } + $result = $limit->afterCallback === null + ? $limiter->consume($limit, $limiterName) + : $limiter->inspect($limit, $limiterName); - foreach ($limits as $limit) { - if (! $limit->afterCallback) { - $this->limiter->hit($limit->key, $limit->decaySeconds); + if ($result->denied()) { + throw $this->buildException($request, $result, $limit->responseCallback); } + + $decisions[] = [$limit, $result]; } $response = $next($request); - foreach ($limits as $limit) { - if ($limit->afterCallback && ($limit->afterCallback)($response)) { - $this->limiter->hit($limit->key, $limit->decaySeconds); + foreach ($decisions as [$limit, $result]) { + if ($limit->afterCallback !== null && ($limit->afterCallback)($response)) { + $result = $limiter->consume($limit, $limiterName); } $response = $this->addHeaders( $response, - $limit->maxAttempts, - $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts) + $result->limit(), + $result->remaining(), ); } return $response; } + /** + * Resolve the fixed-window duration from the middleware argument. + */ + protected function resolveDecaySeconds(float|int|string $decayMinutes): int + { + if (! is_numeric($decayMinutes)) { + throw new InvalidRateLimitException('The rate limit decay minutes must be numeric.'); + } + + return (int) ceil((float) $decayMinutes * 60); + } + /** * Resolve the number of attempts if the user is authenticated or not. * @@ -194,10 +207,10 @@ protected function resolveMaxAttempts(Request $request, int|string $maxAttempts) protected function resolveRequestSignature(Request $request): string { if ($user = $request->user()) { - return $this->formatIdentifier((string) $user->getAuthIdentifier()); + return (string) $user->getAuthIdentifier(); } if ($route = $request->route()) { - return $this->formatIdentifier($route->getDomain() . '|' . $request->ip()); + return $route->getDomain() . '|' . $request->ip(); } throw new RuntimeException('Unable to generate the request signature. Route unavailable.'); @@ -206,14 +219,14 @@ protected function resolveRequestSignature(Request $request): string /** * Create a 'too many attempts' exception. */ - protected function buildException(Request $request, string $key, int $maxAttempts, ?callable $responseCallback = null): ThrottleRequestsException|HttpResponseException + protected function buildException(Request $request, LimitResult $result, ?callable $responseCallback = null): ThrottleRequestsException|HttpResponseException { - $retryAfter = $this->getTimeUntilNextRetry($key); - + // The atomic decision retains real unused capacity on weighted denials; + // Laravel's split retry path reports zero remaining instead. $headers = $this->getHeaders( - $maxAttempts, - $this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter), - $retryAfter + $result->limit(), + $result->remaining(), + $result->retryAfter(), ); return is_callable($responseCallback) @@ -221,14 +234,6 @@ protected function buildException(Request $request, string $key, int $maxAttempt : new ThrottleRequestsException('Too Many Attempts.', null, $headers); } - /** - * Get the number of seconds until the next retry. - */ - protected function getTimeUntilNextRetry(string $key): int - { - return $this->limiter->availableIn($key); - } - /** * Add the limit header information to the given response. */ @@ -265,38 +270,6 @@ protected function getHeaders(int $maxAttempts, int $remainingAttempts, ?int $re return $headers; } - /** - * Calculate the number of remaining attempts. - */ - protected function calculateRemainingAttempts(string $key, int $maxAttempts, ?int $retryAfter = null): int - { - return is_null($retryAfter) ? $this->limiter->retriesLeft($key, $maxAttempts) : 0; - } - - /** - * Format the given identifier based on the configured hashing settings. - */ - private function formatIdentifier(string $value): string - { - return self::$shouldHashKeys ? hash('xxh128', $value) : $value; - } - - /** - * Specify whether rate limiter keys should be hashed. - * - * Boot-only. The flag persists in a static property for the worker lifetime - * and applies to every subsequent request. - */ - public static function shouldHashKeys(bool $shouldHashKeys = true): void - { - self::$shouldHashKeys = $shouldHashKeys; - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - self::$shouldHashKeys = true; - } + // Laravel's formatIdentifier() and shouldHashKeys() opt-out are omitted; + // the rate-limiter package always hashes the complete policy identity. } diff --git a/src/routing/src/Middleware/ThrottleRequestsWithRedis.php b/src/routing/src/Middleware/ThrottleRequestsWithRedis.php deleted file mode 100644 index 244eb332c..000000000 --- a/src/routing/src/Middleware/ThrottleRequestsWithRedis.php +++ /dev/null @@ -1,110 +0,0 @@ -redis = $redis; - } - - /** - * Handle an incoming request. - * - * @throws \Hypervel\Http\Exceptions\ThrottleRequestsException - */ - protected function handleRequest(Request $request, Closure $next, array $limits): Response - { - foreach ($limits as $limit) { - if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds)) { - throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); - } - } - - $response = $next($request); - - foreach ($limits as $limit) { - $response = $this->addHeaders( - $response, - $limit->maxAttempts, - $this->calculateRemainingAttempts($limit->key, $limit->maxAttempts) - ); - } - - return $response; - } - - /** - * Determine if the given key has been "accessed" too many times. - */ - protected function tooManyAttempts(string $key, int $maxAttempts, int $decaySeconds): bool - { - $limiter = new DurationLimiter( - $this->getRedisConnection(), - $key, - $maxAttempts, - $decaySeconds - ); - - return tap(! $limiter->acquire(), function () use ($key, $limiter) { - [$this->decaysAt[$key], $this->remaining[$key]] = [ - $limiter->decaysAt, $limiter->remaining, - ]; - }); - } - - /** - * Calculate the number of remaining attempts. - */ - protected function calculateRemainingAttempts(string $key, int $maxAttempts, ?int $retryAfter = null): int - { - return is_null($retryAfter) ? $this->remaining[$key] : 0; - } - - /** - * Get the number of seconds until the next retry. - */ - protected function getTimeUntilNextRetry(string $key): int - { - return $this->decaysAt[$key] - $this->currentTime(); - } - - /** - * Get the Redis connection that should be used for throttling. - */ - protected function getRedisConnection(): RedisProxy - { - return $this->redis->connection(); - } -} diff --git a/src/support/src/Facades/RateLimiter.php b/src/support/src/Facades/RateLimiter.php index 196c3e4f3..4c99c5e9d 100644 --- a/src/support/src/Facades/RateLimiter.php +++ b/src/support/src/Facades/RateLimiter.php @@ -5,24 +5,26 @@ namespace Hypervel\Support\Facades; /** - * @method static \Hypervel\Cache\RateLimiter for(\UnitEnum|string $name, \Closure $callback) + * @method static \Hypervel\RateLimiter\RateLimiter for(\UnitEnum|string $name, \Closure $callback, \UnitEnum|string|null $store = null) * @method static void resolveKeyScopeUsing(\Closure|null $resolver) * @method static \Closure|null limiter(\UnitEnum|string $name) - * @method static string resolveNamedLimiterKey(string $limiterName, \Hypervel\Cache\RateLimiting\Limit $limit, bool $shouldHashKeys = true) - * @method static mixed attempt(string $key, int $maxAttempts, \Closure $callback, \DateInterval|\DateTimeInterface|int $decaySeconds = 60) - * @method static bool tooManyAttempts(string $key, int $maxAttempts) - * @method static int hit(string $key, \DateInterval|\DateTimeInterface|int $decaySeconds = 60) - * @method static int increment(string $key, \DateInterval|\DateTimeInterface|int $decaySeconds = 60, int $amount = 1) - * @method static int decrement(string $key, \DateInterval|\DateTimeInterface|int $decaySeconds = 60, int $amount = 1) - * @method static mixed attempts(string $key) - * @method static bool resetAttempts(string $key) - * @method static int remaining(string $key, int $maxAttempts) - * @method static int retriesLeft(string $key, int $maxAttempts) - * @method static void clear(string $key) - * @method static int availableIn(string $key) - * @method static string cleanRateLimiterKey(string $key) + * @method static string|null limiterStore(\UnitEnum|string $name) + * @method static \Hypervel\RateLimiter\Limiter store(\UnitEnum|string|null $name = null) + * @method static \Hypervel\RateLimiter\Contracts\Store getStore() + * @method static \Hypervel\RateLimiter\LimitResult consume(\Hypervel\RateLimiter\AdmissionPolicy $policy, \UnitEnum|string|null $limiterName = null) + * @method static \Hypervel\RateLimiter\LimitResult|\Hypervel\RateLimiter\BackoffResult inspect(\Hypervel\RateLimiter\AdmissionPolicy|\Hypervel\RateLimiter\Backoff $policy, \UnitEnum|string|null $limiterName = null) + * @method static mixed attempt(\Hypervel\RateLimiter\AdmissionPolicy $policy, \Closure $callback, \UnitEnum|string|null $limiterName = null) + * @method static \Hypervel\RateLimiter\BackoffResult recordFailure(\Hypervel\RateLimiter\Backoff $backoff, \UnitEnum|string|null $limiterName = null) + * @method static bool clear(\Hypervel\RateLimiter\AdmissionPolicy|\Hypervel\RateLimiter\Backoff $policy, \UnitEnum|string|null $limiterName = null) + * @method static string getDefaultInstance() + * @method static void setDefaultInstance(string $name) + * @method static array getInstanceConfig(string $name) + * @method static \Hypervel\RateLimiter\RateLimiter forgetInstance(array|string|null $name = null) + * @method static void purge(string|null $name = null) + * @method static \Hypervel\RateLimiter\RateLimiter extend(string $name, \Closure $callback) * - * @see \Hypervel\Cache\RateLimiter + * @see \Hypervel\RateLimiter\RateLimiter + * @see \Hypervel\RateLimiter\Limiter */ class RateLimiter extends Facade { @@ -31,6 +33,6 @@ class RateLimiter extends Facade */ protected static function getFacadeAccessor(): string { - return \Hypervel\Cache\RateLimiter::class; + return \Hypervel\RateLimiter\RateLimiter::class; } } diff --git a/src/testbench/src/Foundation/Application.php b/src/testbench/src/Foundation/Application.php index 907700d4a..794d13c36 100644 --- a/src/testbench/src/Foundation/Application.php +++ b/src/testbench/src/Foundation/Application.php @@ -27,7 +27,6 @@ use Hypervel\Mail\Markdown; use Hypervel\Queue\Console\WorkCommand; use Hypervel\Queue\Queue; -use Hypervel\Routing\Middleware\ThrottleRequests; use Hypervel\Support\Arr; use Hypervel\Support\EncodedHtmlString; use Hypervel\Support\Sleep; @@ -398,7 +397,6 @@ public static function flushState(object $instance): void SchemaBuilder::flushState(); Sleep::flushState(); Str::flushState(); - ThrottleRequests::flushState(); TrimStrings::flushState(); TrustProxies::flushState(); TrustHosts::flushState(); diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 3d59c8f09..f4ca97750 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -245,7 +245,6 @@ protected function flushFrameworkState(): void \Hypervel\Routing\CallableDispatcher::flushState(); \Hypervel\Routing\ControllerDispatcher::flushState(); \Hypervel\Routing\ImplicitRouteBinding::flushCache(); - \Hypervel\Routing\Middleware\ThrottleRequests::flushState(); \Hypervel\Routing\Middleware\ValidateSignature::flushState(); \Hypervel\Routing\PendingResourceRegistration::flushState(); \Hypervel\Routing\PendingSingletonResourceRegistration::flushState(); diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index 7d872fd4a..7e8c4a256 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -396,24 +396,19 @@ public function testDefaultMiddlewareAliases() ], $middleware->getMiddlewareAliases()); } - public function testThrottleWithRedisUsesRedisThrottleMiddlewareAlias() + // REMOVED: throttleWithRedis() and throttleApi()'s Redis argument are + // replaced by rate-limiter store configuration and named-store selection. + public function testThrottleApiAddsTheNamedLimiterToTheApiGroup(): void { $middleware = new Middleware; - $middleware->throttleWithRedis(); - - $this->assertSame( - \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class, - $middleware->getMiddlewareAliases()['throttle'] - ); - } - - public function testThrottleApiWithRedisUsesRedisThrottleMiddlewareAlias() - { - $middleware = new Middleware; - $middleware->throttleApi(redis: true); + $middleware->throttleApi('api'); + $this->assertSame([ + 'throttle:api', + \Hypervel\Routing\Middleware\SubstituteBindings::class, + ], $middleware->getMiddlewareGroups()['api']); $this->assertSame( - \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class, + \Hypervel\Routing\Middleware\ThrottleRequests::class, $middleware->getMiddlewareAliases()['throttle'] ); } @@ -455,7 +450,6 @@ public function testDefaultMiddlewarePriority() \Hypervel\Auth\Middleware\UseGuard::class, \Hypervel\Contracts\Auth\Middleware\AuthenticatesRequests::class, \Hypervel\Routing\Middleware\ThrottleRequests::class, - \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class, \Hypervel\Contracts\Session\Middleware\AuthenticatesSessions::class, \Hypervel\Routing\Middleware\SubstituteBindings::class, \Hypervel\Auth\Middleware\Authorize::class, diff --git a/tests/Inertia/InertiaServiceProviderTest.php b/tests/Inertia/InertiaServiceProviderTest.php index 76dd898a5..9f8d5e527 100644 --- a/tests/Inertia/InertiaServiceProviderTest.php +++ b/tests/Inertia/InertiaServiceProviderTest.php @@ -4,11 +4,11 @@ namespace Hypervel\Tests\Inertia; -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Contracts\Http\Kernel as HttpKernelContract; use Hypervel\Http\Request; use Hypervel\Inertia\InertiaServiceProvider; use Hypervel\Inertia\Middleware\EnsureGetOnRedirect; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Support\Facades\Route; @@ -71,9 +71,6 @@ public function testRedirectMiddlewareRegistersThroughTheKernelContract(): void public function testRedirectResponseFromRateLimiterIsConvertedTo303(): void { - // Use worker-array because the throttle counter must survive both requests. - config(['cache.limiter' => 'worker-array']); - RateLimiter::for('api', fn () => Limit::perMinute(1)->response(fn () => back())); // Needed for the web middleware diff --git a/tests/Integration/Http/ThrottleRequestsRedisStoreTest.php b/tests/Integration/Http/ThrottleRequestsRedisStoreTest.php new file mode 100644 index 000000000..f9a716d7d --- /dev/null +++ b/tests/Integration/Http/ThrottleRequestsRedisStoreTest.php @@ -0,0 +1,63 @@ +app->make(RateLimiter::class); + $manager->for('redis', fn () => Limit::perSecond(2)->by('route'), store: 'redis'); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('redis')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 1); + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 0); + $this->get('/') + ->assertTooManyRequests() + ->assertHeader('Retry-After', 1) + ->assertHeader('X-RateLimit-Remaining', 0); + + usleep(1_100_000); + + $this->get('/')->assertOk(); + } + + public function testResponseBasedLimitUsesRedisInspectionAndConditionalConsumption(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('not-found', fn () => Limit::perMinute(1) + ->by('not-found') + ->after(fn (Response $response): bool => $response->getStatusCode() === 404), store: 'redis'); + + Route::get('/', fn (Request $request) => $request->query('missing') === 'yes' + ? new Response('missing', 404) + : new Response('ok')) + ->middleware(ThrottleRequests::using('not-found')); + + $this->get('/')->assertOk(); + $this->get('/')->assertOk(); + $this->get('/?missing=yes') + ->assertNotFound() + ->assertHeader('X-RateLimit-Remaining', 0); + $this->get('/')->assertTooManyRequests(); + } +} diff --git a/tests/Integration/Http/ThrottleRequestsTest.php b/tests/Integration/Http/ThrottleRequestsTest.php new file mode 100644 index 000000000..65608d575 --- /dev/null +++ b/tests/Integration/Http/ThrottleRequestsTest.php @@ -0,0 +1,339 @@ + 'yes')->middleware(ThrottleRequests::class . ':2,1'); + + $this->get('/') + ->assertOk() + ->assertContent('yes') + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 1); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 0); + + CarbonImmutable::setTestNow('2000-01-01 00:00:58'); + + $this->get('/') + ->assertTooManyRequests() + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 0) + ->assertHeader('Retry-After', 2) + ->assertHeader('X-RateLimit-Reset', CarbonImmutable::now()->addSeconds(2)->getTimestamp()); + + CarbonImmutable::setTestNow('2000-01-01 00:01:00'); + + $this->get('/')->assertOk(); + } + + public function testNamedLimiterUsesItsRegisteredStore(): void + { + config([ + 'rate-limiter.stores.routes' => [ + 'driver' => 'worker-array', + ], + ]); + + $manager = $this->app->make(RateLimiter::class); + $policy = Limit::perMinute(1)->by('uploads'); + $manager->for('uploads', fn (): Limit => $policy, store: 'routes'); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('uploads')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 1) + ->assertHeader('X-RateLimit-Remaining', 0); + + $this->assertTrue($manager->store('routes')->inspect($policy, 'uploads')->denied()); + $this->assertTrue($manager->store()->inspect($policy, 'uploads')->allowed()); + } + + public function testNamedLimiterScopeSeparatesPoliciesUnlessTheyAreGlobal(): void + { + $scope = 'tenant-a'; + $manager = $this->app->make(RateLimiter::class); + $manager->resolveKeyScopeUsing(static function () use (&$scope): string { + return $scope; + }); + $manager->for('scoped', fn () => Limit::perMinute(1)->by('api')); + $manager->for('global', fn () => Limit::perMinute(1)->by('api')->globally()); + + Route::get('/scoped', fn (): string => 'yes')->middleware(ThrottleRequests::using('scoped')); + Route::get('/global', fn (): string => 'yes')->middleware(ThrottleRequests::using('global')); + + $this->get('/scoped')->assertOk(); + $this->get('/scoped')->assertTooManyRequests(); + $this->get('/global')->assertOk(); + + $scope = 'tenant-b'; + + $this->get('/scoped')->assertOk(); + $this->get('/global')->assertTooManyRequests(); + } + + public function testMissingNamedLimiterThrowsTheLaravelException(): void + { + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('missing')); + + $this->expectException(MissingRateLimiterException::class); + $this->expectExceptionMessage('Rate limiter [missing] is not defined.'); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testInlineLimitSelectsGuestAuthenticatedAndUserAttributeCapacities(): void + { + $middleware = new ThrottleRequests($this->app->make(RateLimiter::class)); + $guestRequest = Request::create('/guest'); + $guestRequest->setRouteResolver(fn () => new RoutingRoute('GET', '/guest', fn () => null)); + + $guestResponse = $middleware->handle( + $guestRequest, + fn (): Response => new Response('guest'), + '2|3', + ); + + $user = new ThrottleRequestsUser([ + 'id' => 1, + 'password' => 'secret', + 'remember_token' => null, + 'rateLimiting' => 1, + ]); + $userRequest = Request::create('/user'); + $userRequest->setRouteResolver(fn () => new RoutingRoute('GET', '/user', fn () => null)); + $userRequest->setUserResolver(fn (): ThrottleRequestsUser => $user); + + $authenticatedResponse = $middleware->handle( + $userRequest, + fn (): Response => new Response('user'), + '2|3', + ); + $attributeResponse = $middleware->handle( + $userRequest, + fn (): Response => new Response('user'), + 'rateLimiting', + ); + + $this->assertSame('2', $guestResponse->headers->get('X-RateLimit-Limit')); + $this->assertSame('3', $authenticatedResponse->headers->get('X-RateLimit-Limit')); + $this->assertSame('1', $attributeResponse->headers->get('X-RateLimit-Limit')); + } + + public function testNamedLimiterMayReturnAResponseOrUnlimitedPolicy(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('response', fn (): Response => new Response('limited elsewhere', 409)); + $manager->for('unlimited', fn () => Limit::none()); + + Route::get('/response', fn (): string => 'route')->middleware(ThrottleRequests::using('response')); + Route::get('/unlimited', fn (): string => 'route')->middleware(ThrottleRequests::using('unlimited')); + + $this->get('/response')->assertStatus(409)->assertContent('limited elsewhere'); + $this->get('/unlimited') + ->assertOk() + ->assertContent('route') + ->assertHeaderMissing('X-RateLimit-Limit'); + } + + // REMOVED: Laravel's zero-remaining retry fallback is replaced by the + // unused capacity returned by the atomic weighted decision. + + public function testWeightedDenialReportsTruthfulRemainingCapacity(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('uploads', fn () => Limit::perMinute(5)->cost(3)->by('uploads')); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('uploads')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 5) + ->assertHeader('X-RateLimit-Remaining', 2); + + $this->get('/') + ->assertTooManyRequests() + ->assertHeader('X-RateLimit-Limit', 5) + ->assertHeader('X-RateLimit-Remaining', 2); + } + + public function testLeakyBucketHeadersDescribeBurstCapacity(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('api', fn () => LeakyBucket::perMinute(2)->burst(4)->cost(2)->by('api')); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('api')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 4) + ->assertHeader('X-RateLimit-Remaining', 2); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 0); + + $this->get('/') + ->assertTooManyRequests() + ->assertHeader('X-RateLimit-Limit', 4) + ->assertHeader('X-RateLimit-Remaining', 0); + } + + // REMOVED: Laravel's shouldHashKeys(false) coverage does not apply because + // canonical rate-limiter identities are always hashed. + + public function testResponseBasedLimitConsumesOnlyMatchingResponses(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('not-found', fn () => Limit::perMinute(1) + ->by('not-found') + ->after(fn (Response $response): bool => $response->getStatusCode() === 404)); + + Route::get('/', fn (Request $request) => $request->query('missing') === 'yes' + ? new Response('missing', 404) + : new Response('ok')) + ->middleware(ThrottleRequests::using('not-found')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 1); + $this->get('/?missing=yes') + ->assertNotFound() + ->assertHeader('X-RateLimit-Remaining', 0); + $this->get('/')->assertTooManyRequests(); + } + + public function testConcurrentPostResponseDenialDoesNotReplaceTheAdmittedResponse(): void + { + $manager = $this->app->make(RateLimiter::class); + $policy = Limit::perMinute(1)->by('race'); + + $manager->for('race', fn () => $policy->after(function () use ($manager, $policy): bool { + $manager->store()->consume($policy, 'race'); + + return true; + })); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('race')); + + $this->get('/') + ->assertOk() + ->assertContent('yes') + ->assertHeader('X-RateLimit-Limit', 1) + ->assertHeader('X-RateLimit-Remaining', 0) + ->assertHeaderMissing('Retry-After'); + } + + // REMOVED: Laravel's preflight-all-then-hit-all behavior is replaced by + // sequential atomic policy consumption without rollback. + + public function testEarlierPoliciesRemainConsumedWhenALaterPolicyDenies(): void + { + $manager = $this->app->make(RateLimiter::class); + $first = Limit::perMinute(2)->by('first'); + $second = Limit::perMinute(1)->by('second'); + $manager->for('stacked', fn (): array => [$first, $second]); + $manager->store()->consume($second, 'stacked'); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('stacked')); + + $this->get('/')->assertTooManyRequests(); + + $this->assertSame(1, $manager->store()->inspect($first, 'stacked')->remaining()); + $this->assertSame(0, $manager->store()->inspect($second, 'stacked')->remaining()); + } + + public function testNestedSameKeyRequestsRetainTheirOwnDecisionHeaders(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('nested', fn () => Limit::perMinute(3)->by('nested')); + $middleware = new ThrottleRequests($manager); + $innerResponse = null; + + $outerResponse = $middleware->handle( + Request::create('/outer'), + function () use ($middleware, &$innerResponse): Response { + $innerResponse = $middleware->handle( + Request::create('/inner'), + fn (): Response => new Response('inner'), + 'nested', + ); + + return new Response('outer'); + }, + 'nested', + ); + + $this->assertInstanceOf(Response::class, $innerResponse); + $this->assertSame('1', $innerResponse->headers->get('X-RateLimit-Remaining')); + $this->assertSame('2', $outerResponse->headers->get('X-RateLimit-Remaining')); + } + + public function testCustomResponseReceivesLocalDecisionHeaders(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('custom', fn () => Limit::perMinute(1) + ->by('custom') + ->response(fn (Request $request, array $headers): Response => new Response( + $request->path(), + 429, + $headers, + ))); + + Route::get('/custom', fn (): string => 'yes')->middleware(ThrottleRequests::using('custom')); + + $this->get('/custom')->assertOk(); + $this->get('/custom') + ->assertTooManyRequests() + ->assertContent('custom') + ->assertHeader('X-RateLimit-Limit', 1) + ->assertHeader('X-RateLimit-Remaining', 0); + } + + public function testApplicationProvidedLowerRemainingHeaderIsPreserved(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('headers', fn () => Limit::perMinute(5)->by('headers')); + + Route::get('/', fn (): Response => new Response('yes', headers: [ + 'X-RateLimit-Limit' => 1, + 'X-RateLimit-Remaining' => 0, + ]))->middleware(ThrottleRequests::using('headers')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 1) + ->assertHeader('X-RateLimit-Remaining', 0); + } +} + +class ThrottleRequestsUser extends GenericUser +{ + public function hasAttribute(string $key): bool + { + return isset($this->{$key}); + } +} diff --git a/tests/Integration/Http/ThrottleRequestsWithRedisTest.php b/tests/Integration/Http/ThrottleRequestsWithRedisTest.php deleted file mode 100644 index c24ac7a02..000000000 --- a/tests/Integration/Http/ThrottleRequestsWithRedisTest.php +++ /dev/null @@ -1,50 +0,0 @@ -middleware(ThrottleRequestsWithRedis::class . ':2,1'); - - $response = $this->withoutExceptionHandling()->get('/'); - $this->assertSame('yes', $response->getContent()); - $this->assertEquals(2, $response->headers->get('X-RateLimit-Limit')); - $this->assertEquals(1, $response->headers->get('X-RateLimit-Remaining')); - - $response = $this->withoutExceptionHandling()->get('/'); - $this->assertSame('yes', $response->getContent()); - $this->assertEquals(2, $response->headers->get('X-RateLimit-Limit')); - $this->assertEquals(0, $response->headers->get('X-RateLimit-Remaining')); - - CarbonImmutable::setTestNow($finish = $now->addSeconds(58)); - - try { - $this->withoutExceptionHandling()->get('/'); - } catch (Throwable $e) { - $this->assertEquals(429, $e->getStatusCode()); - $this->assertEquals(2, $e->getHeaders()['X-RateLimit-Limit']); - $this->assertEquals(0, $e->getHeaders()['X-RateLimit-Remaining']); - // $this->assertTrue(in_array($e->getHeaders()['Retry-After'], [2, 3])); - // $this->assertTrue(in_array($e->getHeaders()['X-RateLimit-Reset'], [$finish->getTimestamp() + 2, $finish->getTimestamp() + 3])); - } - } -} diff --git a/tests/Routing/RoutingStaticStateTest.php b/tests/Routing/RoutingStaticStateTest.php index 54fef51ed..ad6414092 100644 --- a/tests/Routing/RoutingStaticStateTest.php +++ b/tests/Routing/RoutingStaticStateTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Routing\RoutingStaticStateTest; -use Hypervel\Routing\Middleware\ThrottleRequests; use Hypervel\Routing\PendingResourceRegistration; use Hypervel\Routing\PendingSingletonResourceRegistration; use Hypervel\Routing\Redirector; @@ -58,17 +57,4 @@ public function testRouteFlushStateClearsEnumCache(): void $this->assertSame([], $enumCache->getValue()); } - - public function testThrottleRequestsFlushStateRestoresHashedKeys(): void - { - $shouldHashKeys = new ReflectionProperty(ThrottleRequests::class, 'shouldHashKeys'); - - ThrottleRequests::shouldHashKeys(false); - - $this->assertFalse($shouldHashKeys->getValue()); - - ThrottleRequests::flushState(); - - $this->assertTrue($shouldHashKeys->getValue()); - } } diff --git a/tests/Routing/ThrottleRequestsTest.php b/tests/Routing/ThrottleRequestsTest.php index c1cf2061f..cc9300355 100644 --- a/tests/Routing/ThrottleRequestsTest.php +++ b/tests/Routing/ThrottleRequestsTest.php @@ -5,16 +5,17 @@ namespace Hypervel\Tests\Routing\ThrottleRequestsTest; use Hypervel\Auth\GenericUser; -use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\GlobalLimit; -use Hypervel\Cache\RateLimiting\Limit; -use Hypervel\Cache\Repository; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; use Hypervel\Routing\Middleware\ThrottleRequests; +use Hypervel\Routing\Route; use Hypervel\Tests\Routing\RoutingTestCase; use RuntimeException; -use Symfony\Component\HttpFoundation\Response; + +enum NamedLimiter: string +{ + case Uploads = 'uploads'; +} class ThrottleRequestsTest extends RoutingTestCase { @@ -27,120 +28,63 @@ public function testAuthenticatedIntegerIdentifierIsNormalizedForRequestSignatur 'remember_token' => null, ])); - $this->assertSame( - hash('xxh128', '123'), - (new ExposesThrottleRequestSignature)->resolveRequestSignatureForTest($request) - ); - - ThrottleRequests::shouldHashKeys(false); - $this->assertSame( '123', - (new ExposesThrottleRequestSignature)->resolveRequestSignatureForTest($request) + (new ExposesThrottleRequests)->resolveRequestSignatureForTest($request) ); } - public function testNamedLimiterUsesCentralizedScopedHash(): void + public function testRouteSignatureIsReturnedWithoutPreHashing(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => Limit::perMinute(10)->by('user-1')); - $limiter->resolveKeyScopeUsing(fn () => 'account-1'); - - (new ThrottleRequests($limiter))->handle( - Request::create('/upload'), - fn () => new Response('ok'), - 'uploads', - ); + $request = Request::create('/ping', server: ['REMOTE_ADDR' => '192.0.2.1']); + $request->setRouteResolver(fn (): Route => (new Route('GET', '/ping', fn () => null))->domain('api.example.com')); $this->assertSame( - 1, - $limiter->attempts(hash('xxh128', '9:account-17:uploads6:user-1')), + 'api.example.com|192.0.2.1', + (new ExposesThrottleRequests)->resolveRequestSignatureForTest($request) ); } - public function testNamedLimiterUsesCentralizedRawScopedKeyWhenHashingIsDisabled(): void + public function testMissingRouteCannotProduceARequestSignature(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => Limit::perMinute(10)->by('user-1')); - $limiter->resolveKeyScopeUsing(fn () => 'account-1'); - ThrottleRequests::shouldHashKeys(false); - - (new ThrottleRequests($limiter))->handle( - Request::create('/upload'), - fn () => new Response('ok'), - 'uploads', - ); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to generate the request signature. Route unavailable.'); - $this->assertSame(1, $limiter->attempts('9:account-17:uploads6:user-1')); + (new ExposesThrottleRequests)->resolveRequestSignatureForTest(Request::create('/ping')); } - public function testGlobalNamedLimiterDoesNotResolveScope(): void + public function testItCanGenerateMiddlewareDefinitions(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => new GlobalLimit(10)); - $limiter->resolveKeyScopeUsing(function (): never { - throw new RuntimeException('Scope resolver should not run.'); - }); - - (new ThrottleRequests($limiter))->handle( - Request::create('/upload'), - fn () => new Response('ok'), - 'uploads', + $this->assertSame(ThrottleRequests::class . ':uploads', ThrottleRequests::using(NamedLimiter::Uploads)); + $this->assertSame(ThrottleRequests::class . ':25', ThrottleRequests::with(25)); + $this->assertSame(ThrottleRequests::class . ':25,2', ThrottleRequests::with(25, 2)); + $this->assertSame(ThrottleRequests::class . ':25,2,foo', ThrottleRequests::with(25, 2, 'foo')); + $this->assertSame( + ThrottleRequests::class . ':25,2,foo', + ThrottleRequests::with(maxAttempts: 25, decayMinutes: 2, prefix: 'foo') ); - - $this->assertSame(1, $limiter->attempts(hash('xxh128', '7:uploads0:'))); + $this->assertSame(ThrottleRequests::class . ':60,1,foo', ThrottleRequests::with(prefix: 'foo')); } - public function testUnlimitedNamedLimiterBypassesStorage(): void + public function testFractionalMiddlewareDurationsRoundUpToAWholeSecond(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => Limit::none()); - $nextCalls = 0; - - (new ThrottleRequests($limiter))->handle( - Request::create('/upload'), - function () use (&$nextCalls): Response { - ++$nextCalls; - - return new Response('ok'); - }, - 'uploads', - ); + $middleware = new ExposesThrottleRequests; - $this->assertSame(1, $nextCalls); - $this->assertSame(0, $limiter->attempts(hash('xxh128', '7:uploads0:'))); + $this->assertSame(60, $middleware->resolveDecaySecondsForTest(1)); + $this->assertSame(30, $middleware->resolveDecaySecondsForTest('0.5')); + $this->assertSame(1, $middleware->resolveDecaySecondsForTest(0.001)); } - public function testDefaultSignatureThrottleDoesNotResolveNamedLimiterScope(): void + public function testNonNumericMiddlewareDurationIsRejected(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $scopeCalls = 0; - $limiter->resolveKeyScopeUsing(function () use (&$scopeCalls): string { - ++$scopeCalls; + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage('The rate limit decay minutes must be numeric.'); - return 'account-1'; - }); - - $request = Request::create('/upload'); - $request->setUserResolver(fn (): GenericUser => new GenericUser([ - 'id' => 123, - 'password' => 'secret', - 'remember_token' => null, - ])); - - (new ThrottleRequests($limiter))->handle( - $request, - fn () => new Response('ok'), - 10, - 1, - ); - - $this->assertSame(0, $scopeCalls); - $this->assertSame(1, $limiter->attempts(hash('xxh128', '123'))); + (new ExposesThrottleRequests)->resolveDecaySecondsForTest('invalid'); } } -class ExposesThrottleRequestSignature extends ThrottleRequests +class ExposesThrottleRequests extends ThrottleRequests { public function __construct() { @@ -150,4 +94,9 @@ public function resolveRequestSignatureForTest(Request $request): string { return $this->resolveRequestSignature($request); } + + public function resolveDecaySecondsForTest(float|int|string $decayMinutes): int + { + return $this->resolveDecaySeconds($decayMinutes); + } } From d825a9b117174c28a896d9204c32ce124725e69d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:11 +0000 Subject: [PATCH 11/41] Unify queue throttling on rate limiter stores Move RateLimited and ThrottlesExceptions onto named rate-limiter stores with serializable store selectors. Preserve release timing, dontRelease, exception predicates, reporting callbacks, and Laravel interoperability while deleting Redis-specific middleware subclasses. Use the post-failure decision when concurrent failures open the circuit. --- src/queue/composer.json | 1 + src/queue/src/Middleware/RateLimited.php | 53 +++--- .../src/Middleware/RateLimitedWithRedis.php | 107 ----------- .../src/Middleware/ThrottlesExceptions.php | 69 ++++--- .../ThrottlesExceptionsWithRedis.php | 86 --------- ...Test.php => RateLimitedRedisStoreTest.php} | 36 ++-- tests/Integration/Queue/RateLimitedTest.php | 53 +----- ... => ThrottlesExceptionsRedisStoreTest.php} | 49 +++-- .../Queue/ThrottlesExceptionsTest.php | 131 ++++++++------ tests/Queue/LaravelInteropTest.php | 11 +- .../Middleware/RateLimitedWithRedisTest.php | 104 ----------- .../ThrottlesExceptionsWithRedisTest.php | 120 ------------- tests/Queue/RateLimitedTest.php | 168 +++++++++++------- 13 files changed, 309 insertions(+), 679 deletions(-) delete mode 100644 src/queue/src/Middleware/RateLimitedWithRedis.php delete mode 100644 src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php rename tests/Integration/Queue/{RateLimitedWithRedisTest.php => RateLimitedRedisStoreTest.php} (87%) rename tests/Integration/Queue/{ThrottlesExceptionsWithRedisTest.php => ThrottlesExceptionsRedisStoreTest.php} (74%) delete mode 100644 tests/Queue/Middleware/RateLimitedWithRedisTest.php delete mode 100644 tests/Queue/Middleware/ThrottlesExceptionsWithRedisTest.php diff --git a/src/queue/composer.json b/src/queue/composer.json index 8ec24be1c..04370eb4d 100644 --- a/src/queue/composer.json +++ b/src/queue/composer.json @@ -47,6 +47,7 @@ "hypervel/log": "^0.4", "hypervel/object-pool": "^0.4", "hypervel/pipeline": "^0.4", + "hypervel/rate-limiter": "^0.4", "hypervel/redis": "^0.4", "hypervel/support": "^0.4" }, diff --git a/src/queue/src/Middleware/RateLimited.php b/src/queue/src/Middleware/RateLimited.php index 1df6f057d..5e4af3eb9 100644 --- a/src/queue/src/Middleware/RateLimited.php +++ b/src/queue/src/Middleware/RateLimited.php @@ -5,10 +5,11 @@ namespace Hypervel\Queue\Middleware; use DateTimeInterface; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Unlimited; use Hypervel\Container\Container; -use Hypervel\Support\Arr; +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\Unlimited; use Hypervel\Support\Collection; use UnitEnum; @@ -26,6 +27,11 @@ class RateLimited */ protected string $limiterName; + /** + * The rate limiter store that should be used. + */ + protected ?string $storeName = null; + /** * The number of seconds before a job should be available again if the limit is exceeded. */ @@ -65,32 +71,30 @@ public function handle(mixed $job, callable $next): mixed return $this->handleJob( $job, $next, - Collection::make(Arr::wrap($limiterResponse))->map(function ($limit) { - return (object) [ - 'key' => $this->limiter->resolveNamedLimiterKey( - $this->limiterName, - $limit, - ), - 'maxAttempts' => $limit->maxAttempts, - 'decaySeconds' => $limit->decaySeconds, - ]; - })->all() + Collection::wrap($limiterResponse)->all(), + $this->limiter->store( + $this->storeName ?? $this->limiter->limiterStore($this->limiterName) + ), ); } /** * Handle a rate limited job. + * + * @param array $limits */ - protected function handleJob(mixed $job, callable $next, array $limits): mixed + protected function handleJob(mixed $job, callable $next, array $limits, Limiter $limiter): mixed { + // Laravel preflights every policy before recording hits. Atomic stores + // consume in order, so an earlier accepted decision is never rolled back. foreach ($limits as $limit) { - if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { + $result = $limiter->consume($limit, $this->limiterName); + + if ($result->denied()) { return $this->shouldRelease - ? $job->release($this->releaseAfter ?? $this->getTimeUntilNextRetry($limit->key)) + ? $job->release($this->releaseAfter ?? $result->retryAfter() + 3) : false; } - - $this->limiter->hit($limit->key, $limit->decaySeconds); } return $next($job); @@ -116,12 +120,16 @@ public function dontRelease(): static return $this; } + // Hypervel selects Redis through the same store API as every other backend + // instead of exposing Laravel's Redis-only queue middleware and connection(). /** - * Get the number of seconds that should elapse before the job is retried. + * Specify the rate limiter store that should be used. */ - protected function getTimeUntilNextRetry(string $key): int + public function store(UnitEnum|string $store): static { - return $this->limiter->availableIn($key) + 3; + $this->storeName = (string) enum_value($store); + + return $this; } /** @@ -131,6 +139,7 @@ public function __sleep(): array { return [ 'limiterName', + 'storeName', 'releaseAfter', 'shouldRelease', ]; @@ -139,7 +148,7 @@ public function __sleep(): array /** * Prepare the object after unserialization. */ - public function __wakeup() + public function __wakeup(): void { $this->limiter = Container::getInstance() ->make(RateLimiter::class); diff --git a/src/queue/src/Middleware/RateLimitedWithRedis.php b/src/queue/src/Middleware/RateLimitedWithRedis.php deleted file mode 100644 index 117191d2b..000000000 --- a/src/queue/src/Middleware/RateLimitedWithRedis.php +++ /dev/null @@ -1,107 +0,0 @@ -connectionName = $connection; - } - - /** - * Handle a rate limited job. - */ - protected function handleJob(mixed $job, callable $next, array $limits): mixed - { - foreach ($limits as $limit) { - if ($this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds)) { - return $this->shouldRelease - ? $job->release($this->releaseAfter ?? $this->getTimeUntilNextRetry($limit->key)) - : false; - } - } - - return $next($job); - } - - /** - * Determine if the given key has been "accessed" too many times. - */ - protected function tooManyAttempts(string $key, int $maxAttempts, int $decaySeconds): bool - { - $redis = Container::getInstance() - ->make(Redis::class) - ->connection($this->connectionName); - - $limiter = new DurationLimiter( - $redis, - $key, - $maxAttempts, - $decaySeconds - ); - - return tap(! $limiter->acquire(), function () use ($key, $limiter) { - $this->decaysAt[$key] = $limiter->decaysAt; - }); - } - - /** - * Get the number of seconds that should elapse before the job is retried. - */ - protected function getTimeUntilNextRetry(string $key): int - { - return ($this->decaysAt[$key] - $this->currentTime()) + 3; - } - - /** - * Specify the Redis connection that should be used. - */ - public function connection(string $name): static - { - $this->connectionName = $name; - - return $this; - } - - /** - * Prepare the object for serialization. - */ - public function __sleep(): array - { - return array_merge(parent::__sleep(), ['connectionName']); - } - - /** - * Prepare the object after unserialization. - */ - public function __wakeup() - { - parent::__wakeup(); - } -} diff --git a/src/queue/src/Middleware/ThrottlesExceptions.php b/src/queue/src/Middleware/ThrottlesExceptions.php index 8de35b41b..fdebe5010 100644 --- a/src/queue/src/Middleware/ThrottlesExceptions.php +++ b/src/queue/src/Middleware/ThrottlesExceptions.php @@ -5,9 +5,13 @@ namespace Hypervel\Queue\Middleware; use Closure; -use Hypervel\Cache\RateLimiter; use Hypervel\Container\Container; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Throwable; +use UnitEnum; + +use function Hypervel\Support\enum_value; class ThrottlesExceptions { @@ -56,15 +60,13 @@ class ThrottlesExceptions /** * The prefix of the rate limiter key. - * - * IMPORTANT: Uses Laravel's prefix for cross-framework queue interoperability. */ - protected string $prefix = 'laravel_throttles_exceptions:'; + protected string $prefix = 'hypervel:queue:throttles-exceptions:'; /** - * The rate limiter instance. + * The rate limiter store that should be used. */ - protected $limiter; + protected ?string $storeName = null; /** * Create a new middleware instance. @@ -83,23 +85,30 @@ public function __construct( */ public function handle(mixed $job, callable $next): mixed { - $this->limiter = Container::getInstance() - ->make(RateLimiter::class); - - if ($this->limiter->tooManyAttempts($jobKey = $this->getKey($job), $this->maxAttempts)) { - return $job->release($this->getTimeUntilNextRetry($jobKey)); + $limiter = Container::getInstance() + ->make(RateLimiter::class) + ->store($this->storeName); + $policy = new Limit( + maxAttempts: $this->maxAttempts, + decaySeconds: $this->decaySeconds, + key: $this->getKey($job), + ); + $result = $limiter->inspect($policy); + + if ($result->denied()) { + return $job->release($result->retryAfter() + 3); } try { $next($job); - $this->limiter->clear($jobKey); + $limiter->clear($policy); } catch (Throwable $throwable) { - if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable, $this->limiter)) { + if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable, $limiter)) { throw $throwable; } - if ($this->reportCallback && call_user_func($this->reportCallback, $throwable, $this->limiter)) { + if ($this->reportCallback && call_user_func($this->reportCallback, $throwable, $limiter)) { report($throwable); } @@ -111,9 +120,13 @@ public function handle(mixed $job, callable $next): mixed return $job->fail($throwable); } - $this->limiter->hit($jobKey, $this->decaySeconds); + $result = $limiter->consume($policy); - return $job->release($this->getTimeUntilNextRetryAfterException($throwable)); + return $job->release( + $result->denied() + ? $result->retryAfter() + 3 + : $this->getTimeUntilNextRetryAfterException($throwable), + ); } return null; @@ -191,6 +204,18 @@ public function withPrefix(string $prefix): static return $this; } + // Hypervel selects Redis through the same store API as every other backend + // instead of exposing Laravel's Redis-only queue middleware and connection(). + /** + * Specify the rate limiter store that should be used. + */ + public function store(UnitEnum|string $store): static + { + $this->storeName = (string) enum_value($store); + + return $this; + } + /** * Specify the number of minutes a job should be delayed when it is released (before it has reached its max exceptions). */ @@ -214,7 +239,7 @@ protected function getTimeUntilNextRetryAfterException(Throwable $throwable): in } /** - * Get the cache key associated for the rate limiter. + * Get the key associated with the rate limiter. */ protected function getKey(mixed $job): string { @@ -230,7 +255,7 @@ protected function getKey(mixed $job): string ? $job->displayName() : get_class($job); - return $this->prefix . hash('xxh128', $jobName); + return $this->prefix . $jobName; } /** @@ -262,12 +287,4 @@ public function report(?callable $callback = null): static return $this; } - - /** - * Get the number of seconds that should elapse before the job is retried. - */ - protected function getTimeUntilNextRetry(string $key): int - { - return $this->limiter->availableIn($key) + 3; - } } diff --git a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php b/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php deleted file mode 100644 index e09581cec..000000000 --- a/src/queue/src/Middleware/ThrottlesExceptionsWithRedis.php +++ /dev/null @@ -1,86 +0,0 @@ -redis = Container::getInstance() - ->make(Redis::class) - ->connection($this->connectionName); - - $this->limiter = new DurationLimiter( - $this->redis, - $this->getKey($job), - $this->maxAttempts, - $this->decaySeconds - ); - - if ($this->limiter->tooManyAttempts()) { - return $job->release($this->limiter->decaysAt - $this->currentTime()); - } - - try { - $next($job); - - $this->limiter->clear(); - } catch (Throwable $throwable) { - if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable, $this->limiter)) { - throw $throwable; - } - - if ($this->reportCallback && call_user_func($this->reportCallback, $throwable, $this->limiter)) { - report($throwable); - } - - if ($this->shouldDelete($throwable)) { - return $job->delete(); - } - - if ($this->shouldFail($throwable)) { - return $job->fail($throwable); - } - - $this->limiter->acquire(); - - return $job->release($this->getTimeUntilNextRetryAfterException($throwable)); - } - - return null; - } - - /** - * Specify the Redis connection that should be used. - */ - public function connection(string $name): static - { - $this->connectionName = $name; - - return $this; - } -} diff --git a/tests/Integration/Queue/RateLimitedWithRedisTest.php b/tests/Integration/Queue/RateLimitedRedisStoreTest.php similarity index 87% rename from tests/Integration/Queue/RateLimitedWithRedisTest.php rename to tests/Integration/Queue/RateLimitedRedisStoreTest.php index 9db695298..f08131177 100644 --- a/tests/Integration/Queue/RateLimitedWithRedisTest.php +++ b/tests/Integration/Queue/RateLimitedRedisStoreTest.php @@ -2,28 +2,29 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Queue\RateLimitedWithRedisTest; +namespace Hypervel\Tests\Integration\Queue\RateLimitedRedisStoreTest; use Hypervel\Bus\Dispatcher; use Hypervel\Bus\Queueable; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Contracts\Queue\Job; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Queue\CallQueuedHandler; use Hypervel\Queue\InteractsWithQueue; -use Hypervel\Queue\Middleware\RateLimitedWithRedis; +use Hypervel\Queue\Middleware\RateLimited; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\RequiresPhpExtension; #[RequiresPhpExtension('redis')] -class RateLimitedWithRedisTest extends TestCase +// REMOVED: RateLimitedWithRedis is replaced by RateLimited::store('redis'). +class RateLimitedRedisStoreTest extends TestCase { use InteractsWithRedis; - public function testUnlimitedJobsAreExecuted() + public function testUnlimitedJobsAreExecuted(): void { $rateLimiter = $this->app->make(RateLimiter::class); @@ -51,7 +52,7 @@ public function testUnlimitedJobsAreExecutedUsingIntBackedEnum(): void $this->assertJobRanSuccessfully($testJob); } - public function testRateLimitedJobsAreNotExecutedOnLimitReached() + public function testRateLimitedJobsAreNotExecutedOnLimitReached(): void { $rateLimiter = $this->app->make(RateLimiter::class); @@ -79,7 +80,7 @@ public function testExplicitZeroReleaseDelayIsRespected(): void $this->assertJobWasReleasedAfter($testJob, 0); } - public function testRateLimitedJobsCanBeSkippedOnLimitReached() + public function testRateLimitedJobsCanBeSkippedOnLimitReached(): void { $rateLimiter = $this->app->make(RateLimiter::class); @@ -93,7 +94,7 @@ public function testRateLimitedJobsCanBeSkippedOnLimitReached() $this->assertJobWasSkipped($testJob); } - public function testJobsCanHaveConditionalRateLimits() + public function testJobsCanHaveConditionalRateLimits(): void { $rateLimiter = $this->app->make(RateLimiter::class); @@ -124,22 +125,21 @@ public function testJobsCanHaveConditionalRateLimits() $this->assertJobWasReleased($nonAdminJob); } - public function testMiddlewareSerialization() + public function testMiddlewareSerialization(): void { - $rateLimited = new RateLimitedWithRedis('limiterName', 'default'); + $rateLimited = (new RateLimited('limiterName'))->store('redis'); $rateLimited->shouldRelease = false; $restoredRateLimited = unserialize(serialize($rateLimited)); $fetch = (function (string $name) { return $this->{$name}; - })->bindTo($restoredRateLimited, RateLimitedWithRedis::class); + })->bindTo($restoredRateLimited, RateLimited::class); $this->assertFalse($restoredRateLimited->shouldRelease); $this->assertSame('limiterName', $fetch('limiterName')); - $this->assertSame('default', $fetch('connectionName')); + $this->assertSame('redis', $fetch('storeName')); $this->assertInstanceOf(RateLimiter::class, $fetch('limiter')); - // $this->assertInstanceOf(Connection::class, $fetch('redis')); } protected function assertJobRanSuccessfully(RedisRateLimitedTestJob $testJob): void @@ -240,7 +240,7 @@ public function handle(): void public function middleware(): array { - return [new RateLimitedWithRedis($this->key)]; + return [(new RateLimited($this->key))->store('redis')]; } } @@ -264,7 +264,7 @@ class RedisRateLimitedDontReleaseTestJob extends RedisRateLimitedTestJob { public function middleware(): array { - return [(new RateLimitedWithRedis($this->key))->dontRelease()]; + return [(new RateLimited($this->key))->store('redis')->dontRelease()]; } } @@ -272,7 +272,7 @@ class RedisRateLimitedZeroReleaseAfterTestJob extends RedisRateLimitedTestJob { public function middleware(): array { - return [(new RateLimitedWithRedis($this->key))->releaseAfter(0)]; + return [(new RateLimited($this->key))->store('redis')->releaseAfter(0)]; } } @@ -285,6 +285,6 @@ class RedisRateLimitedTestJobUsingBackedEnum extends RedisRateLimitedTestJob { public function middleware(): array { - return [new RateLimitedWithRedis(RedisBackedEnumNamedRateLimited::Zero)]; + return [(new RateLimited(RedisBackedEnumNamedRateLimited::Zero))->store('redis')]; } } diff --git a/tests/Integration/Queue/RateLimitedTest.php b/tests/Integration/Queue/RateLimitedTest.php index 4cc273b3b..e639c78db 100644 --- a/tests/Integration/Queue/RateLimitedTest.php +++ b/tests/Integration/Queue/RateLimitedTest.php @@ -6,16 +6,12 @@ use Hypervel\Bus\Dispatcher; use Hypervel\Bus\Queueable; -use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Limit; -use Hypervel\Cache\Repository; -use Hypervel\Container\Container; -use Hypervel\Contracts\Cache\Repository as Cache; use Hypervel\Contracts\Queue\Job; use Hypervel\Queue\CallQueuedHandler; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Middleware\RateLimited; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -58,44 +54,8 @@ public function testUnlimitedJobsAreExecutedUsingUnitEnum(): void $this->assertJobRanSuccessfully(RateLimitedTestJobUsingUnitEnum::class); } - public function testRateLimitedJobsAreNotExecutedOnLimitReached2(): void - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->andReturn(0, 1, null); - $cache->shouldReceive('add')->andReturn(true, true); - $cache->shouldReceive('increment')->andReturn(1); - $cache->shouldReceive('has')->andReturn(true); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $rateLimiter = new RateLimiter($cache); - $this->app->instance(RateLimiter::class, $rateLimiter); - $rateLimiter = $this->app->make(RateLimiter::class); - - $rateLimiter->for('test', function ($job) { - return Limit::perHour(1); - }); - - $this->assertJobRanSuccessfully(RateLimitedTestJob::class); - - // Assert Job was released and released with a delay greater than 0 - RateLimitedTestJob::$handled = false; - $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); - - $job = m::mock(Job::class); - - $job->shouldReceive('hasFailed')->once()->andReturn(false); - $job->shouldReceive('release')->once()->withArgs(function ($delay) { - return $delay >= 0; - }); - $job->shouldReceive('isReleased')->andReturn(true); - $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); - - $instance->call($job, [ - 'command' => serialize($command = new RateLimitedTestJob), - ]); - - $this->assertFalse(RateLimitedTestJob::$handled); - } + // REMOVED: the cache-specific multi-call fixture is replaced by the atomic + // package-store decision exercised by testRateLimitedJobsAreNotExecutedOnLimitReached(). public function testRateLimitedJobsAreNotExecutedOnLimitReached(): void { @@ -177,6 +137,7 @@ public function testMiddlewareSerialization(): void $this->assertFalse($restoredRateLimited->shouldRelease); $this->assertSame('limiterName', $fetch('limiterName')); + $this->assertNull($fetch('storeName')); $this->assertInstanceOf(RateLimiter::class, $fetch('limiter')); } @@ -279,7 +240,7 @@ protected function assertJobWasSkipped(string $class): void public function testItCanLimitPerMinute(): void { - Container::getInstance()->instance(RateLimiter::class, $limiter = new RateLimiter(new Repository(new ArrayStore))); + $limiter = $this->app->make(RateLimiter::class); $limiter->for('test', fn () => Limit::perMinute(3)); $jobFactory = fn () => new class { public $released = false; @@ -322,7 +283,7 @@ public function release() public function testItCanLimitPerSecond(): void { - Container::getInstance()->instance(RateLimiter::class, $limiter = new RateLimiter(new Repository(new ArrayStore))); + $limiter = $this->app->make(RateLimiter::class); $limiter->for('test', fn () => Limit::perSecond(3)); $jobFactory = fn () => new class { public $released = false; diff --git a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php b/tests/Integration/Queue/ThrottlesExceptionsRedisStoreTest.php similarity index 74% rename from tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php rename to tests/Integration/Queue/ThrottlesExceptionsRedisStoreTest.php index 4ac4cbd09..929d41d2a 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsRedisStoreTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Queue\ThrottlesExceptionsWithRedisTest; +namespace Hypervel\Tests\Integration\Queue\ThrottlesExceptionsRedisStoreTest; use Exception; use Hypervel\Bus\Dispatcher; @@ -12,8 +12,7 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Queue\CallQueuedHandler; use Hypervel\Queue\InteractsWithQueue; -use Hypervel\Queue\Middleware\ThrottlesExceptionsWithRedis; -use Hypervel\Support\CarbonImmutable; +use Hypervel\Queue\Middleware\ThrottlesExceptions; use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -21,38 +20,32 @@ use RuntimeException; #[RequiresPhpExtension('redis')] -class ThrottlesExceptionsWithRedisTest extends TestCase +// REMOVED: ThrottlesExceptionsWithRedis is replaced by ThrottlesExceptions::store('redis'). +class ThrottlesExceptionsRedisStoreTest extends TestCase { use InteractsWithRedis; - protected function setUp(): void - { - parent::setUp(); - - CarbonImmutable::setTestNow(now()); - } - public function testCircuitIsOpenedForJobErrors(): void { - $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); - $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); - $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerRedisStoreTestJob::class, $key = Str::random()); + $this->assertJobWasReleasedImmediately(CircuitBreakerRedisStoreTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerRedisStoreTestJob::class, $key); } public function testCircuitStaysClosedForSuccessfulJobs(): void { - $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key = Str::random()); - $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); - $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + $this->assertJobRanSuccessfully(CircuitBreakerRedisStoreSuccessfulJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerRedisStoreSuccessfulJob::class, $key); + $this->assertJobRanSuccessfully(CircuitBreakerRedisStoreSuccessfulJob::class, $key); } public function testCircuitResetsAfterSuccess(): void { - $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); - $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); - $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); - $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); - $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerRedisStoreTestJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerRedisStoreSuccessfulJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerRedisStoreTestJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerRedisStoreTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerRedisStoreTestJob::class, $key); } protected function assertJobWasReleasedImmediately($class, string $key): void @@ -132,7 +125,7 @@ public function release(int $delay): static throw $expectedException; }; - $middleware = (new ThrottlesExceptionsWithRedis)->backoff( + $middleware = (new ThrottlesExceptions)->store('redis')->backoff( function (RuntimeException $throwable) use (&$receivedException): int { $receivedException = $throwable; @@ -164,7 +157,7 @@ public function release() throw new RuntimeException('Whoops!'); }; - $middleware = new ThrottlesExceptionsWithRedis; + $middleware = (new ThrottlesExceptions)->store('redis'); $middleware->report(); $middleware->handle($job, $next); @@ -177,7 +170,7 @@ public function release() } } -class CircuitBreakerWithRedisTestJob +class CircuitBreakerRedisStoreTestJob { use InteractsWithQueue; use Queueable; @@ -200,11 +193,11 @@ public function handle(): void public function middleware(): array { - return [(new ThrottlesExceptionsWithRedis(2, 10 * 60))->by($this->key)]; + return [(new ThrottlesExceptions(2, 10 * 60))->store('redis')->by($this->key)]; } } -class CircuitBreakerWithRedisSuccessfulJob +class CircuitBreakerRedisStoreSuccessfulJob { use InteractsWithQueue; use Queueable; @@ -225,6 +218,6 @@ public function handle(): void public function middleware(): array { - return [(new ThrottlesExceptionsWithRedis(2, 10 * 60))->by($this->key)]; + return [(new ThrottlesExceptions(2, 10 * 60))->store('redis')->by($this->key)]; } } diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php index b5f245e80..fdb793493 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -7,12 +7,14 @@ use Exception; use Hypervel\Bus\Dispatcher; use Hypervel\Bus\Queueable; -use Hypervel\Cache\RateLimiter; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Queue\Job; use Hypervel\Queue\CallQueuedHandler; use Hypervel\Queue\InteractsWithQueue; use Hypervel\Queue\Middleware\ThrottlesExceptions; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -349,6 +351,36 @@ function (RuntimeException $throwable) use (&$receivedException): int { $this->assertSame(300, $job->releasedAfter); } + public function testDeniedFailureConsumeUsesTheCircuitOpenDelayInsteadOfBackoff(): void + { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); + $key = 'concurrent-last-slot'; + $limiter = $this->app->make(RateLimiter::class)->store(); + $policy = Limit::perMinute(1)->by('hypervel:queue:throttles-exceptions:' . $key); + $job = new class { + public ?int $releasedAfter = null; + + public function release(int $delay): static + { + $this->releasedAfter = $delay; + + return $this; + } + }; + $middleware = (new ThrottlesExceptions(1, 60)) + ->by($key) + ->backoff(5); + + $result = $middleware->handle($job, function () use ($limiter, $policy): never { + $this->assertTrue($limiter->consume($policy)->allowed()); + + throw new RuntimeException('Whoops!'); + }); + + $this->assertSame($job, $result); + $this->assertSame(63, $job->releasedAfter); + } + public function testReportingExceptions(): void { $this->spy(ExceptionHandler::class) @@ -378,81 +410,68 @@ public function release() $middleware->handle($job, $next); } - public function testUsesJobClassNameForCacheKey(): void + public function testCallbacksReceiveTheSelectedPackageLimiter(): void { - $rateLimiter = $this->mock(RateLimiter::class); + config([ + 'rate-limiter.stores.queue' => [ + 'driver' => 'worker-array', + ], + ]); + $expected = $this->app->make(RateLimiter::class)->store('queue'); + $whenLimiter = null; + $reportLimiter = null; $job = new class { - public $released = false; - - public function release() + public function release(): static { - $this->released = true; - return $this; } }; - $expectedKey = 'laravel_throttles_exceptions:' . hash('xxh128', get_class($job)); + $middleware = (new ThrottlesExceptions) + ->store('queue') + ->when(function (RuntimeException $throwable, Limiter $limiter) use (&$whenLimiter): bool { + $whenLimiter = $limiter; - $rateLimiter->shouldReceive('tooManyAttempts') - ->once() - ->with($expectedKey, 10) - ->andReturn(false); + return true; + }) + ->report(function (RuntimeException $throwable, Limiter $limiter) use (&$reportLimiter): bool { + $reportLimiter = $limiter; - $rateLimiter->shouldReceive('hit') - ->once() - ->with($expectedKey, 600); + return false; + }); - $next = function ($job) { + $this->assertSame($job, $middleware->handle($job, function (): never { throw new RuntimeException('Whoops!'); - }; - - $middleware = new ThrottlesExceptions; - $middleware->handle($job, $next); - - $this->assertTrue($job->released); + })); + $this->assertSame($expected, $whenLimiter); + $this->assertSame($expected, $reportLimiter); } - public function testUsesDisplayNameForCacheKeyWhenAvailable(): void + public function testUsesRawJobClassNameForRateLimiterKey(): void { - $rateLimiter = $this->mock(RateLimiter::class); - $job = new class { - public $released = false; - - public function release() - { - $this->released = true; + }; - return $this; - } + $this->assertSame( + 'hypervel:queue:throttles-exceptions:' . get_class($job), + (new ExposesThrottlesExceptions)->getKeyForTest($job), + ); + } + public function testUsesRawDisplayNameForRateLimiterKeyWhenAvailable(): void + { + $job = new class { public function displayName(): string { return 'App\Actions\ThrottlesExceptionsTestAction'; } }; - $expectedKey = 'laravel_throttles_exceptions:' . hash('xxh128', 'App\Actions\ThrottlesExceptionsTestAction'); - - $rateLimiter->shouldReceive('tooManyAttempts') - ->once() - ->with($expectedKey, 10) - ->andReturn(false); - - $rateLimiter->shouldReceive('hit') - ->once() - ->with($expectedKey, 600); - - $next = function ($job) { - throw new RuntimeException('Whoops!'); - }; - - $middleware = new ThrottlesExceptions; - $middleware->handle($job, $next); - - $this->assertTrue($job->released); + $this->assertSame( + 'hypervel:queue:throttles-exceptions:App\Actions\ThrottlesExceptionsTestAction', + (new ExposesThrottlesExceptions)->getKeyForTest($job), + ); } } @@ -533,3 +552,11 @@ public function middleware(): array return [(new ThrottlesExceptions(2, 10 * 60))->by('test')]; } } + +class ExposesThrottlesExceptions extends ThrottlesExceptions +{ + public function getKeyForTest(mixed $job): string + { + return $this->getKey($job); + } +} diff --git a/tests/Queue/LaravelInteropTest.php b/tests/Queue/LaravelInteropTest.php index 5dabd31ee..ce60db10f 100644 --- a/tests/Queue/LaravelInteropTest.php +++ b/tests/Queue/LaravelInteropTest.php @@ -9,7 +9,6 @@ use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\CallQueuedHandler; -use Hypervel\Queue\Middleware\ThrottlesExceptions; use Hypervel\Queue\Middleware\WithoutOverlapping; use Hypervel\Queue\Worker; use Hypervel\Testbench\TestCase; @@ -53,14 +52,8 @@ public function displayName(): string ); } - public function testThrottlesExceptionsPrefixMatchesLaravel() - { - $middleware = new ThrottlesExceptions; - - $reflection = new ReflectionProperty($middleware, 'prefix'); - - $this->assertSame('laravel_throttles_exceptions:', $reflection->getValue($middleware)); - } + // REMOVED: ThrottlesExceptions now uses the dedicated Hypervel rate-limiter + // state format and no longer claims Laravel cache-key interoperability. public function testUniqueLockDisplayNameKeyMatchesLaravel(): void { diff --git a/tests/Queue/Middleware/RateLimitedWithRedisTest.php b/tests/Queue/Middleware/RateLimitedWithRedisTest.php deleted file mode 100644 index d7f5675f3..000000000 --- a/tests/Queue/Middleware/RateLimitedWithRedisTest.php +++ /dev/null @@ -1,104 +0,0 @@ -connection() with the configured connection name, and passes the resolved - * RedisProxy to DurationLimiter. - */ -class RateLimitedWithRedisTest extends TestCase -{ - public function testConnectionSetterIsFluent() - { - $middleware = new RateLimitedWithRedis('test-limiter'); - - $result = $middleware->connection('custom'); - - $this->assertSame($middleware, $result); - $this->assertInstanceOf(RateLimitedWithRedis::class, $result); - } - - public function testConstructorAcceptsConnectionName() - { - $middleware = new RateLimitedWithRedis('test-limiter', 'custom'); - - // Verify via serialization that connectionName is stored - $serialized = serialize($middleware); - $deserialized = unserialize($serialized); - - $this->assertInstanceOf(RateLimitedWithRedis::class, $deserialized); - } - - public function testSerializationIncludesConnectionName() - { - $middleware = new RateLimitedWithRedis('test-limiter'); - $middleware->connection('custom'); - - $sleepProps = $middleware->__sleep(); - - $this->assertContains('connectionName', $sleepProps); - } - - public function testDefaultConnectionNameIsNull() - { - $middleware = new RateLimitedWithRedis('test-limiter'); - - // Verify through tooManyAttempts — it resolves Factory and calls ->connection(null) - $redis = m::mock(Redis::class); - $redis->shouldReceive('connection') - ->once() - ->with(null) - ->andReturn($this->mockRedisProxy()); - - $this->instance(Redis::class, $redis); - - $reflection = new ReflectionMethod($middleware, 'tooManyAttempts'); - $reflection->setAccessible(true); - - $reflection->invoke($middleware, 'test-key', 10, 60); - } - - public function testTooManyAttemptsResolvesFactoryContractAndCallsConnection() - { - $middleware = new RateLimitedWithRedis('test-limiter'); - $middleware->connection('cache'); - - $redis = m::mock(Redis::class); - $redis->shouldReceive('connection') - ->once() - ->with('cache') - ->andReturn($this->mockRedisProxy()); - - $this->instance(Redis::class, $redis); - - $reflection = new ReflectionMethod($middleware, 'tooManyAttempts'); - $reflection->setAccessible(true); - - $reflection->invoke($middleware, 'test-key', 10, 60); - } - - /** - * Create a mock RedisProxy that handles DurationLimiter's eval() call. - */ - private function mockRedisProxy(): m\MockInterface|RedisProxy - { - $proxy = m::mock(RedisProxy::class); - // DurationLimiter::acquire() calls eval with the Lua script - $proxy->shouldReceive('eval') - ->andReturn([1, time() + 60, 9]); - - return $proxy; - } -} diff --git a/tests/Queue/Middleware/ThrottlesExceptionsWithRedisTest.php b/tests/Queue/Middleware/ThrottlesExceptionsWithRedisTest.php deleted file mode 100644 index 555d141b9..000000000 --- a/tests/Queue/Middleware/ThrottlesExceptionsWithRedisTest.php +++ /dev/null @@ -1,120 +0,0 @@ -connection() - * with the configured connection name, and passes the resolved RedisProxy - * to DurationLimiter. - */ -class ThrottlesExceptionsWithRedisTest extends TestCase -{ - public function testConnectionSetterIsFluent() - { - $middleware = new ThrottlesExceptionsWithRedis; - - $result = $middleware->connection('custom'); - - $this->assertSame($middleware, $result); - $this->assertInstanceOf(ThrottlesExceptionsWithRedis::class, $result); - } - - public function testDefaultConnectionNameIsNull() - { - // Verify handle() resolves Factory and calls ->connection(null) - $redis = m::mock(Redis::class); - $redis->shouldReceive('connection') - ->once() - ->with(null) - ->andReturn($this->mockRedisProxy(tooMany: false)); - - $this->instance(Redis::class, $redis); - - $middleware = new ThrottlesExceptionsWithRedis; - - $job = m::mock(); - $job->shouldReceive('getJobId')->andReturn('test-job-id'); - - $middleware->handle($job, function () { - // no-op — job succeeds - }); - } - - public function testHandleResolvesFactoryContractAndCallsConnection() - { - $redis = m::mock(Redis::class); - $redis->shouldReceive('connection') - ->once() - ->with('cache') - ->andReturn($this->mockRedisProxy(tooMany: false)); - - $this->instance(Redis::class, $redis); - - $middleware = new ThrottlesExceptionsWithRedis; - $middleware->connection('cache'); - - $job = m::mock(); - $job->shouldReceive('getJobId')->andReturn('test-job-id'); - - $middleware->handle($job, function () { - // no-op — job succeeds - }); - } - - public function testHandleReleasesJobWhenTooManyAttempts() - { - $redis = m::mock(Redis::class); - $redis->shouldReceive('connection') - ->once() - ->with(null) - ->andReturn($this->mockRedisProxy(tooMany: true)); - - $this->instance(Redis::class, $redis); - - $middleware = new ThrottlesExceptionsWithRedis; - - $job = m::mock(); - $job->shouldReceive('getJobId')->andReturn('test-job-id'); - $job->shouldReceive('release')->once(); - - $nextCalled = false; - $middleware->handle($job, function () use (&$nextCalled) { - $nextCalled = true; - }); - - $this->assertFalse($nextCalled); - } - - /** - * Create a mock RedisProxy that handles DurationLimiter's eval() calls. - */ - private function mockRedisProxy(bool $tooMany): m\MockInterface|RedisProxy - { - $proxy = m::mock(RedisProxy::class); - - if ($tooMany) { - // tooManyAttempts() Lua script returns [decaysAt, remaining] - $proxy->shouldReceive('eval') - ->andReturn([time() + 60, 0]); - } else { - // tooManyAttempts() returns remaining > 0 (not too many) - $proxy->shouldReceive('eval') - ->andReturn([time() + 60, 5]); - - // clear() calls del() - $proxy->shouldReceive('del')->andReturn(1); - } - - return $proxy; - } -} diff --git a/tests/Queue/RateLimitedTest.php b/tests/Queue/RateLimitedTest.php index 7e2b8b1b9..18c1510a6 100644 --- a/tests/Queue/RateLimitedTest.php +++ b/tests/Queue/RateLimitedTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Queue; -use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Limit; -use Hypervel\Cache\Repository; use Hypervel\Container\Container; use Hypervel\Queue\Middleware\RateLimited; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\LimitResult; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Tests\TestCase; use Mockery as m; use Mockery\MockInterface; @@ -30,70 +30,49 @@ enum RateLimitedTestUnitEnum case uploads; } -class RateLimitedTest extends TestCase +enum RateLimitedTestStore: string { - public function testConstructorAcceptsString(): void - { - $this->mockRateLimiter(); - - new RateLimited('default'); - - $this->assertTrue(true); - } - - public function testConstructorAcceptsStringBackedEnum(): void - { - $this->mockRateLimiter(); - - new RateLimited(RateLimitedTestStringEnum::Default); - - $this->assertTrue(true); - } - - public function testConstructorAcceptsUnitEnum(): void - { - $this->mockRateLimiter(); - - new RateLimited(RateLimitedTestUnitEnum::uploads); - - $this->assertTrue(true); - } + case Redis = 'redis'; +} - public function testConstructorAcceptsIntBackedEnum(): void +class RateLimitedTest extends TestCase +{ + public function testConstructorAcceptsStringsAndEnums(): void { $this->mockRateLimiter(); - new RateLimited(RateLimitedTestIntEnum::Primary); - - $this->assertTrue(true); + $this->assertInstanceOf(RateLimited::class, new RateLimited('default')); + $this->assertInstanceOf(RateLimited::class, new RateLimited(RateLimitedTestStringEnum::Default)); + $this->assertInstanceOf(RateLimited::class, new RateLimited(RateLimitedTestUnitEnum::uploads)); + $this->assertInstanceOf(RateLimited::class, new RateLimited(RateLimitedTestIntEnum::Primary)); } public function testDontReleaseSetsShouldReleaseToFalse(): void { $this->mockRateLimiter(); - $middleware = new RateLimited('default'); $this->assertTrue($middleware->shouldRelease); - - $result = $middleware->dontRelease(); - + $this->assertSame($middleware, $middleware->dontRelease()); $this->assertFalse($middleware->shouldRelease); - $this->assertSame($middleware, $result); } - public function testNamedQueueLimiterUsesCentralizedScopedHash(): void + public function testNamedLimiterUsesItsRegisteredStoreAndLimiterName(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => Limit::perMinute(10)->by('user-1')); - $limiter->resolveKeyScopeUsing(fn () => 'account-1'); - - $container = new Container; - $container->instance(RateLimiter::class, $limiter); - Container::setInstance($container); + $policy = Limit::perMinute(10)->by('user-1'); + $store = m::mock(Limiter::class); + $store->shouldReceive('consume') + ->once() + ->with($policy, 'uploads') + ->andReturn(new LimitResult(true, 10, 9, 0, 60_000_000)); + + $manager = $this->mockRateLimiter(); + $manager->shouldReceive('limiter')->with('uploads')->once()->andReturn(fn () => $policy); + $manager->shouldReceive('limiterStore')->with('uploads')->once()->andReturn('redis'); + $manager->shouldReceive('store')->with('redis')->once()->andReturn($store); $nextCalls = 0; - (new RateLimited('uploads'))->handle( + $result = (new RateLimited('uploads'))->handle( new stdClass, function () use (&$nextCalls): string { ++$nextCalls; @@ -102,21 +81,90 @@ function () use (&$nextCalls): string { }, ); + $this->assertSame('handled', $result); $this->assertSame(1, $nextCalls); - $this->assertSame( - 1, - $limiter->attempts(hash('xxh128', '9:account-17:uploads6:user-1')), - ); } - public function testUnlimitedNamedQueueLimiterBypassesStorage(): void + public function testExplicitStoreOverridesTheNamedLimiterStoreAndSurvivesSerialization(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => Limit::none()); + $policy = Limit::perMinute(10); + $store = m::mock(Limiter::class); + $store->shouldReceive('consume') + ->once() + ->with($policy, 'uploads') + ->andReturn(new LimitResult(true, 10, 9, 0, 60_000_000)); + + $manager = $this->mockRateLimiter(); + $manager->shouldReceive('limiter')->with('uploads')->once()->andReturn(fn () => $policy); + $manager->shouldReceive('limiterStore')->never(); + $manager->shouldReceive('store')->with('redis')->once()->andReturn($store); + + $middleware = unserialize(serialize( + (new RateLimited('uploads'))->store(RateLimitedTestStore::Redis) + )); + + $this->assertInstanceOf(RateLimited::class, $middleware); + $this->assertSame('handled', $middleware->handle(new stdClass, fn (): string => 'handled')); + } - $container = new Container; - $container->instance(RateLimiter::class, $limiter); - Container::setInstance($container); + public function testDeniedJobUsesDecisionRetryTimeAndThreeSecondBuffer(): void + { + $policy = Limit::perMinute(1); + $store = m::mock(Limiter::class); + $store->shouldReceive('consume') + ->once() + ->with($policy, 'uploads') + ->andReturn(new LimitResult(false, 1, 0, 7_000_000, 7_000_000)); + + $manager = $this->mockRateLimiter(); + $manager->shouldReceive('limiter')->andReturn(fn () => $policy); + $manager->shouldReceive('limiterStore')->andReturnNull(); + $manager->shouldReceive('store')->with(null)->andReturn($store); + + $job = m::mock(); + $job->shouldReceive('release')->once()->with(10)->andReturnNull(); + + $this->assertNull((new RateLimited('uploads'))->handle($job, fn () => 'handled')); + } + + // REMOVED: Laravel's preflight-all-then-hit-all behavior is replaced by + // sequential atomic policy consumption without rollback. + + public function testEarlierPoliciesRemainConsumedWhenALaterPolicyDenies(): void + { + $first = Limit::perMinute(2)->by('first'); + $second = Limit::perMinute(1)->by('second'); + $store = m::mock(Limiter::class); + $store->shouldReceive('consume') + ->once() + ->with($first, 'uploads') + ->andReturn(new LimitResult(true, 2, 1, 0, 60_000_000)); + $store->shouldReceive('consume') + ->once() + ->with($second, 'uploads') + ->andReturn(new LimitResult(false, 1, 0, 60_000_000, 60_000_000)); + + $manager = $this->mockRateLimiter(); + $manager->shouldReceive('limiter')->andReturn(fn () => [$first, $second]); + $manager->shouldReceive('limiterStore')->andReturnNull(); + $manager->shouldReceive('store')->andReturn($store); + + $job = m::mock(); + $job->shouldReceive('release')->once()->with(63)->andReturnNull(); + + $nextCalls = 0; + (new RateLimited('uploads'))->handle($job, function () use (&$nextCalls): void { + ++$nextCalls; + }); + + $this->assertSame(0, $nextCalls); + } + + public function testUnlimitedNamedQueueLimiterBypassesStorage(): void + { + $manager = $this->mockRateLimiter(); + $manager->shouldReceive('limiter')->with('uploads')->once()->andReturn(fn () => Limit::none()); + $manager->shouldReceive('store')->never(); $result = (new RateLimited('uploads'))->handle( new stdClass, @@ -124,7 +172,6 @@ public function testUnlimitedNamedQueueLimiterBypassesStorage(): void ); $this->assertSame('handled', $result); - $this->assertSame(0, $limiter->attempts(hash('xxh128', 'uploads'))); } /** @@ -136,7 +183,6 @@ protected function mockRateLimiter(): RateLimiter&MockInterface $container = new Container; $container->instance(RateLimiter::class, $limiter); - Container::setInstance($container); return $limiter; From 8e96099ab78443ee41c8105f951c8f779f9121ae Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:26 +0000 Subject: [PATCH 12/41] Move Fortify login throttling to the rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Fortify’s cache counter calls with one canonical fixed-window policy shared by inspect, consume, and clear operations. Preserve attempts, lockout, retry, username, guard, and IP scoping, plus successful-login clearing, while using typed rate-limiter results. --- src/fortify/composer.json | 2 +- src/fortify/src/LoginRateLimiter.php | 24 +++++++--- src/fortify/stubs/FortifyServiceProvider.stub | 2 +- .../AuthenticatedSessionControllerTest.php | 6 +-- tests/Fortify/LoginRateLimiterTest.php | 46 +++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 tests/Fortify/LoginRateLimiterTest.php diff --git a/src/fortify/composer.json b/src/fortify/composer.json index ec10b9019..4dd65f113 100644 --- a/src/fortify/composer.json +++ b/src/fortify/composer.json @@ -14,7 +14,6 @@ "ext-json": "*", "chillerlan/php-qrcode": "^6.0", "hypervel/auth": "^0.4", - "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", "hypervel/console": "^0.4", @@ -28,6 +27,7 @@ "hypervel/http": "^0.4", "hypervel/passkeys": "^0.4", "hypervel/queue": "^0.4", + "hypervel/rate-limiter": "^0.4", "hypervel/routing": "^0.4", "hypervel/session": "^0.4", "hypervel/support": "^0.4", diff --git a/src/fortify/src/LoginRateLimiter.php b/src/fortify/src/LoginRateLimiter.php index 3ed8ed76b..63dc3d843 100644 --- a/src/fortify/src/LoginRateLimiter.php +++ b/src/fortify/src/LoginRateLimiter.php @@ -4,8 +4,9 @@ namespace Hypervel\Fortify; -use Hypervel\Cache\RateLimiter; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\Str; class LoginRateLimiter @@ -23,7 +24,10 @@ public function __construct( */ public function attempts(Request $request): int { - return $this->limiter->attempts($this->throttleKey($request)); + $policy = $this->limit($request); + $result = $this->limiter->inspect($policy); + + return $result->limit() - $result->remaining(); } /** @@ -31,7 +35,7 @@ public function attempts(Request $request): int */ public function tooManyAttempts(Request $request): bool { - return $this->limiter->tooManyAttempts($this->throttleKey($request), 5); + return $this->limiter->inspect($this->limit($request))->denied(); } /** @@ -39,7 +43,7 @@ public function tooManyAttempts(Request $request): bool */ public function increment(Request $request): void { - $this->limiter->hit($this->throttleKey($request), 60); + $this->limiter->consume($this->limit($request)); } /** @@ -47,7 +51,7 @@ public function increment(Request $request): void */ public function availableIn(Request $request): int { - return $this->limiter->availableIn($this->throttleKey($request)); + return $this->limiter->inspect($this->limit($request))->resetAfter(); } /** @@ -55,7 +59,15 @@ public function availableIn(Request $request): int */ public function clear(Request $request): void { - $this->limiter->clear($this->throttleKey($request)); + $this->limiter->clear($this->limit($request)); + } + + /** + * Build the fixed login rate limit. + */ + private function limit(Request $request): Limit + { + return Limit::perMinute(5)->by($this->throttleKey($request)); } /** diff --git a/src/fortify/stubs/FortifyServiceProvider.stub b/src/fortify/stubs/FortifyServiceProvider.stub index ed6d0fd3b..43c7aa197 100644 --- a/src/fortify/stubs/FortifyServiceProvider.stub +++ b/src/fortify/stubs/FortifyServiceProvider.stub @@ -8,10 +8,10 @@ use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable; use Hypervel\Fortify\Fortify; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Support\ServiceProvider; use Hypervel\Support\Str; diff --git a/tests/Fortify/AuthenticatedSessionControllerTest.php b/tests/Fortify/AuthenticatedSessionControllerTest.php index 28a69c929..2875fb5c4 100644 --- a/tests/Fortify/AuthenticatedSessionControllerTest.php +++ b/tests/Fortify/AuthenticatedSessionControllerTest.php @@ -5,15 +5,13 @@ namespace Hypervel\Tests\Fortify; use Hypervel\Auth\Events\Logout; -use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\Repository; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Fortify\Contracts\LoginViewResponse; use Hypervel\Fortify\LoginRateLimiter; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Http\Request; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Support\Facades\Auth; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\Attributes\WithMigration; @@ -143,7 +141,7 @@ public static function usernameProvider(): array public function testLockoutIsScopedToGuard(): void { $loginRateLimiter = new LoginRateLimiter( - new RateLimiter(new Repository(new ArrayStore)) + $this->app->make(RateLimiter::class) ); $request = Request::create('/login', 'POST', [ diff --git a/tests/Fortify/LoginRateLimiterTest.php b/tests/Fortify/LoginRateLimiterTest.php new file mode 100644 index 000000000..7892846ee --- /dev/null +++ b/tests/Fortify/LoginRateLimiterTest.php @@ -0,0 +1,46 @@ +app->make(LoginRateLimiter::class); + $request = Request::create('/login', 'POST', [ + 'email' => 'taylor@example.com', + ], server: [ + 'REMOTE_ADDR' => '192.0.2.1', + ]); + + $this->assertSame(0, $limiter->attempts($request)); + $this->assertFalse($limiter->tooManyAttempts($request)); + $this->assertSame(0, $limiter->availableIn($request)); + + for ($attempt = 1; $attempt <= 5; ++$attempt) { + $limiter->increment($request); + + $this->assertSame($attempt, $limiter->attempts($request)); + } + + $this->assertTrue($limiter->tooManyAttempts($request)); + $this->assertSame(60, $limiter->availableIn($request)); + + CarbonImmutable::setTestNow('2000-01-01 00:00:30'); + + $this->assertSame(30, $limiter->availableIn($request)); + + $limiter->clear($request); + + $this->assertSame(0, $limiter->attempts($request)); + $this->assertFalse($limiter->tooManyAttempts($request)); + $this->assertSame(0, $limiter->availableIn($request)); + } +} From 8f760d8c9d1e8af7bce28ea3710efb5ff1915943 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:41 +0000 Subject: [PATCH 13/41] Move exception throttling to the rate limiter Use AdmissionPolicy and atomic limiter attempts for exception-report throttling. Remove redundant key hashing and the hash opt-out while preserving Lottery, Unlimited, custom keys, and application overrides through the broader typed policy return. --- src/foundation/composer.json | 1 + src/foundation/src/Exceptions/Handler.php | 24 +++--- .../FoundationExceptionsHandlerTest.php | 80 +++++++++++-------- 3 files changed, 60 insertions(+), 45 deletions(-) diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 821402e2f..d5db0d9e1 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -62,6 +62,7 @@ "hypervel/macroable": "^0.4", "hypervel/prompts": "^0.4", "hypervel/queue": "^0.4", + "hypervel/rate-limiter": "^0.4", "hypervel/reflection": "^0.4", "hypervel/routing": "^0.4", "hypervel/server": "^0.4", diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index d4b539789..2b69c238f 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -8,9 +8,6 @@ use Exception; use Hypervel\Auth\Access\AuthorizationException; use Hypervel\Auth\AuthenticationException; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Limit; -use Hypervel\Cache\RateLimiting\Unlimited; use Hypervel\Console\View\Components\BulletList; use Hypervel\Console\View\Components\Error; use Hypervel\Context\CoroutineContext; @@ -31,6 +28,10 @@ use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; use Hypervel\Http\Response; +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\Unlimited; use Hypervel\Routing\Exceptions\BackedEnumCaseNotFoundException; use Hypervel\Routing\Router; use Hypervel\Session\Store; @@ -132,11 +133,6 @@ class Handler implements ExceptionHandlerContract */ protected array $throttleCallbacks = []; - /** - * Indicate that throttle keys should be hashed. - */ - protected bool $hashThrottleKeys = true; - /** * The callbacks that should be used during rendering. * @@ -586,11 +582,13 @@ protected function shouldntReport(Throwable $e): bool return ! $throttle($e); } + // The package hashes the complete policy identity, so Laravel's + // protected hashThrottleKeys opt-out is intentionally omitted. + $key = $throttle->key ?: 'hypervel:foundation:exceptions:' . $e::class; + return ! $this->container->make(RateLimiter::class)->attempt( - with($throttle->key ?: 'hypervel:foundation:exceptions:' . $e::class, fn ($key) => $this->hashThrottleKeys ? hash('xxh128', $key) : $key), - $throttle->maxAttempts, - fn () => true, - $throttle->decaySeconds + $throttle->by($key), + fn (): bool => true, ); }), rescue: false, report: false); } @@ -598,7 +596,7 @@ protected function shouldntReport(Throwable $e): bool /** * Throttle the given exception. */ - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { foreach ($this->throttleCallbacks as $throttleCallback) { foreach ($this->firstClosureParameterTypes($throttleCallback) as $type) { diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 856c89805..9eff62a1d 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -5,15 +5,8 @@ namespace Hypervel\Tests\Foundation; use Closure; -use DateInterval; -use DateTimeInterface; use Exception; use Hypervel\Auth\AuthenticationException; -use Hypervel\Cache\ArrayStore; -use Hypervel\Cache\NullStore; -use Hypervel\Cache\RateLimiter; -use Hypervel\Cache\RateLimiting\Limit; -use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; @@ -30,6 +23,9 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithExceptionHandling; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Routing\Redirector; use Hypervel\Routing\ResponseFactory; use Hypervel\Session\Store; @@ -54,6 +50,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; +use UnitEnum; use WeakReference; use const UPLOAD_ERR_NO_FILE; @@ -939,10 +936,13 @@ public function testItDoesNotThrottleExceptionsByDefault() $this->assertCount(100, $reported); } + // REMOVED: Laravel's hashThrottleKeys extension point; the canonical rate + // limiter always hashes the complete policy identity. + public function testItDoesNotThrottleExceptionsWhenNullReturned() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { } }; @@ -963,7 +963,7 @@ protected function throttle(Throwable $e): Lottery|Limit|null public function testItDoesNotThrottleExceptionsWhenUnlimitedLimit() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return Limit::none(); } @@ -985,7 +985,7 @@ protected function throttle(Throwable $e): Lottery|Limit|null public function testItCanSampleExceptionsByClass() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return match (true) { $e instanceof RuntimeException => Lottery::odds(2, 10), @@ -1017,7 +1017,7 @@ protected function throttle(Throwable $e): Lottery|Limit|null public function testItRescuesExceptionsWhileThrottlingAndReports() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { throw new RuntimeException('Something went wrong in the throttle method.'); } @@ -1038,7 +1038,7 @@ protected function throttle(Throwable $e): Lottery|Limit|null public function testItRescuesExceptionsIfThereIsAnIssueResolvingTheRateLimiter() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return Limit::perDay(1); } @@ -1066,7 +1066,7 @@ protected function throttle(Throwable $e): Lottery|Limit|null public function testItRescuesExceptionsIfThereIsAnIssueWithTheRateLimiter() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return Limit::perDay(1); } @@ -1077,11 +1077,14 @@ protected function throttle(Throwable $e): Lottery|Limit|null return false; }); - $this->container->instance(RateLimiter::class, $limiter = new class(new CacheRepository(new NullStore)) extends RateLimiter { - public $attempted = false; - - public function attempt(string $key, int $maxAttempts, Closure $callback, DateInterval|DateTimeInterface|int $decaySeconds = 60): mixed - { + $this->container->instance(RateLimiter::class, $limiter = new class($this->container) extends RateLimiter { + public bool $attempted = false; + + public function attempt( + AdmissionPolicy $policy, + Closure $callback, + UnitEnum|string|null $limiterName = null, + ): mixed { $this->attempted = true; throw new Exception('Unable to connect to Redis.'); @@ -1098,7 +1101,7 @@ public function attempt(string $key, int $maxAttempts, Closure $callback, DateIn public function testItCanRateLimitExceptions() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return Limit::perMinute(7); } @@ -1109,14 +1112,17 @@ protected function throttle(Throwable $e): Lottery|Limit|null return false; }); - $this->container->instance(RateLimiter::class, $limiter = new class(new CacheRepository(new ArrayStore)) extends RateLimiter { - public $attempted = 0; - - public function attempt(string $key, int $maxAttempts, Closure $callback, DateInterval|DateTimeInterface|int $decaySeconds = 60): mixed - { + $this->container->instance(RateLimiter::class, $limiter = new class($this->container) extends RateLimiter { + public int $attempted = 0; + + public function attempt( + AdmissionPolicy $policy, + Closure $callback, + UnitEnum|string|null $limiterName = null, + ): mixed { ++$this->attempted; - return parent::attempt(...func_get_args()); + return $this->store()->attempt($policy, $callback, $limiterName); } }); CarbonImmutable::setTestNow(CarbonImmutable::now()->startOfDay()); @@ -1143,7 +1149,7 @@ public function attempt(string $key, int $maxAttempts, Closure $callback, DateIn public function testRateLimitExpiresOnBoundary() { $handler = new class($this->container) extends Handler { - protected function throttle(Throwable $e): Lottery|Limit|null + protected function throttle(Throwable $e): Lottery|AdmissionPolicy|null { return Limit::perMinute(1); } @@ -1154,14 +1160,17 @@ protected function throttle(Throwable $e): Lottery|Limit|null return false; }); - $this->container->instance(RateLimiter::class, $limiter = new class(new CacheRepository(new ArrayStore)) extends RateLimiter { - public $attempted = 0; - - public function attempt(string $key, int $maxAttempts, Closure $callback, DateInterval|DateTimeInterface|int $decaySeconds = 60): mixed - { + $this->container->instance(RateLimiter::class, $limiter = new class($this->container) extends RateLimiter { + public int $attempted = 0; + + public function attempt( + AdmissionPolicy $policy, + Closure $callback, + UnitEnum|string|null $limiterName = null, + ): mixed { ++$this->attempted; - return parent::attempt(...func_get_args()); + return $this->store()->attempt($policy, $callback, $limiterName); } }); @@ -1253,6 +1262,13 @@ protected function getConfig(array $config = []): Repository { return new Repository(array_merge([ 'app' => ['url' => 'http://localhost'], + 'rate-limiter' => [ + 'default' => 'worker-array', + 'stores' => [ + 'worker-array' => ['driver' => 'worker-array'], + ], + 'prefix' => 'foundation-exceptions-test', + ], 'view' => ['config' => ['view_path' => 'view_path']], ], $config)); } From 53c91fbf7fb155e207ed829cb4df7ccfc191d52f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:14:58 +0000 Subject: [PATCH 14/41] Move Reverb limits to the rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the worker-array rate-limiter store for per-connection Reverb message limits and clear matching policy state on close. Replace Reverb’s duplicate Atomic stripe implementation with the shared pre-fork Core lock while retaining call-site ordering, deduplication, and post-release reporting coverage. --- src/reverb/composer.json | 2 +- src/reverb/src/Protocols/Pusher/Server.php | 33 ++- .../Hypervel/HypervelServerProvider.php | 6 +- .../Scaling/SwooleTableSharedState.php | 275 ++++-------------- tests/Reverb/PackageMetadataTest.php | 2 +- tests/Reverb/Protocols/Pusher/ServerTest.php | 40 ++- .../SwooleTableSharedStateLockTest.php | 234 +++------------ .../Scaling/SwooleTableSharedStateTest.php | 9 +- 8 files changed, 156 insertions(+), 445 deletions(-) diff --git a/src/reverb/composer.json b/src/reverb/composer.json index 72bbe24a9..c67bcacb2 100644 --- a/src/reverb/composer.json +++ b/src/reverb/composer.json @@ -36,7 +36,6 @@ "php": "^8.4", "hypervel/api-client": "^0.4", "hypervel/bus": "^0.4", - "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", @@ -52,6 +51,7 @@ "hypervel/log": "^0.4", "hypervel/prompts": "^0.4", "hypervel/queue": "^0.4", + "hypervel/rate-limiter": "^0.4", "hypervel/redis": "^0.4", "hypervel/routing": "^0.4", "hypervel/server": "^0.4", diff --git a/src/reverb/src/Protocols/Pusher/Server.php b/src/reverb/src/Protocols/Pusher/Server.php index fd85577c1..effa4cede 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -4,7 +4,9 @@ namespace Hypervel\Reverb\Protocols\Pusher; -use Hypervel\Cache\RateLimiter; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\Events\ConnectionClosed; use Hypervel\Reverb\Events\ConnectionEstablished; @@ -25,9 +27,9 @@ class Server { /** - * Cached rate limiter instance. + * The per-connection message limiter. */ - protected ?RateLimiter $rateLimiter = null; + protected Limiter $messageRateLimiter; /** * Create a new server instance. @@ -35,7 +37,9 @@ class Server public function __construct( protected ChannelManager $channels, protected EventHandler $handler, + RateLimiter $rateLimiter, ) { + $this->messageRateLimiter = $rateLimiter->store('worker-array'); } /** @@ -191,8 +195,7 @@ public function close(Connection $connection): void if ($connection->hasInitializedRateLimiter()) { try { - ($this->rateLimiter ??= new RateLimiter(app('cache')->store('worker-array'))) - ->clear('reverb:message:' . $connection->id()); + $this->messageRateLimiter->clear($this->messageLimit($connection)); $connection->clearRateLimiterInitialized(); } catch (Throwable $throwable) { $exception ??= $throwable; @@ -270,13 +273,9 @@ protected function ensureWithinRateLimit(Connection $connection): void return; } - $config = $connection->app()->rateLimiting(); - - $this->rateLimiter ??= new RateLimiter(app('cache')->store('worker-array')); - - $key = 'reverb:message:' . $connection->id(); + if ($this->messageRateLimiter->consume($this->messageLimit($connection))->denied()) { + $config = $connection->app()->rateLimiting(); - if ($this->rateLimiter->tooManyAttempts($key, $config['max_attempts'])) { if ($config['terminate_on_limit'] ?? false) { $connection->terminate(); } @@ -284,10 +283,20 @@ protected function ensureWithinRateLimit(Connection $connection): void throw new RateLimitExceeded; } - $this->rateLimiter->increment($key, $config['decay_seconds'] ?? 1); $connection->markRateLimiterInitialized(); } + /** + * Build the message limit for a connection. + */ + protected function messageLimit(Connection $connection): Limit + { + $config = $connection->app()->rateLimiting(); + + return Limit::perSecond($config['max_attempts'], $config['decay_seconds'] ?? 1) + ->by('reverb:message:' . $connection->id()); + } + /** * Verify the origin of the connection. */ diff --git a/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php b/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php index 0d5b3bd8d..0cce75529 100644 --- a/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php +++ b/src/reverb/src/Servers/Hypervel/HypervelServerProvider.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Core\Events\AfterWorkerStart; +use Hypervel\Core\Swoole\StripedLock; use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisProxy; use Hypervel\Reverb\Contracts\ServerProvider; @@ -64,7 +65,10 @@ public function register(): void $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - $this->app->instance(SharedState::class, new SwooleTableSharedState($table, $lockTable)); + $this->app->instance( + SharedState::class, + new SwooleTableSharedState($table, $lockTable, new StripedLock), + ); } $this->app->singleton( diff --git a/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php b/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php index 4e209598d..3c504e7e9 100644 --- a/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php +++ b/src/reverb/src/Servers/Hypervel/Scaling/SwooleTableSharedState.php @@ -5,10 +5,10 @@ namespace Hypervel\Reverb\Servers\Hypervel\Scaling; use ErrorException; +use Hypervel\Core\Swoole\StripedLock; use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Support\Facades\Log; use RuntimeException; -use Swoole\Atomic; use Swoole\Table; use Throwable; @@ -28,28 +28,6 @@ class SwooleTableSharedState implements SharedState protected const string MEMBER_SMOOTHING_KEY_TYPE = 'p'; - /** - * Number of striped locks for inter-worker row lifecycle protection. - */ - protected const int STRIPE_COUNT = 64; - - // Late-bound so deterministic test subclasses can shorten the spin phase. - protected const int SPINS_BEFORE_BACKOFF = 64; - - // Late-bound so deterministic test subclasses can shorten the timeout. - protected const int LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 1_000_000_000; - - /** - * Striped Atomic locks for inter-worker row lifecycle operations. - * - * Prevents races where one worker's decr+del interleaves with another - * worker's ensureRowExists+incr on the same key. Created before fork - * so they're shared across all workers via shared memory. - * - * @var list - */ - protected array $locks; - protected int $hashSeed; /** @@ -60,18 +38,15 @@ class SwooleTableSharedState implements SharedState * * @param Table $table Main counter table (subscription counts, connection slots) * @param Table $lockTable Webhook throttle/dedupe lock table (timestamp-based TTLs) + * @param StripedLock $locks Inter-worker row lifecycle locks created before fork */ public function __construct( protected Table $table, protected Table $lockTable, + protected StripedLock $locks, int $hashSeed = 0, ) { $this->hashSeed = $hashSeed ?: random_int(1, PHP_INT_MAX); - - $this->locks = array_map( - fn () => new Atomic(0), - range(0, self::STRIPE_COUNT - 1), - ); } /** @@ -86,16 +61,17 @@ public function subscribe(string $appId, string $channel, ?string $userId = null $memberAdded = false; } else { $userKey = $this->physicalKey(self::USER_KEY_TYPE, $appId, $channel, $userId); - $locks = $this->locksFor($channelKey, $userKey); - $this->acquireAll($locks); - - try { - $this->ensurePresenceRowsExist($channelKey, $userKey); - $newCount = $this->table->incr($channelKey, 'count', 1); - $userCount = $this->table->incr($userKey, 'count', 1); - } finally { - $this->releaseAll($locks); - } + [$newCount, $userCount] = $this->locks->withLocks( + [$channelKey, $userKey], + function () use ($channelKey, $userKey): array { + $this->ensurePresenceRowsExist($channelKey, $userKey); + + return [ + $this->table->incr($channelKey, 'count', 1), + $this->table->incr($userKey, 'count', 1), + ]; + }, + ); $memberAdded = ($userCount === 1); } @@ -121,16 +97,17 @@ public function unsubscribe(string $appId, string $channel, ?string $userId = nu $memberRemoved = false; } else { $userKey = $this->physicalKey(self::USER_KEY_TYPE, $appId, $channel, $userId); - $locks = $this->locksFor($channelKey, $userKey); - $this->acquireAll($locks); - - try { - $this->ensurePresenceRowsExist($channelKey, $userKey); - $newCount = $this->decrAndCleanup($channelKey); - $userCount = $this->decrAndCleanup($userKey); - } finally { - $this->releaseAll($locks); - } + [$newCount, $userCount] = $this->locks->withLocks( + [$channelKey, $userKey], + function () use ($channelKey, $userKey): array { + $this->ensurePresenceRowsExist($channelKey, $userKey); + + return [ + $this->decrAndCleanup($channelKey), + $this->decrAndCleanup($userKey), + ]; + }, + ); $memberRemoved = ($userCount <= 0); } @@ -168,10 +145,7 @@ public function releaseConnectionSlot(string $appId): void { $key = $this->physicalKey(self::CONNECTION_KEY_TYPE, $appId); - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { + $this->locks->withLock($key, function () use ($key): void { if (! $this->table->exists($key)) { return; } @@ -181,9 +155,7 @@ public function releaseConnectionSlot(string $appId): void if ($newCount <= 0) { $this->table->del($key); } - } finally { - $this->release($lock); - } + }); } /** @@ -256,14 +228,11 @@ public function tryCacheMissLock(string $appId, string $channel, int $ttlMs = 10 public function clearCacheMissLock(string $appId, string $channel): void { $key = $this->physicalKey(self::CACHE_MISS_LOCK_KEY_TYPE, $appId, $channel); - $lock = $this->lockFor($key); - $this->acquire($lock); - try { - $this->lockTable->del($key); - } finally { - $this->release($lock); - } + $this->locks->withLock( + $key, + fn (): bool => $this->lockTable->del($key), + ); } /** @@ -272,14 +241,11 @@ public function clearCacheMissLock(string $appId, string $channel): void public function clearSubscriptionCountLock(string $appId, string $channel): void { $key = $this->physicalKey(self::SUBSCRIPTION_COUNT_LOCK_KEY_TYPE, $appId, $channel); - $lock = $this->lockFor($key); - $this->acquire($lock); - try { - $this->lockTable->del($key); - } finally { - $this->release($lock); - } + $this->locks->withLock( + $key, + fn (): bool => $this->lockTable->del($key), + ); } /** @@ -288,15 +254,10 @@ public function clearSubscriptionCountLock(string $appId, string $channel): void public function setSmoothingPending(string $appId, string $channel, int $ttlMs): void { $key = $this->physicalKey(self::CHANNEL_SMOOTHING_KEY_TYPE, $appId, $channel); - $lock = $this->lockFor($key); - $this->acquire($lock); - $stored = false; - - try { - $stored = $this->setLockRow($key, microtime(true)); - } finally { - $this->release($lock); - } + $stored = $this->locks->withLock( + $key, + fn (): bool => $this->setLockRow($key, microtime(true)), + ); if (! $stored) { $this->reportFullLockTable($key); @@ -320,15 +281,10 @@ public function clearSmoothingPending(string $appId, string $channel, int $ttlMs public function setMemberSmoothingPending(string $appId, string $channel, string $userId, int $ttlMs): void { $key = $this->physicalKey(self::MEMBER_SMOOTHING_KEY_TYPE, $appId, $channel, $userId); - $lock = $this->lockFor($key); - $this->acquire($lock); - $stored = false; - - try { - $stored = $this->setLockRow($key, microtime(true)); - } finally { - $this->release($lock); - } + $stored = $this->locks->withLock( + $key, + fn (): bool => $this->setLockRow($key, microtime(true)), + ); if (! $stored) { $this->reportFullLockTable($key); @@ -355,11 +311,8 @@ public function clearMemberSmoothingPending(string $appId, string $channel, stri */ protected function tryLock(string $key, int $ttlMs): bool { - $lock = $this->lockFor($key); - $this->acquire($lock); - $stored = false; - - try { + $writeFailed = false; + $stored = $this->locks->withLock($key, function () use ($key, $ttlMs, &$writeFailed): bool { $row = $this->lockTable->get($key, 'locked_at'); $now = microtime(true); @@ -368,11 +321,12 @@ protected function tryLock(string $key, int $ttlMs): bool } $stored = $this->setLockRow($key, $now); - } finally { - $this->release($lock); - } + $writeFailed = ! $stored; - if (! $stored) { + return $stored; + }); + + if ($writeFailed) { $this->reportFullLockTable($key); } @@ -416,10 +370,7 @@ protected function reportFullLockTable(string $key): void */ protected function consumeMarker(string $key, int $ttlMs): bool { - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { + return $this->locks->withLock($key, function () use ($key, $ttlMs): bool { $row = $this->lockTable->get($key, 'locked_at'); if ($row === false) { @@ -435,9 +386,7 @@ protected function consumeMarker(string $key, int $ttlMs): bool } return true; - } finally { - $this->release($lock); - } + }); } /** @@ -448,16 +397,11 @@ protected function consumeMarker(string $key, int $ttlMs): bool */ protected function atomicIncr(string $key): int { - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { + return $this->locks->withLock($key, function () use ($key): int { $this->ensureRowExists($key); return $this->table->incr($key, 'count', 1); - } finally { - $this->release($lock); - } + }); } /** @@ -468,16 +412,11 @@ protected function atomicIncr(string $key): int */ protected function atomicDecrAndCleanup(string $key): int { - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { + return $this->locks->withLock($key, function () use ($key): int { $this->ensureRowExists($key); return $this->decrAndCleanup($key); - } finally { - $this->release($lock); - } + }); } /** @@ -496,108 +435,6 @@ protected function decrAndCleanup(string $key): int return $newCount; } - /** - * Get the striped locks for two keys in deterministic order. - * - * @return list - */ - protected function locksFor(string $firstKey, string $secondKey): array - { - $firstIndex = $this->lockIndexFor($firstKey); - $secondIndex = $this->lockIndexFor($secondKey); - - if ($firstIndex === $secondIndex) { - return [$this->locks[$firstIndex]]; - } - - if ($firstIndex < $secondIndex) { - return [$this->locks[$firstIndex], $this->locks[$secondIndex]]; - } - - return [$this->locks[$secondIndex], $this->locks[$firstIndex]]; - } - - /** - * Acquire every lock in order. - * - * @param list $locks - */ - protected function acquireAll(array $locks): void - { - $acquired = []; - - try { - foreach ($locks as $lock) { - $this->acquire($lock); - $acquired[] = $lock; - } - } catch (Throwable $exception) { - $this->releaseAll($acquired); - - throw $exception; - } - } - - /** - * Release every lock in reverse order. - * - * @param list $locks - */ - protected function releaseAll(array $locks): void - { - foreach (array_reverse($locks) as $lock) { - $this->release($lock); - } - } - - /** - * Get the striped lock for a given key. - */ - protected function lockFor(string $key): Atomic - { - return $this->locks[$this->lockIndexFor($key)]; - } - - /** - * Get the striped lock index for a given key. - */ - protected function lockIndexFor(string $key): int - { - return crc32($key) % self::STRIPE_COUNT; - } - - /** - * Acquire a striped lock (spin-lock). - */ - protected function acquire(Atomic $lock): void - { - $deadline = null; - $spins = 0; - - while (! $lock->cmpset(0, 1)) { - $deadline ??= hrtime(true) + static::LOCK_ACQUIRE_TIMEOUT_NANOSECONDS; - - if (++$spins < static::SPINS_BEFORE_BACKOFF) { - continue; - } - - if (hrtime(true) >= $deadline) { - throw new RuntimeException('Timed out acquiring a Swoole table shared-state lock.'); - } - - $spins = 0; - usleep(1); - } - } - - /** - * Release a striped lock. - */ - protected function release(Atomic $lock): void - { - $lock->cmpset(1, 0); - } - /** * Ensure a row exists in the table before incrementing. * diff --git a/tests/Reverb/PackageMetadataTest.php b/tests/Reverb/PackageMetadataTest.php index c6f776278..7f9c8b62b 100644 --- a/tests/Reverb/PackageMetadataTest.php +++ b/tests/Reverb/PackageMetadataTest.php @@ -27,7 +27,6 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'ext-swoole', 'hypervel/api-client', 'hypervel/bus', - 'hypervel/cache', 'hypervel/collections', 'hypervel/console', 'hypervel/container', @@ -43,6 +42,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'hypervel/log', 'hypervel/prompts', 'hypervel/queue', + 'hypervel/rate-limiter', 'hypervel/redis', 'hypervel/routing', 'hypervel/server', diff --git a/tests/Reverb/Protocols/Pusher/ServerTest.php b/tests/Reverb/Protocols/Pusher/ServerTest.php index b01e0ed1b..a23f642b3 100644 --- a/tests/Reverb/Protocols/Pusher/ServerTest.php +++ b/tests/Reverb/Protocols/Pusher/ServerTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Reverb\Protocols\Pusher; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\RateLimiter; use Hypervel\Reverb\Connection; use Hypervel\Reverb\Contracts\WebSocketConnection; use Hypervel\Reverb\Events\ConnectionClosed; @@ -171,7 +173,11 @@ public function testReportsUnexpectedMessageFailuresWithoutChangingTheClientPayl $exceptionHandler->shouldReceive('report')->once()->with($exception); $this->app->instance(ExceptionHandler::class, $exceptionHandler); - $server = new Server($this->app->make(ChannelManager::class), $handler); + $server = new Server( + $this->app->make(ChannelManager::class), + $handler, + $this->app->make(RateLimiter::class), + ); $server->message( $connection = new FakeConnection, json_encode([ @@ -665,7 +671,7 @@ public function testRejectsAMessageWhenTheRateLimitIsExceeded(): void $this->assertFalse($connection->wasTerminated); } - public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): void + public function testMessageRateLimiterUsesWorkerArrayStore(): void { $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ 'enabled' => true, @@ -673,10 +679,13 @@ public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): void 'decay_seconds' => 60, 'terminate_on_limit' => false, ]); + $this->app['config']->set('rate-limiter.default', 'missing'); + $this->app->forgetInstance(Server::class); + $server = $this->app->make(Server::class); - $this->server->open($connection = new FakeConnection); + $server->open($connection = new FakeConnection); - $this->server->message( + $server->message( $connection, json_encode([ 'event' => 'pusher:subscribe', @@ -684,14 +693,15 @@ public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): void ]) ); - $this->assertTrue( - $this->app->make('cache')->store('worker-array')->has('reverb:message:' . $connection->id()) - ); - $this->assertFalse( - $this->app->make('cache')->store('array')->has('reverb:message:' . $connection->id()) - ); + $policy = Limit::perSecond(1, 60)->by('reverb:message:' . $connection->id()); + $result = $this->app->make(RateLimiter::class) + ->store('worker-array') + ->inspect($policy); - $this->server->message( + $this->assertTrue($result->denied()); + $this->assertSame(0, $result->remaining()); + + $server->message( $connection, json_encode([ 'event' => 'pusher:subscribe', @@ -728,16 +738,16 @@ public function testCloseClearsInitializedMessageRateLimiterState(): void ]) ); - $cache = $this->app->make('cache')->store('worker-array'); - $key = 'reverb:message:' . $connection->id(); + $limiter = $this->app->make(RateLimiter::class)->store('worker-array'); + $policy = Limit::perSecond(1, 60)->by('reverb:message:' . $connection->id()); $this->assertTrue($connection->hasInitializedRateLimiter()); - $this->assertTrue($cache->has($key)); + $this->assertTrue($limiter->inspect($policy)->denied()); $this->server->close($connection); $this->assertFalse($connection->hasInitializedRateLimiter()); - $this->assertFalse($cache->has($key)); + $this->assertTrue($limiter->inspect($policy)->allowed()); } public function testTerminatesTheConnectionWhenRateLimitIsExceededAndConfiguredToTerminate(): void diff --git a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php index 0a8ec205f..0cf8fa76d 100644 --- a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php +++ b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateLockTest.php @@ -4,12 +4,12 @@ namespace Hypervel\Tests\Reverb\Servers\Hypervel\Scaling; +use Hypervel\Core\Swoole\StripedLock; use Hypervel\Reverb\Servers\Hypervel\Scaling\SwooleTableSharedState; use Hypervel\Tests\TestCase; use RuntimeException; use Swoole\Atomic; use Swoole\Coroutine\Channel; -use Swoole\Process; use Swoole\Table; use Throwable; @@ -20,116 +20,10 @@ class SwooleTableSharedStateLockTest extends TestCase { protected bool $runTestsInCoroutine = false; - public function testContendedStripeBacksOffAndAcquiresAfterRelease(): void - { - $state = $this->createState(LockTestSharedState::class); - $state->holdLockFor('key'); - $called = false; - - run(function () use ($state, &$called): void { - go(function () use ($state): void { - usleep(5_000); - $state->releaseLockFor('key'); - }); - - $state->withLockFor('key', function () use (&$called): void { - $called = true; - }); - }); - - $this->assertTrue($called); - } - - public function testAbandonedStripeFailsWithinTheAcquisitionDeadline(): void - { - $state = $this->createState(LockTestSharedState::class); - $process = new Process(function (Process $process) use ($state): void { - try { - $state->holdLockFor('key'); - - try { - $state->withLockFor('key', fn (): bool => true); - $message = 'lock unexpectedly acquired'; - } catch (RuntimeException $exception) { - $message = $exception->getMessage(); - } - - $process->write($message); - } finally { - // Never run PHPUnit/Testbench shutdown handlers inherited from the parent. - posix_kill(getmypid(), SIGKILL); - } - }, false, SOCK_STREAM); - - $pid = $process->start(); - - if ($pid === false) { - $this->fail('Unable to start Reverb lock child.'); - } - - $process->setBlocking(false); - $message = ''; - $reaped = false; - $deadline = hrtime(true) + 250_000_000; - - try { - while ($message === '' && hrtime(true) < $deadline) { - $chunk = $process->read(); - - if (is_string($chunk) && $chunk !== '') { - $message .= $chunk; - break; - } - - if ($this->reapIfExited($pid)) { - $reaped = true; - - $chunk = $process->read(); - - if (is_string($chunk) && $chunk !== '') { - $message .= $chunk; - } - - break; - } - - usleep(1_000); - } - } finally { - if (! $reaped && Process::kill($pid, 0)) { - Process::kill($pid, SIGKILL); - } - - $process->close(); - - if (! $reaped) { - $reapDeadline = hrtime(true) + 1_000_000_000; - - while (! $this->reapIfExited($pid)) { - if (hrtime(true) >= $reapDeadline) { - throw new RuntimeException("Timed out reaping Reverb lock child [{$pid}]."); - } - - usleep(1_000); - } - } - } - - if ($message === '') { - $this->fail($reaped - ? "Reverb lock child [{$pid}] exited without reporting a message." - : "Timed out after 250ms waiting for Reverb lock child [{$pid}] to report."); - } - - $this->assertSame( - 'Timed out acquiring a Swoole table shared-state lock.', - $message, - ); - } - public function testFailedLockRowsAreReportedOnlyAfterTheirStripeIsReleased(): void { - $state = $this->createState(ReportingProbeSharedState::class); + $locks = new ProbeStripedLock; + $state = $this->createState(ReportingProbeSharedState::class, $locks); $this->assertFalse($state->tryCacheMissLock('app', 'channel')); $state->setSmoothingPending('app', 'channel', 1_000); @@ -145,20 +39,21 @@ public function testFailedLockRowsAreReportedOnlyAfterTheirStripeIsReleased(): v public function testPresenceMutationAcquiresSharedStripeOnlyOnce(): void { - $state = $this->createState(AtomicPresenceProbeSharedState::class); + $locks = new ProbeStripedLock; + $state = $this->createState(AtomicPresenceProbeSharedState::class, $locks); [$channel, $userId] = $state->presenceIdentityForSharedStripe(); $result = $state->subscribe('app', $channel, $userId); $this->assertTrue($result->channelOccupied); $this->assertTrue($result->memberAdded); - $this->assertSame(1, $state->acquisitions); - $this->assertSame(1, $state->releases); + $this->assertSame(1, $locks->acquisitions); + $this->assertSame(1, $locks->releases); } public function testOppositePresenceStripeOrderCannotDeadlock(): void { - $state = $this->createState(AtomicPresenceProbeSharedState::class); + $state = $this->createState(AtomicPresenceProbeSharedState::class, new ProbeStripedLock); [$first, $second] = $state->oppositePresenceIdentities(); $results = new Channel(2); $outcomes = []; @@ -182,37 +77,6 @@ public function testOppositePresenceStripeOrderCannotDeadlock(): void $this->assertSame([true, true], $outcomes); } - /** - * Reap the owned child if it has exited. - */ - private function reapIfExited(int $pid): bool - { - $status = 0; - $result = pcntl_waitpid($pid, $status, WNOHANG); - - if ($result === $pid) { - return true; - } - - if ($result !== -1) { - return false; - } - - $error = pcntl_get_last_error(); - - if ($error === PCNTL_ECHILD) { - return true; - } - - if ($error === PCNTL_EINTR) { - return false; - } - - throw new RuntimeException( - "Unable to reap Reverb lock child [{$pid}]: " . pcntl_strerror($error), - ); - } - /** * Create a shared-state test double with real Swoole tables. * @@ -220,7 +84,7 @@ private function reapIfExited(int $pid): bool * @param class-string $class * @return T */ - private function createState(string $class): SwooleTableSharedState + private function createState(string $class, ProbeStripedLock $locks): SwooleTableSharedState { $table = new Table(128); $table->column('count', Table::TYPE_INT); @@ -230,34 +94,7 @@ private function createState(string $class): SwooleTableSharedState $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - return new $class($table, $lockTable); - } -} - -class LockTestSharedState extends SwooleTableSharedState -{ - protected const int LOCK_ACQUIRE_TIMEOUT_NANOSECONDS = 50_000_000; - - public function holdLockFor(string $key): void - { - $this->lockFor($key)->set(1); - } - - public function releaseLockFor(string $key): void - { - $this->lockFor($key)->set(0); - } - - public function withLockFor(string $key, callable $callback): mixed - { - $lock = $this->lockFor($key); - $this->acquire($lock); - - try { - return $callback(); - } finally { - $this->release($lock); - } + return new $class($table, $lockTable, $locks); } } @@ -277,16 +114,12 @@ protected function reportFullLockTable(string $key): void { $this->reportedKeys[] = $key; $this->reportedWhileLocked = $this->reportedWhileLocked - || $this->lockFor($key)->get() !== 0; + || $this->locks->isLocked($key); } } class AtomicPresenceProbeSharedState extends SwooleTableSharedState { - public int $acquisitions = 0; - - public int $releases = 0; - /** * Find a channel/member identity whose rows share one stripe. * @@ -337,18 +170,6 @@ public function oppositePresenceIdentities(): array throw new RuntimeException('Unable to find opposite-order presence identities.'); } - protected function acquire(Atomic $lock): void - { - parent::acquire($lock); - ++$this->acquisitions; - } - - protected function release(Atomic $lock): void - { - ++$this->releases; - parent::release($lock); - } - protected function ensurePresenceRowsExist(string $channelKey, string $userKey): void { parent::ensurePresenceRowsExist($channelKey, $userKey); @@ -363,8 +184,37 @@ protected function ensurePresenceRowsExist(string $channelKey, string $userKey): private function presenceStripePair(string $channel, string $userId): array { return [ - $this->lockIndexFor($this->physicalKey(self::SUBSCRIPTION_KEY_TYPE, 'app', $channel)), - $this->lockIndexFor($this->physicalKey(self::USER_KEY_TYPE, 'app', $channel, $userId)), + $this->locks->stripe($this->physicalKey(self::SUBSCRIPTION_KEY_TYPE, 'app', $channel)), + $this->locks->stripe($this->physicalKey(self::USER_KEY_TYPE, 'app', $channel, $userId)), ]; } } + +class ProbeStripedLock extends StripedLock +{ + public int $acquisitions = 0; + + public int $releases = 0; + + public function stripe(string $key): int + { + return $this->lockIndexFor($key); + } + + public function isLocked(string $key): bool + { + return $this->lockFor($key)->get() !== 0; + } + + protected function acquire(Atomic $lock): void + { + parent::acquire($lock); + ++$this->acquisitions; + } + + protected function release(Atomic $lock): void + { + ++$this->releases; + parent::release($lock); + } +} diff --git a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateTest.php b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateTest.php index 62a1f8513..dd33ec851 100644 --- a/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateTest.php +++ b/tests/Reverb/Servers/Hypervel/Scaling/SwooleTableSharedStateTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Reverb\Servers\Hypervel\Scaling; +use Hypervel\Core\Swoole\StripedLock; use Hypervel\Reverb\Servers\Hypervel\Scaling\SwooleTableSharedState; use Hypervel\Support\Facades\Log; use Hypervel\Tests\Reverb\ReverbTestCase; @@ -26,7 +27,7 @@ protected function setUp(): void $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - $this->state = new SwooleTableSharedState($table, $lockTable); + $this->state = new SwooleTableSharedState($table, $lockTable, new StripedLock); } public function testSubscribeReturnsChannelOccupiedOnFirstSubscriber(): void @@ -196,7 +197,7 @@ public function testThrowsExceptionWhenTableIsFull(): void $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - $state = new SwooleTableSharedState($smallTable, $lockTable); + $state = new SwooleTableSharedState($smallTable, $lockTable, new StripedLock); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('reverb.servers.reverb.swoole_shared_state.rows'); @@ -242,7 +243,7 @@ public function testPresenceCreationFailureDoesNotPublishOnlyOneCounter(): void $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - $state = new FailingSecondPresenceRowSharedState($table, $lockTable); + $state = new FailingSecondPresenceRowSharedState($table, $lockTable, new StripedLock); try { $state->subscribe('app1', 'presence-channel', 'user-1'); @@ -461,7 +462,7 @@ public function testTryLockReturnsFalseWhenLockTableFull(): void $lockTable->column('locked_at', Table::TYPE_FLOAT); $lockTable->create(); - $state = new SwooleTableSharedState($table, $lockTable); + $state = new SwooleTableSharedState($table, $lockTable, new StripedLock); Log::shouldReceive('error') ->once() ->withArgs(fn (string $message): bool => str_contains($message, 'swoole_shared_state.lock_rows')); From edaf632709ba98a80f01074b424b1f31518c1bf5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:15:25 +0000 Subject: [PATCH 15/41] Decouple Cache from rate limiting and adopt coordinator timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Cache’s legacy limiter implementation, binding, config, tests, and timer wrapper now that every consumer uses the dedicated component. Move Swoole cache maintenance onto Coordinator Timer with validate-all, resolve-all, register ordering, captured stores, registration rollback, and worker-exit cancellation. Add the missing direct Swoole and coordinator package requirements and retain real worker-recycle coverage. --- src/cache/composer.json | 12 +- src/cache/src/CacheServiceProvider.php | 35 +- src/cache/src/Listeners/BaseListener.php | 11 +- .../src/Listeners/CreateSwooleTimers.php | 118 ----- .../RegisterSwooleMaintenanceTimers.php | 103 +++++ src/cache/src/RateLimiter.php | 303 ------------- src/cache/src/RateLimiting/GlobalLimit.php | 16 - src/cache/src/RateLimiting/Limit.php | 135 ------ src/cache/src/RateLimiting/Unlimited.php | 16 - src/cache/src/SwooleTimer.php | 27 -- src/foundation/config/cache.php | 12 - tests/Cache/CacheRateLimiterTest.php | 229 ---------- tests/Cache/CacheServiceProviderTest.php | 39 +- tests/Cache/CreateSwooleTimersTest.php | 298 ------------ .../SwooleMaintenanceTimerRecycleServer.php | 72 +++ .../Fixtures/SwooleTimerRecycleServer.php | 50 --- tests/Cache/LimitTest.php | 59 --- tests/Cache/RateLimiterTest.php | 331 -------------- .../RegisterSwooleMaintenanceTimersTest.php | 423 ++++++++++++++++++ ...ooleMaintenanceTimerWorkerRecycleTest.php} | 6 +- .../Cache/Redis/RedisCacheIntegrationTest.php | 14 +- 21 files changed, 619 insertions(+), 1690 deletions(-) delete mode 100644 src/cache/src/Listeners/CreateSwooleTimers.php create mode 100644 src/cache/src/Listeners/RegisterSwooleMaintenanceTimers.php delete mode 100644 src/cache/src/RateLimiter.php delete mode 100644 src/cache/src/RateLimiting/GlobalLimit.php delete mode 100644 src/cache/src/RateLimiting/Limit.php delete mode 100644 src/cache/src/RateLimiting/Unlimited.php delete mode 100644 src/cache/src/SwooleTimer.php delete mode 100644 tests/Cache/CacheRateLimiterTest.php delete mode 100644 tests/Cache/CreateSwooleTimersTest.php create mode 100644 tests/Cache/Fixtures/SwooleMaintenanceTimerRecycleServer.php delete mode 100644 tests/Cache/Fixtures/SwooleTimerRecycleServer.php delete mode 100644 tests/Cache/LimitTest.php delete mode 100644 tests/Cache/RateLimiterTest.php create mode 100644 tests/Cache/RegisterSwooleMaintenanceTimersTest.php rename tests/Cache/{SwooleTimerWorkerRecycleTest.php => SwooleMaintenanceTimerWorkerRecycleTest.php} (93%) diff --git a/src/cache/composer.json b/src/cache/composer.json index c441fb476..1f4ce3b8b 100644 --- a/src/cache/composer.json +++ b/src/cache/composer.json @@ -17,7 +17,7 @@ { "name": "Raj Siva-Rajah", "homepage": "https://github.com/binaryfire" - } + } ], "support": { "issues": "https://github.com/hypervel/components/issues", @@ -30,20 +30,22 @@ }, "require": { "php": "^8.4", - "laravel/serializable-closure": "^2.0.10", - "psr/simple-cache": "^3.0", + "ext-swoole": "^6.2", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", "hypervel/console": "^0.4", "hypervel/container": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coordinator": "^0.4", + "hypervel/core": "^0.4", "hypervel/coroutine": "^0.4", "hypervel/database": "^0.4", "hypervel/filesystem": "^0.4", - "hypervel/core": "^0.4", "hypervel/macroable": "^0.4", "hypervel/redis": "^0.4", - "hypervel/support": "^0.4" + "hypervel/support": "^0.4", + "laravel/serializable-closure": "^2.0.10", + "psr/simple-cache": "^3.0" }, "provide": { "psr/simple-cache-implementation": "3.0" diff --git a/src/cache/src/CacheServiceProvider.php b/src/cache/src/CacheServiceProvider.php index c02d07f28..640578842 100644 --- a/src/cache/src/CacheServiceProvider.php +++ b/src/cache/src/CacheServiceProvider.php @@ -10,15 +10,12 @@ use Hypervel\Cache\Console\PruneDbExpiredCommand; use Hypervel\Cache\Console\PruneStaleTagsCommand; use Hypervel\Cache\Listeners\CreateSwooleTable; -use Hypervel\Cache\Listeners\CreateSwooleTimers; +use Hypervel\Cache\Listeners\RegisterSwooleMaintenanceTimers; use Hypervel\Cache\Redis\Console\BenchmarkCommand; use Hypervel\Cache\Redis\Console\DoctorCommand; -use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerStart; -use Hypervel\Core\Events\OnWorkerExit; use Hypervel\Support\ServiceProvider; -use Throwable; class CacheServiceProvider extends ServiceProvider { @@ -31,12 +28,6 @@ public function register(): void $this->app->singleton('cache.store', fn ($app) => $app->make('cache')->driver()); - $this->app->singleton(RateLimiter::class, fn ($app) => new RateLimiter( - $app->make('cache')->driver( - $app->make('config')->get('cache.limiter') - ) - )); - $this->commands([ BenchmarkCommand::class, CacheTableCommand::class, @@ -60,29 +51,7 @@ public function boot(): void }); $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { - $this->app->make(CreateSwooleTimers::class)->handle($event); - }); - - $events->listen(OnWorkerExit::class, function (OnWorkerExit $event): void { - if ($event->workerId !== 0 || $event->server->taskworker) { - return; - } - - try { - $this->app->make(CreateSwooleTimers::class)->stop(); - } catch (Throwable $exception) { - try { - $this->app->make(ExceptionHandler::class)->report($exception); - } catch (Throwable $reportingFailure) { - try { - file_put_contents( - 'php://stderr', - (string) $exception . PHP_EOL . (string) $reportingFailure . PHP_EOL, - ); - } catch (Throwable) { - } - } - } + $this->app->make(RegisterSwooleMaintenanceTimers::class)->handle($event); }); if ($this->app->runningInConsole()) { diff --git a/src/cache/src/Listeners/BaseListener.php b/src/cache/src/Listeners/BaseListener.php index 49f3d9ae4..02e5402d8 100644 --- a/src/cache/src/Listeners/BaseListener.php +++ b/src/cache/src/Listeners/BaseListener.php @@ -4,19 +4,20 @@ namespace Hypervel\Cache\Listeners; +use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container; use Hypervel\Support\Collection; abstract class BaseListener { - public function __construct(protected Container $container) - { + public function __construct( + protected Container $container, + protected Repository $config, + ) { } protected function swooleStores(): Collection { - $config = $this->container->make('config')->array('cache.stores'); - - return collect($config)->where('driver', 'swoole'); + return collect($this->config->array('cache.stores'))->where('driver', 'swoole'); } } diff --git a/src/cache/src/Listeners/CreateSwooleTimers.php b/src/cache/src/Listeners/CreateSwooleTimers.php deleted file mode 100644 index f5fe20f83..000000000 --- a/src/cache/src/Listeners/CreateSwooleTimers.php +++ /dev/null @@ -1,118 +0,0 @@ - - */ - protected array $timerIds = []; - - public function __construct(Container $container, protected SwooleTimer $timer) - { - parent::__construct($container); - } - - /** - * Create timers for all configured Swoole cache stores. - */ - public function handle(AfterWorkerStart $event): void - { - if (! $this->shouldRegisterTimers($event)) { - return; - } - - $timerIds = []; - - try { - foreach ($this->swooleStores() as $name => $config) { - $timerId = $this->timer->tick( - $config['eviction_interval'] ?? 10000, - fn () => $this->store($name)->evictRecords(), - ); - - if ($timerId === false) { - throw new RuntimeException("Unable to register the Swoole cache eviction timer for store [{$name}]."); - } - - $timerIds[] = $timerId; - - $timerId = $this->timer->tick( - $config['interval_refresh_interval'] ?? 1000, - fn () => $this->store($name)->refreshIntervalCaches(), - ); - - if ($timerId === false) { - throw new RuntimeException("Unable to register the Swoole cache interval refresh timer for store [{$name}]."); - } - - $timerIds[] = $timerId; - } - } catch (Throwable $throwable) { - for ($index = count($timerIds) - 1; $index >= 0; --$index) { - try { - $this->timer->clear($timerIds[$index]); - } catch (Throwable) { - // Preserve the timer registration failure. - } - } - - throw $throwable; - } - - $this->timerIds = $timerIds; - } - - /** - * Stop every timer owned by this worker. - */ - public function stop(): void - { - $timerIds = $this->timerIds; - $this->timerIds = []; - $exception = null; - - for ($index = count($timerIds) - 1; $index >= 0; --$index) { - try { - if (! $this->timer->clear($timerIds[$index])) { - throw new RuntimeException("Unable to clear Swoole cache timer [{$timerIds[$index]}]."); - } - } catch (Throwable $throwable) { - $exception ??= $throwable; - } - } - - if ($exception !== null) { - throw $exception; - } - } - - /** - * Determine if this worker should own Swoole cache timers. - */ - protected function shouldRegisterTimers(AfterWorkerStart $event): bool - { - return $event->workerId === 0 && ! $event->server->taskworker; - } - - /** - * Get a Swoole cache store. - */ - protected function store(string $name): SwooleStore - { - /** @var SwooleStore */ - return $this->container->make('cache')->store($name)->getStore(); - } -} diff --git a/src/cache/src/Listeners/RegisterSwooleMaintenanceTimers.php b/src/cache/src/Listeners/RegisterSwooleMaintenanceTimers.php new file mode 100644 index 000000000..9f3f8146f --- /dev/null +++ b/src/cache/src/Listeners/RegisterSwooleMaintenanceTimers.php @@ -0,0 +1,103 @@ +workerId !== 0 || $event->server->taskworker) { + return; + } + + $storeIntervals = []; + + foreach ($this->swooleStores()->keys() as $name) { + $name = (string) $name; + $storeIntervals[$name] = [ + 'eviction' => $this->intervalInSeconds("cache.stores.{$name}.eviction_interval"), + 'refresh' => $this->intervalInSeconds("cache.stores.{$name}.interval_refresh_interval"), + ]; + } + + $stores = []; + + foreach (array_keys($storeIntervals) as $name) { + $stores[$name] = $this->store($name); + } + + $timerIds = []; + + try { + foreach ($storeIntervals as $name => $intervals) { + $store = $stores[$name]; + + $timerIds[] = $this->timer->tick( + $intervals['eviction'], + fn () => $store->evictRecords(), + ); + + $timerIds[] = $this->timer->tick( + $intervals['refresh'], + fn () => $store->refreshIntervalCaches(), + ); + } + } catch (Throwable $throwable) { + for ($index = count($timerIds) - 1; $index >= 0; --$index) { + try { + $this->timer->clear($timerIds[$index]); + } catch (Throwable) { + // Preserve the timer registration failure. + } + } + + throw $throwable; + } + } + + /** + * Get a Swoole cache store. + */ + protected function store(string $name): SwooleStore + { + /** @var SwooleStore */ + return $this->container->make('cache')->store($name)->getStore(); + } + + /** + * Get a configured maintenance interval in seconds. + */ + protected function intervalInSeconds(string $key): float + { + $milliseconds = $this->config->integer($key); + + if ($milliseconds <= 0) { + throw new InvalidArgumentException( + "Configuration value for key [{$key}] must be greater than zero." + ); + } + + return $milliseconds / 1000; + } +} diff --git a/src/cache/src/RateLimiter.php b/src/cache/src/RateLimiter.php deleted file mode 100644 index de15462bd..000000000 --- a/src/cache/src/RateLimiter.php +++ /dev/null @@ -1,303 +0,0 @@ -cache = $cache; - } - - /** - * Register a named limiter configuration. - * - * Boot-only. The callback persists on the singleton rate limiter for the - * worker lifetime and applies to every subsequent limiter() lookup; - * per-request use races across coroutines. - */ - public function for(UnitEnum|string $name, Closure $callback): static - { - $resolvedName = $this->resolveLimiterName($name); - - $this->limiters[$resolvedName] = $callback; - - return $this; - } - - /** - * Register the named limiter key scope resolver. - * - * Boot-only. The callback persists on the singleton rate limiter for the - * worker lifetime and applies to subsequent named limits across coroutines. - */ - public function resolveKeyScopeUsing(?Closure $resolver): void - { - $this->keyScopeResolver = $resolver; - } - - /** - * Get the given named rate limiter. - */ - public function limiter(UnitEnum|string $name): ?Closure - { - $resolvedName = $this->resolveLimiterName($name); - - $limiter = $this->limiters[$resolvedName] ?? null; - - if (! is_callable($limiter)) { - return null; - } - - return function (...$args) use ($limiter) { - $result = $limiter(...$args); - - if (! is_array($result)) { - return $result; - } - - $duplicates = (new Collection($result))->duplicates('key'); - - if ($duplicates->isEmpty()) { - return $result; - } - - foreach ($result as $limit) { - if ($duplicates->contains($limit->key)) { - $limit->key = $limit->fallbackKey(); - } - } - - return $result; - }; - } - - /** - * Resolve the storage key for a named rate limit. - */ - public function resolveNamedLimiterKey( - string $limiterName, - Limit $limit, - bool $shouldHashKeys = true, - ): string { - $scope = $limit instanceof GlobalLimit - ? null - : $this->keyScopeResolver?->__invoke($limiterName); - - // Length prefixes keep arbitrary segment values injective before hashing. - $key = strlen($limiterName) . ':' . $limiterName - . strlen($limit->key) . ':' . $limit->key; - - if ($scope !== null) { - $key = strlen($scope) . ':' . $scope . $key; - } - - return $shouldHashKeys - ? hash('xxh128', $key) - : $key; - } - - /** - * Attempt to execute a callback if it's not limited. - */ - public function attempt(string $key, int $maxAttempts, Closure $callback, DateInterval|DateTimeInterface|int $decaySeconds = 60): mixed - { - if ($this->tooManyAttempts($key, $maxAttempts)) { - return false; - } - - if (is_null($result = $callback())) { - $result = true; - } - - return tap($result, function () use ($key, $decaySeconds) { - $this->hit($key, $decaySeconds); - }); - } - - /** - * Determine if the given key has been "accessed" too many times. - */ - public function tooManyAttempts(string $key, int $maxAttempts): bool - { - if ($this->attempts($key) >= $maxAttempts) { - if ($this->cache->has($this->cleanRateLimiterKey($key) . ':timer')) { - return true; - } - - $this->resetAttempts($key); - } - - return false; - } - - /** - * Increment (by 1) the counter for a given key for a given decay time. - */ - public function hit(string $key, DateInterval|DateTimeInterface|int $decaySeconds = 60): int - { - return $this->increment($key, $decaySeconds); - } - - /** - * Increment the counter for a given key for a given decay time by a given amount. - */ - public function increment(string $key, DateInterval|DateTimeInterface|int $decaySeconds = 60, int $amount = 1): int - { - $key = $this->cleanRateLimiterKey($key); - - $this->cache->add( - $key . ':timer', - $this->availableAt($decaySeconds), - $decaySeconds - ); - - $added = $this->withoutSerializationOrCompression( - fn () => $this->cache->add($key, 0, $decaySeconds) - ); - - $hits = (int) $this->cache->increment($key, $amount); - - if (! $added && $hits === $amount) { - $this->withoutSerializationOrCompression( - fn () => $this->cache->put($key, $amount, $decaySeconds) - ); - } - - return $hits; - } - - /** - * Decrement the counter for a given key for a given decay time by a given amount. - */ - public function decrement(string $key, DateInterval|DateTimeInterface|int $decaySeconds = 60, int $amount = 1): int - { - return $this->increment($key, $decaySeconds, $amount * -1); - } - - /** - * Get the number of attempts for the given key. - */ - public function attempts(string $key): mixed - { - $key = $this->cleanRateLimiterKey($key); - - return $this->withoutSerializationOrCompression(fn () => $this->cache->get($key, 0)); - } - - /** - * Reset the number of attempts for the given key. - */ - public function resetAttempts(string $key): bool - { - $key = $this->cleanRateLimiterKey($key); - - return $this->cache->forget($key); - } - - /** - * Get the number of retries left for the given key. - */ - public function remaining(string $key, int $maxAttempts): int - { - $key = $this->cleanRateLimiterKey($key); - - $attempts = $this->attempts($key); - - return max(0, $maxAttempts - $attempts); - } - - /** - * Get the number of retries left for the given key. - */ - public function retriesLeft(string $key, int $maxAttempts): int - { - return $this->remaining($key, $maxAttempts); - } - - /** - * Clear the hits and lockout timer for the given key. - */ - public function clear(string $key): void - { - $key = $this->cleanRateLimiterKey($key); - - $this->resetAttempts($key); - - $this->cache->forget($key . ':timer'); - } - - /** - * Get the number of seconds until the "key" is accessible again. - */ - public function availableIn(string $key): int - { - $key = $this->cleanRateLimiterKey($key); - - return max(0, $this->cache->get($key . ':timer') - $this->currentTime()); - } - - /** - * Clean the rate limiter key from unicode characters. - */ - public function cleanRateLimiterKey(string $key): string - { - return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key)); - } - - /** - * Execute the given callback without serialization or compression when applicable. - */ - protected function withoutSerializationOrCompression(callable $callback): mixed - { - $store = $this->cache->getStore(); - - if (! $store instanceof RedisStore) { - return $callback(); - } - - return $store->connection()->withoutSerializationOrCompression($callback); - } - - /** - * Resolve the rate limiter name. - */ - private function resolveLimiterName(UnitEnum|string $name): string - { - return (string) enum_value($name); - } -} diff --git a/src/cache/src/RateLimiting/GlobalLimit.php b/src/cache/src/RateLimiting/GlobalLimit.php deleted file mode 100644 index d43fe2390..000000000 --- a/src/cache/src/RateLimiting/GlobalLimit.php +++ /dev/null @@ -1,16 +0,0 @@ -key = $key; - $this->maxAttempts = $maxAttempts; - $this->decaySeconds = $decaySeconds; - } - - /** - * Create a new rate limit. - */ - public static function perSecond(int $maxAttempts, int $decaySeconds = 1): static - { - return new static('', $maxAttempts, $decaySeconds); - } - - /** - * Create a new rate limit. - */ - public static function perMinute(int $maxAttempts, int $decayMinutes = 1): static - { - return new static('', $maxAttempts, 60 * $decayMinutes); - } - - /** - * Create a new rate limit using minutes as decay time. - */ - public static function perMinutes(int $decayMinutes, int $maxAttempts): static - { - return new static('', $maxAttempts, 60 * $decayMinutes); - } - - /** - * Create a new rate limit using hours as decay time. - */ - public static function perHour(int $maxAttempts, int $decayHours = 1): static - { - return new static('', $maxAttempts, 60 * 60 * $decayHours); - } - - /** - * Create a new rate limit using days as decay time. - */ - public static function perDay(int $maxAttempts, int $decayDays = 1): static - { - return new static('', $maxAttempts, 60 * 60 * 24 * $decayDays); - } - - /** - * Create a new unlimited rate limit. - */ - public static function none(): Unlimited - { - return new Unlimited; - } - - /** - * Set the key of the rate limit. - */ - public function by(mixed $key): static - { - $this->key = $key; - - return $this; - } - - /** - * Set the callback to determine if the limiter should be hit. - */ - public function after(callable $callback): static - { - $this->afterCallback = $callback; - - return $this; - } - - /** - * Set the callback that should generate the response when the limit is exceeded. - */ - public function response(callable $callback): static - { - $this->responseCallback = $callback; - - return $this; - } - - /** - * Get a potential fallback key for the limit. - */ - public function fallbackKey(): string - { - $prefix = $this->key ? "{$this->key}:" : ''; - - return "{$prefix}attempts:{$this->maxAttempts}:decay:{$this->decaySeconds}"; - } -} diff --git a/src/cache/src/RateLimiting/Unlimited.php b/src/cache/src/RateLimiting/Unlimited.php deleted file mode 100644 index 34937e33c..000000000 --- a/src/cache/src/RateLimiting/Unlimited.php +++ /dev/null @@ -1,16 +0,0 @@ - env('CACHE_STORE', 'database'), - /* - |-------------------------------------------------------------------------- - | Rate Limiter Cache Store - |-------------------------------------------------------------------------- - | - | This option controls the cache store used by the rate limiter. When - | this option is not configured, the default cache store is used. - | - */ - - 'limiter' => env('CACHE_LIMITER'), - /* |-------------------------------------------------------------------------- | Cache Stores diff --git a/tests/Cache/CacheRateLimiterTest.php b/tests/Cache/CacheRateLimiterTest.php deleted file mode 100644 index c7bee2a18..000000000 --- a/tests/Cache/CacheRateLimiterTest.php +++ /dev/null @@ -1,229 +0,0 @@ -shouldReceive('get')->once()->with('key', 0)->andReturn(1); - $cache->shouldReceive('has')->once()->with('key:timer')->andReturn(true); - $cache->shouldReceive('add')->never(); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $this->assertTrue($rateLimiter->tooManyAttempts('key', 1)); - } - - public function testHitProperlyIncrementsAttemptCount() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true); - $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true); - $cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->hit('key', 1); - } - - public function testIncrementProperlyIncrementsAttemptCount() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true); - $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true); - $cache->shouldReceive('increment')->once()->with('key', 5)->andReturn(5); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->increment('key', 1, 5); - } - - public function testDecrementProperlyDecrementsAttemptCount() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true); - $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true); - $cache->shouldReceive('increment')->once()->with('key', -5)->andReturn(-5); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->decrement('key', 1, 5); - } - - public function testHitHasNoMemoryLeak() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true); - $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(false); - $cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1); - $cache->shouldReceive('put')->once()->with('key', 1, 1); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->hit('key', 1); - } - - public function testIncrementWithCustomAmountHasNoMemoryLeak(): void - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 60)->andReturnTrue(); - $cache->shouldReceive('add')->once()->with('key', 0, 60)->andReturnFalse(); - $cache->shouldReceive('increment')->once()->with('key', 2)->andReturn(2); - $cache->shouldReceive('put')->once()->with('key', 2, 60); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - (new RateLimiter($cache))->increment('key', 60, 2); - } - - public function testRemainingIsNotNegative() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->with('key', 0)->andReturn(5); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $rateLimiter = new RateLimiter($cache); - - $this->assertSame(0, $rateLimiter->remaining('key', 3)); - $this->assertSame(0, $rateLimiter->retriesLeft('key', 3)); - } - - public function testRetriesLeftReturnsCorrectCount() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(3); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $this->assertEquals(2, $rateLimiter->retriesLeft('key', 5)); - } - - public function testClearClearsTheCacheKeys() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('forget')->once()->with('key'); - $cache->shouldReceive('forget')->once()->with('key:timer'); - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->clear('key'); - } - - public function testAvailableInReturnsPositiveValues() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->andReturn(now()->subSeconds(60)->getTimestamp(), null); - $rateLimiter = new RateLimiter($cache); - - $this->assertTrue($rateLimiter->availableIn('key:timer') >= 0); - $this->assertTrue($rateLimiter->availableIn('key:timer') >= 0); - } - - public function testAttemptsCallbackReturnsTrue() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(0); - $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1); - $cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturns(1); - $cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $executed = false; - - $rateLimiter = new RateLimiter($cache); - - $rateLimiter->attempt('key', 1, function () use (&$executed) { - $executed = true; - }, 1); - $this->assertTrue($executed); - } - - public function testAttemptsCallbackReturnsCallbackReturn() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->times(6)->with('key', 0)->andReturn(0); - $cache->shouldReceive('add')->times(6)->with('key:timer', m::type('int'), 1); - $cache->shouldReceive('add')->times(6)->with('key', 0, 1)->andReturns(1); - $cache->shouldReceive('increment')->times(6)->with('key', 1)->andReturn(1); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $rateLimiter = new RateLimiter($cache); - - $this->assertSame('foo', $rateLimiter->attempt('key', 1, function () { - return 'foo'; - }, 1)); - - $this->assertSame(false, $rateLimiter->attempt('key', 1, function () { - return false; - }, 1)); - - $this->assertSame([], $rateLimiter->attempt('key', 1, function () { - return []; - }, 1)); - - $this->assertSame(0, $rateLimiter->attempt('key', 1, function () { - return 0; - }, 1)); - - $this->assertSame(0.0, $rateLimiter->attempt('key', 1, function () { - return 0.0; - }, 1)); - - $this->assertSame('', $rateLimiter->attempt('key', 1, function () { - return ''; - }, 1)); - } - - public function testAttemptsCallbackReturnsFalse() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->once()->with('key', 0)->andReturn(2); - $cache->shouldReceive('has')->once()->with('key:timer')->andReturn(true); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $executed = false; - - $rateLimiter = new RateLimiter($cache); - - $this->assertFalse($rateLimiter->attempt('key', 1, function () use (&$executed) { - $executed = true; - }, 1)); - $this->assertFalse($executed); - } - - public function testKeysAreSanitizedFromUnicodeCharacters() - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->once()->with('john', 0)->andReturn(1); - $cache->shouldReceive('has')->once()->with('john:timer')->andReturn(true); - $cache->shouldReceive('add')->never(); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - $rateLimiter = new RateLimiter($cache); - - $this->assertTrue($rateLimiter->tooManyAttempts('jôhn', 1)); - } - - public function testKeyIsSanitizedOnlyOnce() - { - $cache = m::mock(Cache::class); - $rateLimiter = new RateLimiter($cache); - - $key = "john'doe"; - $cleanedKey = $rateLimiter->cleanRateLimiterKey($key); - - $cache->shouldReceive('get')->once()->with($cleanedKey, 0)->andReturn(1); - $cache->shouldReceive('has')->once()->with("{$cleanedKey}:timer")->andReturn(true); - $cache->shouldReceive('add')->never(); - $cache->shouldReceive('getStore')->andReturn(new ArrayStore); - - $this->assertTrue($rateLimiter->tooManyAttempts($key, 1)); - } -} diff --git a/tests/Cache/CacheServiceProviderTest.php b/tests/Cache/CacheServiceProviderTest.php index 5c5581fe9..486007967 100644 --- a/tests/Cache/CacheServiceProviderTest.php +++ b/tests/Cache/CacheServiceProviderTest.php @@ -7,20 +7,16 @@ use Closure; use Hypervel\Cache\CacheManager; use Hypervel\Cache\CacheServiceProvider; -use Hypervel\Cache\Listeners\CreateSwooleTimers; use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Container\Container; -use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\BeforeServerStart; -use Hypervel\Core\Events\OnWorkerExit; use Hypervel\Support\Facades\Cache; use Hypervel\Tests\TestCase; use LogicException; use Mockery as m; -use RuntimeException; use Swoole\Server as SwooleServer; class CacheServiceProviderTest extends TestCase @@ -34,7 +30,7 @@ public function testConsoleFinalizationRunsAfterEveryProviderCanContribute(): vo $events = m::mock(Dispatcher::class); $listeners = []; $events->shouldReceive('listen') - ->times(3) + ->times(2) ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { $listeners[$event][] = $listener; }); @@ -67,7 +63,6 @@ public function testConsoleFinalizationRunsAfterEveryProviderCanContribute(): vo $this->assertCount(1, $listeners[BeforeServerStart::class]); $this->assertCount(1, $listeners[AfterWorkerStart::class]); - $this->assertCount(1, $listeners[OnWorkerExit::class]); $this->assertInstanceOf(Closure::class, $bootedCallback); $bootedCallback(); @@ -92,7 +87,7 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $events = m::mock(Dispatcher::class); $listeners = []; $events->shouldReceive('listen') - ->times(4) + ->times(3) ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { $listeners[$event][] = $listener; }); @@ -116,7 +111,6 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $this->assertTrue($provider->bootCalled); $this->assertCount(1, $listeners[BeforeServerStart::class]); $this->assertCount(2, $listeners[AfterWorkerStart::class]); - $this->assertCount(1, $listeners[OnWorkerExit::class]); $server = m::mock(SwooleServer::class); // Policy finalization runs in request workers and taskworkers, unlike Swoole timer registration. @@ -127,35 +121,6 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $workerManager->allowSerializableClassesUsing(static fn (): array => []); } - public function testWorkerExitStopsOwnedTimersAndReportsCleanupFailure(): void - { - $events = m::mock(Dispatcher::class); - $listeners = []; - $events->shouldReceive('listen') - ->times(4) - ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { - $listeners[$event][] = $listener; - }); - $failure = new RuntimeException('Unable to clear timer.'); - $timers = m::mock(CreateSwooleTimers::class); - $timers->expects('stop')->andThrow($failure); - $handler = m::mock(ExceptionHandler::class); - $handler->expects('report')->with($failure); - $application = m::mock(Application::class); - $application->expects('make')->with('events')->andReturn($events); - $application->expects('runningInConsole')->andReturnFalse(); - $application->expects('make')->with(CreateSwooleTimers::class)->andReturn($timers); - $application->expects('make')->with(ExceptionHandler::class)->andReturn($handler); - $provider = new CacheServiceProvider($application); - $server = m::mock(SwooleServer::class); - $server->taskworker = false; - - $provider->boot(); - $listeners[OnWorkerExit::class][0](new OnWorkerExit($server, 0)); - - $this->addToAssertionCount(1); - } - public function testFacadeCallsTheManagerExtensionWithoutResolvingAStore(): void { $resolver = static fn (): array => [CachePolicyEarlyContribution::class]; diff --git a/tests/Cache/CreateSwooleTimersTest.php b/tests/Cache/CreateSwooleTimersTest.php deleted file mode 100644 index 620cac1b8..000000000 --- a/tests/Cache/CreateSwooleTimersTest.php +++ /dev/null @@ -1,298 +0,0 @@ -shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'fast' => [ - 'driver' => 'swoole', - 'eviction_interval' => 25000, - 'interval_refresh_interval' => 3000, - ], - 'defaulted' => [ - 'driver' => 'swoole', - ], - 'redis' => [ - 'driver' => 'redis', - ], - ]); - - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - - $timer = new FakeSwooleTimer; - - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); - - $this->assertSame([25000, 3000, 10000, 1000], array_column($timer->ticks, 'milliseconds')); - } - - public function testDoesNotRegisterTimersOnOtherWorkers(): void - { - $container = m::mock(Container::class); - $timer = new FakeSwooleTimer; - - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 1)); - - $this->assertSame([], $timer->ticks); - } - - public function testDoesNotRegisterTimersOnTaskWorkers(): void - { - $container = m::mock(Container::class); - $timer = new FakeSwooleTimer; - - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0, taskworker: true)); - - $this->assertSame([], $timer->ticks); - } - - public function testTimerCallbacksCallTheConfiguredStore(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'fast' => [ - 'driver' => 'swoole', - ], - ]); - - $store = m::mock(SwooleStore::class); - $store->shouldReceive('evictRecords')->once(); - $store->shouldReceive('refreshIntervalCaches')->once(); - - $repository = m::mock(); - $repository->shouldReceive('getStore')->twice()->andReturn($store); - - $cache = m::mock(); - $cache->shouldReceive('store')->twice()->with('fast')->andReturn($repository); - - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - $container->shouldReceive('make')->twice()->with('cache')->andReturn($cache); - - $timer = new FakeSwooleTimer; - - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); - - $timer->ticks[0]['callback'](); - $timer->ticks[1]['callback'](); - } - - public function testRollsBackEvictionTimerWhenIntervalTimerRegistrationFails(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'fast' => [ - 'driver' => 'swoole', - ], - ]); - - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - - $timer = new FakeSwooleTimer([41, false]); - - try { - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); - - $this->fail('Expected timer registration to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame( - 'Unable to register the Swoole cache interval refresh timer for store [fast].', - $exception->getMessage(), - ); - } - - $this->assertSame([41], $timer->cleared); - } - - public function testRollsBackEveryEarlierTimerWhenLaterStoreRegistrationFails(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'first' => [ - 'driver' => 'swoole', - ], - 'second' => [ - 'driver' => 'swoole', - ], - ]); - - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - - $timer = new FakeSwooleTimer([11, 12, 13, false]); - - try { - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); - - $this->fail('Expected timer registration to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame( - 'Unable to register the Swoole cache interval refresh timer for store [second].', - $exception->getMessage(), - ); - } - - $this->assertSame([13, 12, 11], $timer->cleared); - } - - public function testPreservesThrownRegistrationFailureWhileAttemptingEveryRollback(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'first' => [ - 'driver' => 'swoole', - ], - 'second' => [ - 'driver' => 'swoole', - ], - ]); - - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - - $failure = new RuntimeException('Timer registration failed.'); - $timer = new FakeSwooleTimer([11, 12, $failure], [12]); - - try { - (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0)); - - $this->fail('Expected timer registration to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); - } - - $this->assertSame([12, 11], $timer->cleared); - } - - public function testStopsEveryRegisteredTimerInReverseOrderAndIsIdempotent(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'fast' => [ - 'driver' => 'swoole', - ], - ]); - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - $timer = new FakeSwooleTimer([41, 42]); - $listener = new CreateSwooleTimers($container, $timer); - - $listener->handle($this->workerEvent(workerId: 0)); - $listener->stop(); - $listener->stop(); - - $this->assertSame([42, 41], $timer->cleared); - } - - public function testStopAttemptsEveryTimerAndPreservesTheFirstClearFailure(): void - { - $config = m::mock(ConfigRepository::class); - $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([ - 'fast' => [ - 'driver' => 'swoole', - ], - ]); - $container = m::mock(Container::class); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - $timer = new FakeSwooleTimer([11, 12], [11], [12]); - $listener = new CreateSwooleTimers($container, $timer); - $listener->handle($this->workerEvent(workerId: 0)); - - try { - $listener->stop(); - $this->fail('Expected timer cleanup to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame('Unable to clear Swoole cache timer [12].', $exception->getMessage()); - } - - $listener->stop(); - - $this->assertSame([12, 11], $timer->cleared); - } - - private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart - { - $server = m::mock(SwooleServer::class); - $server->taskworker = $taskworker; - - return new AfterWorkerStart($server, $workerId); - } -} - -class FakeSwooleTimer extends SwooleTimer -{ - /** - * @var list - */ - public array $ticks = []; - - /** - * @var list - */ - public array $cleared = []; - - /** - * @param list $results - * @param list $clearFailures - * @param list $falseClearResults - */ - public function __construct( - protected array $results = [], - protected array $clearFailures = [], - protected array $falseClearResults = [], - ) { - } - - public function tick(int $milliseconds, Closure $callback): int|false - { - $this->ticks[] = compact('milliseconds', 'callback'); - - if ($this->results !== []) { - $result = array_shift($this->results); - - if ($result instanceof Throwable) { - throw $result; - } - - return $result; - } - - return array_key_last($this->ticks); - } - - public function clear(int $timerId): bool - { - $this->cleared[] = $timerId; - - if (in_array($timerId, $this->clearFailures, true)) { - throw new RuntimeException("Unable to clear timer [{$timerId}]."); - } - - if (in_array($timerId, $this->falseClearResults, true)) { - return false; - } - - return true; - } -} diff --git a/tests/Cache/Fixtures/SwooleMaintenanceTimerRecycleServer.php b/tests/Cache/Fixtures/SwooleMaintenanceTimerRecycleServer.php new file mode 100644 index 000000000..6d63573d6 --- /dev/null +++ b/tests/Cache/Fixtures/SwooleMaintenanceTimerRecycleServer.php @@ -0,0 +1,72 @@ + [ + 'stores' => [ + 'swoole' => [ + 'driver' => 'swoole', + 'table' => 'default', + 'eviction_interval' => 60_000, + 'interval_refresh_interval' => 60_000, + ], + ], + 'swoole_tables' => [ + 'default' => [ + 'rows' => 64, + 'bytes' => 1024, + 'conflict_proportion' => 0.2, + ], + ], + ], +]); +$container->instance(ContainerContract::class, $container); +$container->instance('config', $config); +$container->instance('cache', new CacheManager($container)); + +// Replacement workers must inherit one shared table instead of allocating a +// worker-local table on every recycle. +(new CreateSwooleTable($container, $config))->handle(new BeforeServerStart('http')); + +$timers = new RegisterSwooleMaintenanceTimers($container, new Timer, $config); +$server = new Server('127.0.0.1', $port); +$server->set([ + 'worker_num' => 1, + 'max_request' => 1, + 'max_wait_time' => 1, + 'log_file' => $logPath, +]); +$server->on('workerStart', function (Server $server, int $workerId) use ($timers, $statePath): void { + $timers->handle(new AfterWorkerStart($server, $workerId)); + file_put_contents($statePath, "start\n", FILE_APPEND | LOCK_EX); +}); +$server->on('workerExit', function () use ($statePath): void { + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + file_put_contents($statePath, "exit\n", FILE_APPEND | LOCK_EX); +}); +$server->on('request', static function (Request $request, Response $response): void { + $response->end('ok'); +}); +$server->start(); diff --git a/tests/Cache/Fixtures/SwooleTimerRecycleServer.php b/tests/Cache/Fixtures/SwooleTimerRecycleServer.php deleted file mode 100644 index 47557b3a9..000000000 --- a/tests/Cache/Fixtures/SwooleTimerRecycleServer.php +++ /dev/null @@ -1,50 +0,0 @@ -instance('config', new Repository([ - 'cache' => [ - 'stores' => [ - 'swoole' => [ - 'driver' => 'swoole', - 'eviction_interval' => 60_000, - 'interval_refresh_interval' => 60_000, - ], - ], - ], -])); -$timers = new CreateSwooleTimers($container, new SwooleTimer); -$server = new Server('127.0.0.1', $port); -$server->set([ - 'worker_num' => 1, - 'max_request' => 1, - 'max_wait_time' => 1, - 'log_file' => $logPath, -]); -$server->on('workerStart', function (Server $server, int $workerId) use ($timers, $statePath): void { - $timers->handle(new AfterWorkerStart($server, $workerId)); - file_put_contents($statePath, "start\n", FILE_APPEND | LOCK_EX); -}); -$server->on('workerExit', function () use ($timers, $statePath): void { - $timers->stop(); - file_put_contents($statePath, "exit\n", FILE_APPEND | LOCK_EX); -}); -$server->on('request', static function (Request $request, Response $response): void { - $response->end('ok'); -}); -$server->start(); diff --git a/tests/Cache/LimitTest.php b/tests/Cache/LimitTest.php deleted file mode 100644 index 8af017fc9..000000000 --- a/tests/Cache/LimitTest.php +++ /dev/null @@ -1,59 +0,0 @@ -assertSame(1, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perSecond(3); - $this->assertSame(1, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perSecond(3, 5); - $this->assertSame(5, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perMinute(3); - $this->assertSame(60, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perMinute(3, 4); - $this->assertSame(240, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perMinutes(2, 3); - $this->assertSame(120, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perHour(3); - $this->assertSame(3600, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perHour(3, 2); - $this->assertSame(7200, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perDay(3); - $this->assertSame(86400, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = Limit::perDay(3, 5); - $this->assertSame(432000, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - - $limit = new GlobalLimit(3); - $this->assertSame(60, $limit->decaySeconds); - $this->assertSame(3, $limit->maxAttempts); - } -} diff --git a/tests/Cache/RateLimiterTest.php b/tests/Cache/RateLimiterTest.php deleted file mode 100644 index 16bb6810d..000000000 --- a/tests/Cache/RateLimiterTest.php +++ /dev/null @@ -1,331 +0,0 @@ -for($name, fn () => 'limit'); - - $limiters = $reflectedLimitersProperty->getValue($rateLimiter); - - $this->assertArrayHasKey($expected, $limiters); - - $limiterClosure = $rateLimiter->limiter($name); - - $this->assertNotNull($limiterClosure); - } - - public static function registerNamedRateLimiterDataProvider(): array - { - return [ - 'uses BackedEnum' => [BackedEnumNamedRateLimiter::Api, 'api'], - 'uses UnitEnum' => [UnitEnumNamedRateLimiter::ThirdParty, 'ThirdParty'], - 'uses normal string' => ['yolo', 'yolo'], - ]; - } - - public function testForWithBackedEnumStoresUnderValue(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $rateLimiter->for(BackedEnumNamedRateLimiter::Api, fn () => 'api-limit'); - - // Can retrieve with enum - $this->assertNotNull($rateLimiter->limiter(BackedEnumNamedRateLimiter::Api)); - - // Can also retrieve with string value - $this->assertNotNull($rateLimiter->limiter('api')); - - // Closure returns expected value - $this->assertSame('api-limit', $rateLimiter->limiter(BackedEnumNamedRateLimiter::Api)()); - } - - public function testForWithUnitEnumStoresUnderName(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $rateLimiter->for(UnitEnumNamedRateLimiter::ThirdParty, fn () => 'third-party-limit'); - - // Can retrieve with enum - $this->assertNotNull($rateLimiter->limiter(UnitEnumNamedRateLimiter::ThirdParty)); - - // Can also retrieve with string name (PascalCase) - $this->assertNotNull($rateLimiter->limiter('ThirdParty')); - - // Closure returns expected value - $this->assertSame('third-party-limit', $rateLimiter->limiter(UnitEnumNamedRateLimiter::ThirdParty)()); - } - - public function testLimiterReturnsNullForNonExistentEnum(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - - $this->assertNull($rateLimiter->limiter(BackedEnumNamedRateLimiter::Web)); - $this->assertNull($rateLimiter->limiter(UnitEnumNamedRateLimiter::Internal)); - } - - public function testBackedEnumAndStringInteroperability(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - - // Register with string - $rateLimiter->for('api', fn () => 'string-registered'); - - // Retrieve with BackedEnum that has same value - $limiter = $rateLimiter->limiter(BackedEnumNamedRateLimiter::Api); - - $this->assertNotNull($limiter); - $this->assertSame('string-registered', $limiter()); - } - - public function testUnitEnumAndStringInteroperability(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - - // Register with string (matching UnitEnum name) - $rateLimiter->for('ThirdParty', fn () => 'string-registered'); - - // Retrieve with UnitEnum - $limiter = $rateLimiter->limiter(UnitEnumNamedRateLimiter::ThirdParty); - - $this->assertNotNull($limiter); - $this->assertSame('string-registered', $limiter()); - } - - public function testMultipleEnumLimitersCanCoexist(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - - $rateLimiter->for(BackedEnumNamedRateLimiter::Api, fn () => 'api-limit'); - $rateLimiter->for(BackedEnumNamedRateLimiter::Web, fn () => 'web-limit'); - $rateLimiter->for(UnitEnumNamedRateLimiter::ThirdParty, fn () => 'third-party-limit'); - $rateLimiter->for('custom', fn () => 'custom-limit'); - - $this->assertSame('api-limit', $rateLimiter->limiter(BackedEnumNamedRateLimiter::Api)()); - $this->assertSame('web-limit', $rateLimiter->limiter(BackedEnumNamedRateLimiter::Web)()); - $this->assertSame('third-party-limit', $rateLimiter->limiter(UnitEnumNamedRateLimiter::ThirdParty)()); - $this->assertSame('custom-limit', $rateLimiter->limiter('custom')()); - } - - public function testShouldUseOriginKeyAsPrefixWhenMultipleLimiterWithSameKey() - { - $rateLimiter = new RateLimiter(new Repository(new ArrayStore)); - - $rateLimiter->for('user_limiter', fn (string $userId) => [ - Limit::perSecond(3)->by($userId), - Limit::perMinute(5)->by($userId), - ]); - - $userId1 = '123'; - $userId2 = '456'; - - $limiterForUser1 = $rateLimiter->limiter('user_limiter')($userId1); - $limiterForUser2 = $rateLimiter->limiter('user_limiter')($userId2); - - for ($i = 0; $i < 3; ++$i) { - $this->assertFalse($rateLimiter->tooManyAttempts($limiterForUser1[0]->key, $limiterForUser1[0]->maxAttempts)); - $this->assertFalse($rateLimiter->tooManyAttempts($limiterForUser2[0]->key, $limiterForUser2[0]->maxAttempts)); - - $rateLimiter->hit($limiterForUser1[0]->key, $limiterForUser1[0]->decaySeconds); - $rateLimiter->hit($limiterForUser2[0]->key, $limiterForUser2[0]->decaySeconds); - } - - $this->assertNotSame($limiterForUser1[0]->key, $limiterForUser2[0]->key); - $this->assertNotSame($limiterForUser1[1]->key, $limiterForUser2[1]->key); - } - - public function testForWithIntBackedEnumStoresUnderStringCastValue(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $rateLimiter->for(IntBackedEnumNamedRateLimiter::First, fn () => 'int-limit'); - - // Can retrieve with enum - $this->assertNotNull($rateLimiter->limiter(IntBackedEnumNamedRateLimiter::First)); - - // Can also retrieve with string-cast value - $this->assertNotNull($rateLimiter->limiter('1')); - - // Closure returns expected value - $this->assertSame('int-limit', $rateLimiter->limiter(IntBackedEnumNamedRateLimiter::First)()); - } - - public function testNamedLimiterKeyUsesCanonicalHashedAndRawFormats(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $limit = Limit::perMinute(10)->by('user-1'); - $key = '3:api6:user-1'; - - $this->assertSame( - hash('xxh128', $key), - $rateLimiter->resolveNamedLimiterKey('api', $limit), - ); - $this->assertSame( - $key, - $rateLimiter->resolveNamedLimiterKey('api', $limit, shouldHashKeys: false), - ); - } - - public function testNamedLimiterKeyIncludesResolvedScopeBeforeSingleHash(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $resolvedName = null; - $rateLimiter->resolveKeyScopeUsing(function (string $limiterName) use (&$resolvedName): string { - $resolvedName = $limiterName; - - return 'account-1'; - }); - $limit = Limit::perMinute(10)->by('user-1'); - $key = '9:account-13:api6:user-1'; - - $this->assertSame( - hash('xxh128', $key), - $rateLimiter->resolveNamedLimiterKey('api', $limit), - ); - $this->assertSame( - $key, - $rateLimiter->resolveNamedLimiterKey('api', $limit, shouldHashKeys: false), - ); - $this->assertSame('api', $resolvedName); - } - - public function testNullScopeAndClearedResolverUseTheUnscopedKey(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $limit = Limit::perMinute(10)->by('user-1'); - - $rateLimiter->resolveKeyScopeUsing(fn () => null); - $this->assertSame( - hash('xxh128', '3:api6:user-1'), - $rateLimiter->resolveNamedLimiterKey('api', $limit), - ); - - $rateLimiter->resolveKeyScopeUsing(fn () => 'account-1'); - $rateLimiter->resolveKeyScopeUsing(null); - - $this->assertSame( - hash('xxh128', '3:api6:user-1'), - $rateLimiter->resolveNamedLimiterKey('api', $limit), - ); - } - - public function testGlobalLimitNeverInvokesScopeResolver(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $rateLimiter->resolveKeyScopeUsing(function (): never { - throw new RuntimeException('Scope resolver should not run.'); - }); - $limit = new GlobalLimit(10); - - $this->assertSame( - hash('xxh128', '3:api0:'), - $rateLimiter->resolveNamedLimiterKey('api', $limit), - ); - $this->assertSame( - '3:api0:', - $rateLimiter->resolveNamedLimiterKey('api', $limit, shouldHashKeys: false), - ); - } - - public function testNamedLimiterKeyAcceptsEmptyAndFallbackLimitKeys(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $emptyLimit = Limit::perMinute(10); - $fallbackLimit = Limit::perMinute(10)->by($emptyLimit->fallbackKey()); - - $this->assertSame( - hash('xxh128', '3:api0:'), - $rateLimiter->resolveNamedLimiterKey('api', $emptyLimit), - ); - $this->assertSame( - hash('xxh128', '3:api20:attempts:10:decay:60'), - $rateLimiter->resolveNamedLimiterKey('api', $fallbackLimit), - ); - } - - public function testNamedLimiterKeysPreserveNameAndKeyBoundaries(): void - { - $rateLimiter = new RateLimiter(m::mock(Cache::class)); - $first = Limit::perMinute(10)->by('c'); - $second = Limit::perMinute(10)->by('bc'); - - $this->assertNotSame( - $rateLimiter->resolveNamedLimiterKey('ab', $first), - $rateLimiter->resolveNamedLimiterKey('a', $second), - ); - $this->assertNotSame( - $rateLimiter->resolveNamedLimiterKey('ab', $first, shouldHashKeys: false), - $rateLimiter->resolveNamedLimiterKey('a', $second, shouldHashKeys: false), - ); - } - - public function testNamedLimiterKeysPreserveScopeAndNameBoundaries(): void - { - $first = new RateLimiter(m::mock(Cache::class)); - $first->resolveKeyScopeUsing(fn () => 'scope:one'); - $second = new RateLimiter(m::mock(Cache::class)); - $second->resolveKeyScopeUsing(fn () => 'scope'); - $limit = Limit::perMinute(10)->by('user-1'); - - $this->assertNotSame( - $first->resolveNamedLimiterKey('api', $limit), - $second->resolveNamedLimiterKey('one:api', $limit), - ); - $this->assertNotSame( - $first->resolveNamedLimiterKey('api', $limit, shouldHashKeys: false), - $second->resolveNamedLimiterKey('one:api', $limit, shouldHashKeys: false), - ); - } - - public function testNamedLimiterKeysPreserveOptionalScopeArity(): void - { - $unscoped = new RateLimiter(m::mock(Cache::class)); - $scoped = new RateLimiter(m::mock(Cache::class)); - $scoped->resolveKeyScopeUsing(fn () => 'account-1'); - $limit = Limit::perMinute(10)->by('user-1'); - - $this->assertNotSame( - $unscoped->resolveNamedLimiterKey('account-1:api', $limit), - $scoped->resolveNamedLimiterKey('api', $limit), - ); - $this->assertNotSame( - $unscoped->resolveNamedLimiterKey('account-1:api', $limit, shouldHashKeys: false), - $scoped->resolveNamedLimiterKey('api', $limit, shouldHashKeys: false), - ); - } -} diff --git a/tests/Cache/RegisterSwooleMaintenanceTimersTest.php b/tests/Cache/RegisterSwooleMaintenanceTimersTest.php new file mode 100644 index 000000000..1f9768a7b --- /dev/null +++ b/tests/Cache/RegisterSwooleMaintenanceTimersTest.php @@ -0,0 +1,423 @@ +config([ + 'fast' => [ + 'driver' => 'swoole', + 'eviction_interval' => 25000, + 'interval_refresh_interval' => 3000, + ], + 'defaulted' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + 'redis' => [ + 'driver' => 'redis', + ], + ]); + + $container = $this->containerWithSwooleStores('fast', 'defaulted'); + $timer = new FakeCoordinatorTimer; + + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->assertSame([25.0, 3.0, 10.0, 1.0], array_column($timer->ticks, 'seconds')); + } + + public function testDoesNotRegisterTimersOnOtherWorkers(): void + { + $container = m::mock(Container::class); + $timer = new FakeCoordinatorTimer; + + (new RegisterSwooleMaintenanceTimers($container, $timer, $this->config([]))) + ->handle($this->workerEvent(workerId: 1)); + + $this->assertSame([], $timer->ticks); + } + + public function testDoesNotRegisterTimersOnTaskWorkers(): void + { + $container = m::mock(Container::class); + $timer = new FakeCoordinatorTimer; + + (new RegisterSwooleMaintenanceTimers($container, $timer, $this->config([]))) + ->handle($this->workerEvent(workerId: 0, taskworker: true)); + + $this->assertSame([], $timer->ticks); + } + + public function testTimerCallbacksCallTheConfiguredStore(): void + { + $config = $this->config([ + 'fast' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + ]); + + $store = m::mock(SwooleStore::class); + $store->shouldReceive('evictRecords')->once(); + $store->shouldReceive('refreshIntervalCaches')->once(); + + $repository = m::mock(); + $repository->shouldReceive('getStore')->once()->andReturn($store); + + $cache = m::mock(); + $cache->shouldReceive('store')->once()->with('fast')->andReturn($repository); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->once()->with('cache')->andReturn($cache); + + $timer = new FakeCoordinatorTimer; + + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $timer->ticks[0]['callback'](); + $timer->ticks[1]['callback'](); + } + + public function testResolvesEveryStoreBeforeRegisteringTimers(): void + { + $config = $this->config([ + 'first' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + 'second' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + ]); + $firstRepository = m::mock(); + $firstRepository->shouldReceive('getStore')->once()->andReturn(m::mock(SwooleStore::class)); + + $failure = new RuntimeException('Store resolution failed.'); + $cache = m::mock(); + $cache->shouldReceive('store')->once()->with('first')->andReturn($firstRepository); + $cache->shouldReceive('store')->once()->with('second')->andThrow($failure); + + $container = m::mock(Container::class); + $container->shouldReceive('make')->twice()->with('cache')->andReturn($cache); + + $timer = new FakeCoordinatorTimer; + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected store resolution to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([], $timer->ticks); + } + + public function testRollsBackEvictionTimerWhenIntervalTimerRegistrationFails(): void + { + $config = $this->config([ + 'fast' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + ]); + $container = $this->containerWithSwooleStores('fast'); + $failure = new RuntimeException('Timer registration failed.'); + $timer = new FakeCoordinatorTimer([41, $failure]); + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected timer registration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([41], $timer->cleared); + } + + public function testRollsBackEveryEarlierTimerWhenLaterStoreRegistrationFails(): void + { + $config = $this->config([ + 'first' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + 'second' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + ]); + $container = $this->containerWithSwooleStores('first', 'second'); + $failure = new RuntimeException('Timer registration failed.'); + $timer = new FakeCoordinatorTimer([11, 12, 13, $failure]); + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected timer registration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([13, 12, 11], $timer->cleared); + } + + public function testPreservesThrownRegistrationFailureWhileAttemptingEveryRollback(): void + { + $config = $this->config([ + 'first' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + 'second' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + ]); + $container = $this->containerWithSwooleStores('first', 'second'); + $failure = new RuntimeException('Timer registration failed.'); + $timer = new FakeCoordinatorTimer([11, 12, $failure], [12]); + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected timer registration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([12, 11], $timer->cleared); + } + + /** + * @param array $intervalConfig + */ + #[DataProvider('invalidIntervals')] + public function testRejectsInvalidIntervalsBeforeTimerRegistration( + array $intervalConfig, + string $invalidKey, + ): void { + $config = $this->config([ + 'invalid' => [ + 'driver' => 'swoole', + ...$intervalConfig, + ], + ]); + $container = m::mock(Container::class); + $timer = new FakeCoordinatorTimer; + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected invalid timer configuration to fail.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString( + "cache.stores.invalid.{$invalidKey}", + $exception->getMessage(), + ); + } + + $this->assertSame([], $timer->ticks); + } + + /** + * @return array, string}> + */ + public static function invalidIntervals(): array + { + return [ + 'missing eviction interval' => [[ + 'interval_refresh_interval' => 1000, + ], 'eviction_interval'], + 'wrong eviction interval type' => [[ + 'eviction_interval' => '10000', + 'interval_refresh_interval' => 1000, + ], 'eviction_interval'], + 'zero eviction interval' => [[ + 'eviction_interval' => 0, + 'interval_refresh_interval' => 1000, + ], 'eviction_interval'], + 'negative eviction interval' => [[ + 'eviction_interval' => -1, + 'interval_refresh_interval' => 1000, + ], 'eviction_interval'], + 'missing refresh interval' => [[ + 'eviction_interval' => 10000, + ], 'interval_refresh_interval'], + 'wrong refresh interval type' => [[ + 'eviction_interval' => 10000, + 'interval_refresh_interval' => '1000', + ], 'interval_refresh_interval'], + 'zero refresh interval' => [[ + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 0, + ], 'interval_refresh_interval'], + 'negative refresh interval' => [[ + 'eviction_interval' => 10000, + 'interval_refresh_interval' => -1, + ], 'interval_refresh_interval'], + ]; + } + + public function testRejectsAnInvalidLaterStoreBeforeRegisteringEarlierTimers(): void + { + $config = $this->config([ + 'first' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 1000, + ], + 'second' => [ + 'driver' => 'swoole', + 'eviction_interval' => 10000, + 'interval_refresh_interval' => 0, + ], + ]); + $container = m::mock(Container::class); + $timer = new FakeCoordinatorTimer; + + try { + (new RegisterSwooleMaintenanceTimers($container, $timer, $config)) + ->handle($this->workerEvent(workerId: 0)); + + $this->fail('Expected invalid timer configuration to fail.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString( + 'cache.stores.second.interval_refresh_interval', + $exception->getMessage(), + ); + } + + $this->assertSame([], $timer->ticks); + } + + /** + * @param array> $stores + */ + private function config(array $stores): Repository + { + return new Repository([ + 'cache' => [ + 'stores' => $stores, + ], + ]); + } + + /** + * Create a container that resolves the given Swoole cache stores. + */ + private function containerWithSwooleStores(string ...$names): Container + { + $cache = m::mock(); + + foreach ($names as $name) { + $repository = m::mock(); + $repository->shouldReceive('getStore')->once()->andReturn(m::mock(SwooleStore::class)); + $cache->shouldReceive('store')->once()->with($name)->andReturn($repository); + } + + $container = m::mock(Container::class); + $container->shouldReceive('make')->times(count($names))->with('cache')->andReturn($cache); + + return $container; + } + + private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart + { + $server = m::mock(SwooleServer::class); + $server->taskworker = $taskworker; + + return new AfterWorkerStart($server, $workerId); + } +} + +class FakeCoordinatorTimer extends Timer +{ + /** + * @var list + */ + public array $ticks = []; + + /** + * @var list + */ + public array $cleared = []; + + /** + * @param list $results + * @param list $clearFailures + */ + public function __construct( + protected array $results = [], + protected array $clearFailures = [], + ) { + parent::__construct(); + } + + public function tick( + float $seconds, + callable $callback, + string $identifier = Constants::WORKER_EXIT, + ): int { + $this->ticks[] = compact('seconds', 'callback', 'identifier'); + + if ($this->results !== []) { + $result = array_shift($this->results); + + if ($result instanceof Throwable) { + throw $result; + } + + return $result; + } + + return count($this->ticks); + } + + public function clear(int $timerId): void + { + $this->cleared[] = $timerId; + + if (in_array($timerId, $this->clearFailures, true)) { + throw new RuntimeException("Unable to clear timer [{$timerId}]."); + } + } +} diff --git a/tests/Cache/SwooleTimerWorkerRecycleTest.php b/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php similarity index 93% rename from tests/Cache/SwooleTimerWorkerRecycleTest.php rename to tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php index 7bca5159a..f755028eb 100644 --- a/tests/Cache/SwooleTimerWorkerRecycleTest.php +++ b/tests/Cache/SwooleMaintenanceTimerWorkerRecycleTest.php @@ -12,7 +12,7 @@ use Symfony\Component\Process\Process; #[RequiresOperatingSystem('Linux|Darwin')] -class SwooleTimerWorkerRecycleTest extends TestCase +class SwooleMaintenanceTimerWorkerRecycleTest extends TestCase { protected string $tempDir; @@ -20,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - $this->tempDir = ParallelTesting::tempDir('SwooleTimerWorkerRecycleTest'); + $this->tempDir = ParallelTesting::tempDir('SwooleMaintenanceTimerWorkerRecycleTest'); mkdir($this->tempDir, 0777, true); } @@ -38,7 +38,7 @@ public function testOwnedCacheTimersDoNotDelayWorkerRecycle(): void $logPath = $this->tempDir . '/swoole.log'; $process = new Process([ PHP_BINARY, - __DIR__ . '/Fixtures/SwooleTimerRecycleServer.php', + __DIR__ . '/Fixtures/SwooleMaintenanceTimerRecycleServer.php', dirname(__DIR__, 2) . '/vendor/autoload.php', (string) $port, $statePath, diff --git a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php index 8c124affe..e3ac06f1c 100644 --- a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php @@ -4,12 +4,10 @@ namespace Hypervel\Tests\Integration\Cache\Redis; -use Hypervel\Cache\RateLimiter; - /** * Integration tests ported from Laravel's RedisCacheIntegrationTest. * - * Tests core Redis cache behavior (add, rate limiter) against a real Redis connection. + * Tests core Redis cache behavior against a real Redis connection. */ class RedisCacheIntegrationTest extends RedisCacheIntegrationTestCase { @@ -21,16 +19,6 @@ public function testRedisCacheAddTwice() $this->assertGreaterThan(3500, $this->store()->connection()->ttl($this->store()->getPrefix() . 'k')); } - public function testRedisCacheRateLimiter() - { - $rateLimiter = new RateLimiter($this->cache()); - - $this->assertFalse($rateLimiter->tooManyAttempts('key', 1)); - $this->assertEquals(1, $rateLimiter->hit('key', 60)); - $this->assertTrue($rateLimiter->tooManyAttempts('key', 1)); - $this->assertFalse($rateLimiter->tooManyAttempts('key', 2)); - } - /** * Breaking change. */ From 07de944fdc179037c22da63cd49d48111a1a8dc3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:15:40 +0000 Subject: [PATCH 16/41] Use SHA-cached Lua for Redis limiters Route the existing blocking concurrency and duration limiters through evalWithShaCache instead of resending Lua bodies on every operation. Preserve connection selection, prefixes, cluster routing, builder APIs, and results while covering NOSCRIPT fallback and native-versus-script error classification. --- src/redis/src/Limiters/ConcurrencyLimiter.php | 12 +- src/redis/src/Limiters/DurationLimiter.php | 29 ++-- src/redis/src/RedisConnection.php | 8 +- .../ConcurrencyLimiterIntegrationTest.php | 26 ++++ .../Redis/DurationLimiterIntegrationTest.php | 22 +++ .../Redis/EvalWithShaCacheIntegrationTest.php | 28 ++++ tests/Redis/ConcurrencyLimiterBuilderTest.php | 43 +++--- tests/Redis/ConcurrencyLimiterTest.php | 134 +++++++++--------- tests/Redis/DurationLimiterBuilderTest.php | 27 +++- tests/Redis/DurationLimiterTest.php | 105 ++++++++------ 10 files changed, 274 insertions(+), 160 deletions(-) diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index ea159c9f4..af8bc0621 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiter.php +++ b/src/redis/src/Limiters/ConcurrencyLimiter.php @@ -133,11 +133,13 @@ protected function claimSlot(string $id): false|string return false; } - $result = $this->redis->eval(...array_merge( - [LuaScripts::acquireConcurrencySlot(), count($this->slots)], - $this->slots, - [$this->keyPrefix, $this->releaseAfter, $id], - )); + $result = $this->redis->withConnection( + fn (RedisConnection $connection): mixed => $connection->evalWithShaCache( + LuaScripts::acquireConcurrencySlot(), + $this->slots, + [$this->keyPrefix, $this->releaseAfter, $id], + ), + ); return is_string($result) ? $result : false; } diff --git a/src/redis/src/Limiters/DurationLimiter.php b/src/redis/src/Limiters/DurationLimiter.php index 729c57f84..252b517b8 100644 --- a/src/redis/src/Limiters/DurationLimiter.php +++ b/src/redis/src/Limiters/DurationLimiter.php @@ -5,6 +5,7 @@ namespace Hypervel\Redis\Limiters; use Hypervel\Contracts\Limiters\LimiterTimeoutException; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Support\Sleep; @@ -71,14 +72,12 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 750 */ public function acquire(): bool { - $results = $this->redis->eval( - $this->luaScript(), - 1, - $this->name, - microtime(true), - time(), - $this->decay, - $this->maxLocks, + $results = $this->redis->withConnection( + fn (RedisConnection $connection): mixed => $connection->evalWithShaCache( + $this->luaScript(), + [$this->name], + [microtime(true), time(), $this->decay, $this->maxLocks], + ), ); $this->decaysAt = (int) $results[1]; @@ -93,14 +92,12 @@ public function acquire(): bool */ public function tooManyAttempts(): bool { - $results = $this->redis->eval( - $this->tooManyAttemptsLuaScript(), - 1, - $this->name, - microtime(true), - time(), - $this->decay, - $this->maxLocks, + $results = $this->redis->withConnection( + fn (RedisConnection $connection): mixed => $connection->evalWithShaCache( + $this->tooManyAttemptsLuaScript(), + [$this->name], + [microtime(true), time(), $this->decay, $this->maxLocks], + ), ); $this->decaysAt = (int) $results[0]; diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 7da4d6e0f..0751e664f 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -1608,15 +1608,17 @@ public function client(): mixed * isn't cached yet (NOSCRIPT error). * * Unlike naive implementations that treat any `false` return as NOSCRIPT, - * this method properly distinguishes NOSCRIPT errors from other failures - * (syntax errors, OOM, WRONGTYPE, etc.) and throws on non-NOSCRIPT errors. + * this method wraps script and data errors returned by phpredis as `false` + * while native server, cluster, authentication, and transport exceptions + * propagate unchanged. * * @param string $script The Lua script to execute * @param array $keys Redis keys (passed as KEYS[] in Lua) * @param array $args Additional arguments (passed as ARGV[] in Lua) * @return mixed The script's return value * - * @throws LuaScriptException If script execution fails (non-NOSCRIPT error) + * @throws LuaScriptException If phpredis returns a non-NOSCRIPT script or data error + * @throws RedisException If Redis rejects execution or the connection fails */ public function evalWithShaCache(string $script, array $keys = [], array $args = []): mixed { diff --git a/tests/Integration/Redis/ConcurrencyLimiterIntegrationTest.php b/tests/Integration/Redis/ConcurrencyLimiterIntegrationTest.php index c08ded202..979d0ba09 100644 --- a/tests/Integration/Redis/ConcurrencyLimiterIntegrationTest.php +++ b/tests/Integration/Redis/ConcurrencyLimiterIntegrationTest.php @@ -307,6 +307,32 @@ public function testWrongOwnerCannotReleaseOrRefreshHeldSlot(): void } } + public function testAcquireUsesTheSelectedConnectionPrefix(): void + { + $prefixed = Redis::connection($this->createRedisConnectionWithOptions( + 'concurrency_limiter_prefixed', + ['prefix' => 'concurrency-limiter:'], + )); + $plain = Redis::connection($this->createRedisConnectionWithOptions( + 'concurrency_limiter_plain', + ['prefix' => ''], + )); + + $plain->del('concurrency-limiter:selected-connection1', 'selected-connection1'); + + $lease = (new ConcurrencyLimiter($prefixed, 'selected-connection', 1, 60))->acquire(0); + + try { + $this->assertSame(1, $plain->exists('concurrency-limiter:selected-connection1')); + $this->assertSame(0, $plain->exists('selected-connection1')); + $this->assertTrue($lease->release()); + $this->assertSame(0, $plain->exists('concurrency-limiter:selected-connection1')); + } finally { + $lease->release(); + $plain->del('concurrency-limiter:selected-connection1', 'selected-connection1'); + } + } + /** * Get the Redis connection for testing. */ diff --git a/tests/Integration/Redis/DurationLimiterIntegrationTest.php b/tests/Integration/Redis/DurationLimiterIntegrationTest.php index ddb2d1a3e..888485578 100644 --- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php +++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php @@ -158,6 +158,28 @@ public function testAcquireResetsAfterDecay(): void $this->assertSame(0, $limiter->remaining); } + public function testAcquireUsesTheSelectedConnectionPrefix(): void + { + $prefixed = Redis::connection($this->createRedisConnectionWithOptions( + 'duration_limiter_prefixed', + ['prefix' => 'duration-limiter:'], + )); + $plain = Redis::connection($this->createRedisConnectionWithOptions( + 'duration_limiter_plain', + ['prefix' => ''], + )); + + $plain->del('duration-limiter:selected-connection', 'selected-connection'); + + try { + $this->assertTrue((new DurationLimiter($prefixed, 'selected-connection', 1, 60))->acquire()); + $this->assertSame(1, $plain->exists('duration-limiter:selected-connection')); + $this->assertSame(0, $plain->exists('selected-connection')); + } finally { + $plain->del('duration-limiter:selected-connection', 'selected-connection'); + } + } + /** * Get the Redis connection for testing. */ diff --git a/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php b/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php index fe46e164a..021aaaa07 100644 --- a/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php +++ b/tests/Integration/Redis/EvalWithShaCacheIntegrationTest.php @@ -8,6 +8,7 @@ use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; +use RedisException; /** * Integration tests for RedisConnection::evalWithShaCache(). @@ -143,6 +144,33 @@ public function testEvalWithShaCacheThrowsOnRuntimeError(): void }); } + public function testEvalWithShaCachePreservesPhpRedisErrorClassification(): void + { + try { + Redis::withConnection(function ($connection) { + return $connection->evalWithShaCache( + "return redis.error_reply('ERR application script failure')", + ); + }); + + $this->fail('Expected the application script error to be wrapped.'); + } catch (LuaScriptException $exception) { + $this->assertStringContainsString('ERR application script failure', $exception->getMessage()); + } + + try { + Redis::withConnection(function ($connection) { + return $connection->evalWithShaCache( + "return redis.error_reply('OOM simulated server state')", + ); + }); + + $this->fail('Expected the server-state error to propagate.'); + } catch (RedisException $exception) { + $this->assertSame('OOM simulated server state', $exception->getMessage()); + } + } + public function testEvalWithShaCacheReturnsNilAsFalse(): void { $result = Redis::withConnection(function ($connection) { diff --git a/tests/Redis/ConcurrencyLimiterBuilderTest.php b/tests/Redis/ConcurrencyLimiterBuilderTest.php index 2b76b109f..475d3585a 100644 --- a/tests/Redis/ConcurrencyLimiterBuilderTest.php +++ b/tests/Redis/ConcurrencyLimiterBuilderTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Redis\Limiters\ConcurrencyLease; use Hypervel\Redis\Limiters\ConcurrencyLimiterBuilder; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; @@ -85,9 +86,7 @@ public function testThenExecutesCallbackWhenLockAcquired(): void { $redis = $this->mockRedis(); // ConcurrencyLimiter::acquire() Lua script returns a slot name - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-key1'); + $this->expectSlotClaim($redis, 'test-key1'); // ConcurrencyLimiter::release() called after callback $redis->shouldReceive('eval') @@ -115,9 +114,7 @@ public function testThenPropagatesReleaseFailureAfterSuccessfulCallback(): void $redis = $this->mockRedis(); $releaseException = new RuntimeException('release failed'); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-key1'); + $this->expectSlotClaim($redis, 'test-key1'); $redis->shouldReceive('eval') ->once() ->andThrow($releaseException); @@ -139,9 +136,7 @@ public function testThenPreservesCallbackFailureWhenReleaseAlsoFails(): void $redis = $this->mockRedis(); $callbackException = new RuntimeException('callback failed'); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-key1'); + $this->expectSlotClaim($redis, 'test-key1'); $redis->shouldReceive('eval') ->once() ->andThrow(new RuntimeException('release failed')); @@ -164,8 +159,7 @@ public function testThenCallsFailureCallbackOnTimeout(): void { $redis = $this->mockRedis(); // ConcurrencyLimiter::acquire() always fails - $redis->shouldReceive('eval') - ->andReturn(false); + $this->expectSlotClaim($redis, false); $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); $builder->limit(5)->block(0)->sleep(1); @@ -189,8 +183,7 @@ public function testThenThrowsExceptionWithoutFailureCallback(): void { $redis = $this->mockRedis(); // ConcurrencyLimiter::acquire() always fails - $redis->shouldReceive('eval') - ->andReturn(false); + $this->expectSlotClaim($redis, false); $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); $builder->limit(5)->block(0)->sleep(1); @@ -205,9 +198,7 @@ public function testThenThrowsExceptionWithoutFailureCallback(): void public function testAcquireReturnsLease(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-key1'); + $this->expectSlotClaim($redis, 'test-key1'); $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); $builder->limit(5)->block(0); @@ -218,9 +209,7 @@ public function testAcquireReturnsLease(): void public function testThenDoesNotRouteCallbackTimeoutExceptionToFailureCallback(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-key1'); + $this->expectSlotClaim($redis, 'test-key1'); $redis->shouldReceive('eval') ->once() ->andReturn(1); @@ -280,4 +269,20 @@ private function mockRedis(): m\MockInterface|RedisProxy return $redis; } + + /** + * Expect a slot-claim script evaluation on one held Redis connection. + */ + private function expectSlotClaim(m\MockInterface|RedisProxy $redis, false|string $result): void + { + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('withConnection') + ->once() + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn($result); + } } diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 8a675b55d..e12af5b1b 100644 --- a/tests/Redis/ConcurrencyLimiterTest.php +++ b/tests/Redis/ConcurrencyLimiterTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Redis\Limiters\ConcurrencyLease; use Hypervel\Redis\Limiters\ConcurrencyLimiter; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; @@ -24,10 +25,8 @@ public function testBlockExecutesCallbackOnSuccessfulAcquisition(): void { $redis = $this->mockRedis(); - // acquire() calls eval with the lock script — return a slot name to indicate success - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + // acquire() returns a slot name to indicate success + $this->expectSlotClaim($redis, 'test-lock1'); // release() calls eval with the release script $redis->shouldReceive('eval') @@ -55,9 +54,7 @@ public function testBlockReturnsTrueWithoutCallback(): void $redis = $this->mockRedis(); // acquire() succeeds - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); @@ -71,9 +68,7 @@ public function testBlockReleasesLockWhenCallbackThrows(): void $redis = $this->mockRedis(); // acquire() succeeds - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); // release() should still be called $redis->shouldReceive('eval') @@ -101,9 +96,7 @@ public function testBlockPropagatesReleaseFailureAfterSuccessfulCallback(): void $redis = $this->mockRedis(); $releaseException = new RuntimeException('release failed'); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $redis->shouldReceive('eval') ->once() ->andThrow($releaseException); @@ -124,9 +117,7 @@ public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void $redis = $this->mockRedis(); $callbackException = new RuntimeException('callback failed'); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $redis->shouldReceive('eval') ->once() ->andThrow(new RuntimeException('release failed')); @@ -147,9 +138,12 @@ public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void public function testBlockThrowsTimeoutExceptionWhenCannotAcquire(): void { $redis = $this->mockRedis(); + $connection = m::mock(RedisConnection::class); // acquire() always fails (returns falsy) - $redis->shouldReceive('eval') + $redis->shouldReceive('withConnection') + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + $connection->shouldReceive('evalWithShaCache') ->andReturn(false); $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); @@ -164,25 +158,19 @@ public function testAcquirePassesCorrectKeysToLuaScript(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->withArgs(function (string $script, int $numKeys, ...$args): bool { - // With maxLocks=3, we should have 3 keys - $this->assertSame(3, $numKeys); - - // First 3 args are the slot keys - $this->assertSame('test-lock1', $args[0]); - $this->assertSame('test-lock2', $args[1]); - $this->assertSame('test-lock3', $args[2]); - - // Then ARGV: name, releaseAfter, id - $this->assertSame('test-lock', $args[3]); - $this->assertSame(60, $args[4]); - $this->assertNotEmpty($args[5]); // random id + $this->expectSlotClaim( + $redis, + 'test-lock1', + function (string $script, array $keys, array $arguments): bool { + $this->assertNotSame('', $script); + $this->assertSame(['test-lock1', 'test-lock2', 'test-lock3'], $keys); + $this->assertSame('test-lock', $arguments[0]); + $this->assertSame(60, $arguments[1]); + $this->assertNotEmpty($arguments[2]); return true; - }) - ->andReturn('test-lock1'); + }, + ); $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); @@ -193,9 +181,9 @@ public function testBlockWithZeroLimitDoesNotCallEvalAndTimesOut(): void { $redis = $this->mockRedis(); - // limit(0) means no slots — acquire must short-circuit before calling eval, + // limit(0) means no slots — acquire must short-circuit before evaluating Lua, // otherwise Lua hits redis.call('mget') with no args and errors. - $redis->shouldNotReceive('eval'); + $redis->shouldNotReceive('withConnection'); $this->expectException(LimiterTimeoutException::class); @@ -206,7 +194,7 @@ public function testBlockWithNegativeLimitDoesNotCallEvalAndTimesOut(): void { $redis = $this->mockRedis(); - $redis->shouldNotReceive('eval'); + $redis->shouldNotReceive('withConnection'); $this->expectException(LimiterTimeoutException::class); @@ -217,9 +205,7 @@ public function testAcquireReturnsLease(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); @@ -233,9 +219,7 @@ public function testLeaseCanReleaseSlot(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $redis->shouldReceive('eval') ->once() ->withArgs(function (string $script, int $numKeys, string $key, string $id): bool { @@ -256,9 +240,7 @@ public function testLeaseCanRefreshSlot(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $redis->shouldReceive('eval') ->once() ->withArgs(function (string $script, int $numKeys, string $key, string $id, int $seconds): bool { @@ -280,9 +262,7 @@ public function testLeaseReturnsRemainingLifetime(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn('test-lock1'); + $this->expectSlotClaim($redis, 'test-lock1'); $redis->shouldReceive('ttl') ->once() ->with('test-lock1') @@ -298,18 +278,16 @@ public function testClusterConnectionTagsSlotKeys(): void $redis = $this->mockRedis(); $redis->shouldReceive('isCluster')->andReturnTrue(); - $redis->shouldReceive('eval') - ->once() - ->withArgs(function (string $script, int $numKeys, ...$args): bool { - $this->assertSame(3, $numKeys); - $this->assertSame('{test-lock}1', $args[0]); - $this->assertSame('{test-lock}2', $args[1]); - $this->assertSame('{test-lock}3', $args[2]); - $this->assertSame('{test-lock}', $args[3]); + $this->expectSlotClaim( + $redis, + '{test-lock}1', + function (string $script, array $keys, array $arguments): bool { + $this->assertSame(['{test-lock}1', '{test-lock}2', '{test-lock}3'], $keys); + $this->assertSame('{test-lock}', $arguments[0]); return true; - }) - ->andReturn('{test-lock}1'); + }, + ); (new ConcurrencyLimiter($redis, 'test-lock', 3, 60))->block(5); } @@ -319,15 +297,16 @@ public function testClusterConnectionLeavesExistingHashTagAlone(): void $redis = $this->mockRedis(); $redis->shouldReceive('isCluster')->andReturnTrue(); - $redis->shouldReceive('eval') - ->once() - ->withArgs(function (string $script, int $numKeys, ...$args): bool { - $this->assertSame('{test-lock}:funnel1', $args[0]); - $this->assertSame('{test-lock}:funnel', $args[1]); + $this->expectSlotClaim( + $redis, + '{test-lock}:funnel1', + function (string $script, array $keys, array $arguments): bool { + $this->assertSame(['{test-lock}:funnel1'], $keys); + $this->assertSame('{test-lock}:funnel', $arguments[0]); return true; - }) - ->andReturn('{test-lock}:funnel1'); + }, + ); (new ConcurrencyLimiter($redis, '{test-lock}:funnel', 1, 60))->block(5); } @@ -342,4 +321,27 @@ private function mockRedis(): m\MockInterface|RedisProxy return $redis; } + + /** + * Expect a slot-claim script evaluation on one held Redis connection. + */ + private function expectSlotClaim( + m\MockInterface|RedisProxy $redis, + false|string $result, + ?callable $assertion = null, + ): void { + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('withConnection') + ->once() + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + + $expectation = $connection->shouldReceive('evalWithShaCache')->once(); + + if ($assertion !== null) { + $expectation->withArgs($assertion); + } + + $expectation->andReturn($result); + } } diff --git a/tests/Redis/DurationLimiterBuilderTest.php b/tests/Redis/DurationLimiterBuilderTest.php index 521504fb5..98ce49a05 100644 --- a/tests/Redis/DurationLimiterBuilderTest.php +++ b/tests/Redis/DurationLimiterBuilderTest.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Redis\Limiters\DurationLimiterBuilder; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; @@ -76,9 +77,7 @@ public function testThenExecutesCallbackWhenLockAcquired(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() Lua script returns success - $redis->shouldReceive('eval') - ->once() - ->andReturn([1, time() + 60, 4]); + $this->expectEvaluation($redis, [1, time() + 60, 4]); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0); @@ -94,8 +93,7 @@ public function testThenCallsFailureCallbackOnTimeout(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() always fails - $redis->shouldReceive('eval') - ->andReturn([0, time() + 60, 0]); + $this->expectEvaluation($redis, [0, time() + 60, 0]); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0)->sleep(1); @@ -119,8 +117,7 @@ public function testThenThrowsExceptionWithoutFailureCallback(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() always fails - $redis->shouldReceive('eval') - ->andReturn([0, time() + 60, 0]); + $this->expectEvaluation($redis, [0, time() + 60, 0]); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0)->sleep(1); @@ -160,4 +157,20 @@ private function mockRedis(): m\MockInterface|RedisProxy { return m::mock(RedisProxy::class); } + + /** + * Expect a script evaluation on one held Redis connection. + */ + private function expectEvaluation(m\MockInterface|RedisProxy $redis, mixed $result): void + { + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('withConnection') + ->once() + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + + $connection->shouldReceive('evalWithShaCache') + ->once() + ->andReturn($result); + } } diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php index 42b0cc8d7..c23b7c075 100644 --- a/tests/Redis/DurationLimiterTest.php +++ b/tests/Redis/DurationLimiterTest.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Redis\Limiters\DurationLimiter; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; @@ -13,7 +14,7 @@ /** * Tests for DurationLimiter. * - * DurationLimiter provides a sliding window rate limiter using Redis Lua scripts. + * DurationLimiter provides a fixed-window rate limiter using Redis Lua scripts. */ class DurationLimiterTest extends TestCase { @@ -21,9 +22,7 @@ public function testAcquireSucceedsWhenBelowLimit(): void { $redis = $this->mockRedis(); // Lua script returns: [acquired (1=success), decaysAt, remaining] - $redis->shouldReceive('eval') - ->once() - ->andReturn([1, time() + 60, 4]); + $this->expectEvaluation($redis, [1, time() + 60, 4]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -33,23 +32,23 @@ public function testAcquireSucceedsWhenBelowLimit(): void $this->assertSame(4, $limiter->remaining); } - public function testAcquireUsesTransformedEvalSignature(): void + public function testAcquireUsesShaCachedEvalSignature(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->withArgs(function (string $script, int $numberOfKeys, string $name, float $microtime, int $timestamp, int $decay, int $maxLocks): bool { + $this->expectEvaluation( + $redis, + [1, time() + 60, 4], + function (string $script, array $keys, array $arguments): bool { $this->assertNotSame('', $script); - $this->assertSame(1, $numberOfKeys); - $this->assertSame('test-key', $name); - $this->assertGreaterThan(0.0, $microtime); - $this->assertGreaterThan(0, $timestamp); - $this->assertSame(60, $decay); - $this->assertSame(5, $maxLocks); + $this->assertSame(['test-key'], $keys); + $this->assertGreaterThan(0.0, $arguments[0]); + $this->assertGreaterThan(0, $arguments[1]); + $this->assertSame(60, $arguments[2]); + $this->assertSame(5, $arguments[3]); return true; - }) - ->andReturn([1, time() + 60, 4]); + }, + ); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -60,9 +59,7 @@ public function testAcquireFailsWhenAtLimit(): void { $redis = $this->mockRedis(); // Lua script returns: [acquired (0=failed), decaysAt, remaining] - $redis->shouldReceive('eval') - ->once() - ->andReturn([0, time() + 30, 0]); + $this->expectEvaluation($redis, [0, time() + 30, 0]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -76,9 +73,7 @@ public function testRemainingIsNeverNegative(): void { $redis = $this->mockRedis(); // Even if script returns negative, remaining should be 0 - $redis->shouldReceive('eval') - ->once() - ->andReturn([0, time() + 60, -2]); + $this->expectEvaluation($redis, [0, time() + 60, -2]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -90,9 +85,7 @@ public function testRemainingIsNeverNegative(): void public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn([time() + 60, 0]); + $this->expectEvaluation($redis, [time() + 60, 0]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -105,9 +98,7 @@ public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn([time() + 60, 3]); + $this->expectEvaluation($redis, [time() + 60, 3]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -117,23 +108,23 @@ public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void $this->assertSame(3, $limiter->remaining); } - public function testTooManyAttemptsUsesTransformedEvalSignature(): void + public function testTooManyAttemptsUsesShaCachedEvalSignature(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->withArgs(function (string $script, int $numberOfKeys, string $name, float $microtime, int $timestamp, int $decay, int $maxLocks): bool { + $this->expectEvaluation( + $redis, + [time() + 60, 2], + function (string $script, array $keys, array $arguments): bool { $this->assertNotSame('', $script); - $this->assertSame(1, $numberOfKeys); - $this->assertSame('test-key', $name); - $this->assertGreaterThan(0.0, $microtime); - $this->assertGreaterThan(0, $timestamp); - $this->assertSame(60, $decay); - $this->assertSame(5, $maxLocks); + $this->assertSame(['test-key'], $keys); + $this->assertGreaterThan(0.0, $arguments[0]); + $this->assertGreaterThan(0, $arguments[1]); + $this->assertSame(60, $arguments[2]); + $this->assertSame(5, $arguments[3]); return true; - }) - ->andReturn([time() + 60, 2]); + }, + ); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -159,9 +150,7 @@ public function testClearDeletesKey(): void public function testBlockExecutesCallbackOnSuccess(): void { $redis = $this->mockRedis(); - $redis->shouldReceive('eval') - ->once() - ->andReturn([1, time() + 60, 4]); + $this->expectEvaluation($redis, [1, time() + 60, 4]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -178,8 +167,13 @@ public function testBlockExecutesCallbackOnSuccess(): void public function testBlockThrowsExceptionAfterTimeout(): void { $redis = $this->mockRedis(); + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('withConnection') + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + // Always fail to acquire - $redis->shouldReceive('eval') + $connection->shouldReceive('evalWithShaCache') ->andReturn([0, time() + 60, 0]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -200,4 +194,27 @@ private function mockRedis(): m\MockInterface|RedisProxy { return m::mock(RedisProxy::class); } + + /** + * Expect a script evaluation on one held Redis connection. + */ + private function expectEvaluation( + m\MockInterface|RedisProxy $redis, + mixed $result, + ?callable $assertion = null, + ): void { + $connection = m::mock(RedisConnection::class); + + $redis->shouldReceive('withConnection') + ->once() + ->andReturnUsing(fn (callable $callback): mixed => $callback($connection)); + + $expectation = $connection->shouldReceive('evalWithShaCache')->once(); + + if ($assertion !== null) { + $expectation->withArgs($assertion); + } + + $expectation->andReturn($result); + } } From 47853fa2a2ce1d31ef2a690b04252d941b2cbf22 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:15:55 +0000 Subject: [PATCH 17/41] Isolate configuration publishing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run no-merge config publishing through Testbench’s framework-configuration bootstrap and a disposable destination. Assert every target is absent before publication so tests cannot pass by comparing Foundation config sources to themselves, while retaining the intentional empty-stub branch. --- .../Console/ConfigPublishCommandTest.php | 28 ------- ...hCommandWithoutMergedConfigurationTest.php | 80 +++++++++++++++++++ 2 files changed, 80 insertions(+), 28 deletions(-) create mode 100644 tests/Integration/Foundation/Console/ConfigPublishCommandWithoutMergedConfigurationTest.php diff --git a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php index 7690811f3..1d85c205a 100644 --- a/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php +++ b/tests/Integration/Foundation/Console/ConfigPublishCommandTest.php @@ -7,7 +7,6 @@ use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\ConfigPublishCommand; -use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\Concerns\InteractsWithPublishedFiles; use Mockery as m; use ReflectionClass; @@ -135,33 +134,6 @@ public function testItOverwritesExistingConfigWithForce(): void ->expectsOutputToContain("Published '{$name}' configuration file."); } - public function testItCanPublishConfigFilesWhenConfiguredWithDontMergeFrameworkConfiguration(): void - { - foreach ([ - 'app', 'auth', 'broadcasting', 'cache', 'cors', - 'database', 'filesystems', 'hashing', 'logging', - 'mail', 'queue', 'session', 'view', - ] as $file) { - $this->preserveConfigFile($file); - } - - $this->artisan('config:publish', ['--all' => true, '--force' => true])->assertOk(); - - foreach ([ - 'app', 'auth', 'broadcasting', 'cache', 'cors', - 'database', 'filesystems', 'hashing', 'logging', - 'mail', 'queue', 'session', 'view', - ] as $file) { - $this->assertFilenameExists("config/{$file}.php"); - $this->assertStringContainsString( - file_get_contents($this->baseConfigPath . "/{$file}.php"), - file_get_contents(config_path("{$file}.php")) - ); - } - - $this->assertSame(config('app.providers'), ServiceProvider::defaultProviders()->toArray()); - } - public function testItFailsWithUnrecognizedConfigFile(): void { $this->artisan('config:publish', ['name' => 'nonexistent-config-file']) diff --git a/tests/Integration/Foundation/Console/ConfigPublishCommandWithoutMergedConfigurationTest.php b/tests/Integration/Foundation/Console/ConfigPublishCommandWithoutMergedConfigurationTest.php new file mode 100644 index 000000000..f6529dffd --- /dev/null +++ b/tests/Integration/Foundation/Console/ConfigPublishCommandWithoutMergedConfigurationTest.php @@ -0,0 +1,80 @@ +afterApplicationCreated(function () use ($files): void { + $files->ensureDirectoryExists($this->app->basePath('published-config')); + $this->app->useConfigPath($this->app->basePath('published-config')); + + $this->beforeApplicationDestroyed(function () use ($files): void { + $files->deleteDirectory($this->app->basePath('published-config')); + }); + }); + + parent::setUp(); + } + + public function testItCanPublishConfigFilesWithoutMergedFrameworkConfiguration(): void + { + $destination = $this->app->basePath('published-config'); + $expectedConfigs = $this->getExpectedConfigFiles(); + + $this->assertSame($destination, config_path()); + + foreach (array_keys($expectedConfigs) as $name) { + $this->assertFileDoesNotExist(config_path("{$name}.php")); + } + + $this->artisan('config:publish', ['--all' => true])->assertOk(); + + foreach ($expectedConfigs as $name => $source) { + $this->assertFilenameExists("published-config/{$name}.php"); + $this->assertSame(file_get_contents($source), file_get_contents(config_path("{$name}.php"))); + } + + $this->assertSame(config('app.providers'), ServiceProvider::defaultProviders()->toArray()); + } + + /** + * Get the framework configuration files keyed by name. + * + * @return array + */ + private function getExpectedConfigFiles(): array + { + $baseConfigPath = dirname((new ReflectionClass(ConfigPublishCommand::class))->getFileName(), 3) . '/config'; + $files = []; + + foreach (Finder::create()->files()->name('*.php')->in($baseConfigPath) as $file) { + $files[basename($file->getPathname(), '.php')] = $file->getPathname(); + } + + ksort($files); + + return $files; + } +} From 0de73d94f89eb1f1c0b1e161ec40811ca546a4c7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:16:09 +0000 Subject: [PATCH 18/41] Add rate limiter benchmark harness Add a developer-only end-to-end benchmark for fixed-window and leaky-bucket decisions through the real manager, pools, drivers, and result decoding. Report throughput and latency percentiles for allowed-heavy, denied-heavy, single-client, and contended workloads without adding a production command. --- tests/Benchmarks/RateLimiter/README.md | 43 ++ tests/Benchmarks/RateLimiter/benchmark.php | 486 +++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 tests/Benchmarks/RateLimiter/README.md create mode 100755 tests/Benchmarks/RateLimiter/benchmark.php diff --git a/tests/Benchmarks/RateLimiter/README.md b/tests/Benchmarks/RateLimiter/README.md new file mode 100644 index 000000000..6e2455c2c --- /dev/null +++ b/tests/Benchmarks/RateLimiter/README.md @@ -0,0 +1,43 @@ +# Rate Limiter Benchmark + +This developer-only harness measures complete rate limiter operations through Hypervel's application container, manager, store wrapper, backend pool, atomic transition, and result decoding. It is not registered as an Artisan command and is not part of the PHPUnit suite. + +Run the default Redis, Swoole, and database workloads from the components repository root: + +```shell +php tests/Benchmarks/RateLimiter/benchmark.php +``` + +The harness loads the repository's `.env` through `tests/bootstrap.php`. Redis uses the connection configured by `rate-limiter.stores.redis.connection`. The database store uses the default database connection unless `--database-connection` selects another one. Its configured `rate_limits` table must already exist, so run the rate limiter migration before benchmarking it. + +For example: + +```shell +php tests/Benchmarks/RateLimiter/benchmark.php \ + --stores=redis,swoole,database \ + --database-connection=mysql \ + --operations=10000 \ + --concurrency=16 \ + --warmup=100 +``` + +Each output row records operations per second and p50, p95, and p99 operation latency. The heading records the PHP and Swoole versions, workload size, warmup, concurrency, and generated rate limiter prefix. Each store also prints its non-secret connection, driver, and sizing inputs. + +The harness measures fixed-window and leaky-bucket policies on both allowed-heavy and denied-heavy paths. It runs each path with one client and with the requested number of clients contending for the same logical policy. Redis and pooled database operations can overlap while awaiting I/O. Swoole operations do not suspend inside one worker, so its concurrent row measures the normal single-worker coroutine workload rather than cross-process lock contention; the forked-worker test suite covers cross-process correctness. + +Use a configured MySQL, MariaDB, or PostgreSQL connection when comparing production database behavior. SQLite results are explicitly labeled and should not be treated as representative of a networked database. + +## Cache Limiter Baseline + +The old cache-backed Redis limiter was measured once from pre-change commit `4c2ea3a9d212aa96016cfe0fa3f7de32ee56aca0` and then removed. Its operation matched the routing middleware path: check the limit, record the hit, and read remaining capacity on acceptance; check the limit and read retry timing on denial. The new operation used `Limiter::consume()` and read the returned decision fields. Both used the same local Redis service and connection configuration. + +Indicative environment: PHP 8.4.23, Swoole 6.2.2, 5,000 measured operations per row, 100 warmup operations, Redis on `127.0.0.1`, and one or 16 clients. + +| Path | Clients | Old operations/s | New operations/s | Old p50 | New p50 | Old p99 | New p99 | +|---|---:|---:|---:|---:|---:|---:|---:| +| Allowed | 1 | 1,310 | 5,944 | 741.14 µs | 156.78 µs | 1,030.79 µs | 270.22 µs | +| Allowed | 16 | 2,719 | 13,050 | 6,173.58 µs | 1,269.18 µs | 7,716.57 µs | 1,491.76 µs | +| Denied | 1 | 2,246 | 5,709 | 433.14 µs | 157.45 µs | 651.11 µs | 273.17 µs | +| Denied | 16 | 4,919 | 13,155 | 3,317.07 µs | 1,235.62 µs | 4,414.36 µs | 1,433.20 µs | + +These are local development measurements, not release claims. Re-run the retained harness on deployment-like Redis, Valkey, Swoole, and database environments before making capacity decisions. diff --git a/tests/Benchmarks/RateLimiter/benchmark.php b/tests/Benchmarks/RateLimiter/benchmark.php new file mode 100755 index 000000000..554180f7b --- /dev/null +++ b/tests/Benchmarks/RateLimiter/benchmark.php @@ -0,0 +1,486 @@ +#!/usr/bin/env php + $stores + */ + public function __construct( + private readonly Repository $config, + private readonly RateLimiter $manager, + private readonly array $stores, + private readonly int $operations, + private readonly int $concurrency, + private readonly int $warmup, + private readonly string $runId, + ) { + } + + /** + * Run every configured benchmark scenario. + */ + public function execute(): void + { + $this->printEnvironment(); + + foreach ($this->stores as $storeName) { + $limiter = $this->manager->store($storeName); + + printf("\nStore: %s\n", $storeName); + printf("Backend: %s\n", $this->describeBackend($storeName, $limiter)); + printf( + "%-13s %-8s %8s %14s %12s %12s %12s\n", + 'policy', + 'path', + 'clients', + 'operations/s', + 'p50 us', + 'p95 us', + 'p99 us', + ); + + foreach (['fixed-window', 'leaky-bucket'] as $policyName) { + foreach (['allowed', 'denied'] as $path) { + foreach (array_values(array_unique([1, $this->concurrency])) as $clients) { + $this->benchmarkScenario($limiter, $storeName, $policyName, $path, $clients); + } + } + } + } + } + + /** + * Benchmark one policy, result path, and client count. + */ + private function benchmarkScenario( + Limiter $limiter, + string $storeName, + string $policyName, + string $path, + int $clients, + ): void { + $expectedAllowed = $path === 'allowed'; + $key = implode(':', [$this->runId, $storeName, $policyName, $path, (string) $clients]); + $policy = $this->makePolicy($policyName, $expectedAllowed, $key); + + $limiter->clear($policy); + + try { + if (! $expectedAllowed) { + $this->consume($limiter, $policy, true); + } + + for ($operation = 0; $operation < $this->warmup; ++$operation) { + $this->consume($limiter, $policy, $expectedAllowed); + } + + $startedAt = hrtime(true); + $samples = $this->runOperations($limiter, $policy, $expectedAllowed, $clients); + $elapsedNanoseconds = hrtime(true) - $startedAt; + + sort($samples, SORT_NUMERIC); + + printf( + "%-13s %-8s %8d %14.0f %12.2f %12.2f %12.2f\n", + $policyName, + $path, + $clients, + $this->operations / ($elapsedNanoseconds / 1_000_000_000), + $this->percentile($samples, 0.50) / 1000, + $this->percentile($samples, 0.95) / 1000, + $this->percentile($samples, 0.99) / 1000, + ); + } finally { + $limiter->clear($policy); + } + } + + /** + * Create the policy for one benchmark path. + */ + private function makePolicy(string $policyName, bool $expectedAllowed, string $key): AdmissionPolicy + { + if (! $expectedAllowed) { + return match ($policyName) { + 'fixed-window' => Limit::perDay(1)->by($key), + 'leaky-bucket' => LeakyBucket::perDay(1)->burst(1)->by($key), + default => throw new LogicException("Unknown benchmark policy [{$policyName}]."), + }; + } + + $capacity = $this->operations + $this->warmup + 1; + + return match ($policyName) { + 'fixed-window' => Limit::perMinute($capacity)->by($key), + 'leaky-bucket' => LeakyBucket::perSecond(min($capacity, 1_000_000)) + ->burst($capacity) + ->by($key), + default => throw new LogicException("Unknown benchmark policy [{$policyName}]."), + }; + } + + /** + * Run the requested operations across the requested number of clients. + * + * @return list per-operation latency samples in nanoseconds + */ + private function runOperations( + Limiter $limiter, + AdmissionPolicy $policy, + bool $expectedAllowed, + int $clients, + ): array { + if ($clients === 1) { + return $this->runClient($limiter, $policy, $expectedAllowed, $this->operations); + } + + $callbacks = []; + $baseOperations = intdiv($this->operations, $clients); + $remainder = $this->operations % $clients; + + for ($client = 0; $client < $clients; ++$client) { + $clientOperations = $baseOperations + ($client < $remainder ? 1 : 0); + $callbacks[] = fn (): array => $this->runClient( + $limiter, + $policy, + $expectedAllowed, + $clientOperations, + ); + } + + $samples = []; + + foreach (parallel($callbacks, $clients) as $clientSamples) { + array_push($samples, ...$clientSamples); + } + + return $samples; + } + + /** + * Run one client's share of the workload. + * + * @return list per-operation latency samples in nanoseconds + */ + private function runClient( + Limiter $limiter, + AdmissionPolicy $policy, + bool $expectedAllowed, + int $operations, + ): array { + $samples = []; + + for ($operation = 0; $operation < $operations; ++$operation) { + $startedAt = hrtime(true); + $this->consume($limiter, $policy, $expectedAllowed); + $samples[] = hrtime(true) - $startedAt; + } + + return $samples; + } + + /** + * Consume one policy and exercise the middleware-relevant result fields. + */ + private function consume(Limiter $limiter, AdmissionPolicy $policy, bool $expectedAllowed): void + { + $result = $limiter->consume($policy); + + if ($result->allowed() !== $expectedAllowed) { + throw new LogicException(sprintf( + 'Expected the benchmark operation to be %s, but it was %s.', + $expectedAllowed ? 'allowed' : 'denied', + $result->allowed() ? 'allowed' : 'denied', + )); + } + + $result->remaining(); + $result->retryAfter(); + } + + /** + * Return the nearest-rank percentile from sorted nanosecond samples. + * + * @param list $samples + */ + private function percentile(array $samples, float $percentile): int + { + $index = (int) ceil(count($samples) * $percentile) - 1; + + return $samples[max(0, min(count($samples) - 1, $index))]; + } + + /** + * Print the reproducibility inputs for this run. + */ + private function printEnvironment(): void + { + printf("Hypervel rate limiter benchmark\n"); + printf("Timestamp: %s\n", gmdate(DATE_ATOM)); + printf("PHP: %s\n", PHP_VERSION); + printf("Swoole: %s\n", phpversion('swoole') ?: 'not loaded'); + printf("Operations per row: %d\n", $this->operations); + printf("Warmup operations per row: %d\n", $this->warmup); + printf("Concurrent clients: %d\n", $this->concurrency); + printf("Rate limiter prefix: %s\n", $this->config->string('rate-limiter.prefix')); + } + + /** + * Describe the non-secret backend inputs for one store. + */ + private function describeBackend(string $storeName, Limiter $limiter): string + { + $storeConfig = $this->config->get("rate-limiter.stores.{$storeName}"); + + if (! is_array($storeConfig) || ! is_string($storeConfig['driver'] ?? null)) { + throw new RuntimeException("Rate limiter store [{$storeName}] has invalid benchmark configuration."); + } + + $driver = $storeConfig['driver']; + $details = match ($driver) { + 'redis' => $this->describeRedis($storeConfig), + 'database' => $this->describeDatabase($storeConfig), + 'swoole' => sprintf( + 'rows=%s conflict_proportion=%s', + (string) ($storeConfig['rows'] ?? 'unknown'), + (string) ($storeConfig['conflict_proportion'] ?? 'unknown'), + ), + default => '', + }; + + return trim(sprintf('%s driver=%s %s', $limiter->getStore()::class, $driver, $details)); + } + + /** + * Describe one Redis benchmark connection without exposing credentials. + * + * @param array $storeConfig + */ + private function describeRedis(array $storeConfig): string + { + $connectionName = $storeConfig['connection'] ?? null; + + if (! is_string($connectionName) || $connectionName === '') { + throw new RuntimeException('The Redis benchmark store requires a connection name.'); + } + + $connection = $this->config->get("database.redis.{$connectionName}"); + + if (! is_array($connection)) { + throw new RuntimeException("Redis connection [{$connectionName}] is not configured."); + } + + return sprintf( + 'connection=%s host=%s port=%s database=%s', + $connectionName, + (string) ($connection['host'] ?? 'url'), + (string) ($connection['port'] ?? 'url'), + (string) ($connection['database'] ?? 'default'), + ); + } + + /** + * Describe one database benchmark connection without exposing credentials. + * + * @param array $storeConfig + */ + private function describeDatabase(array $storeConfig): string + { + $connectionName = $storeConfig['connection'] ?? $this->config->string('database.default'); + + if (! is_string($connectionName) || $connectionName === '') { + throw new RuntimeException('The database benchmark store requires a connection name.'); + } + + $connection = $this->config->get("database.connections.{$connectionName}"); + + if (! is_array($connection)) { + throw new RuntimeException("Database connection [{$connectionName}] is not configured."); + } + + return sprintf( + 'connection=%s driver=%s database=%s table=%s', + $connectionName, + (string) ($connection['driver'] ?? 'unknown'), + (string) ($connection['database'] ?? 'url'), + (string) ($storeConfig['table'] ?? 'unknown'), + ); + } +} + +/** + * Run the benchmark CLI. + */ +function main(): int +{ + $options = getopt('', [ + 'stores:', + 'operations:', + 'concurrency:', + 'warmup:', + 'database-connection:', + 'help', + ]); + + if ($options === false) { + fwrite(STDERR, "Unable to parse benchmark options.\n"); + + return 1; + } + + if (array_key_exists('help', $options)) { + printUsage(); + + return 0; + } + + try { + $stores = parseStores($options['stores'] ?? 'redis,swoole,database'); + $operations = parseIntegerOption($options, 'operations', 10_000, 1, 1_000_000); + $concurrency = parseIntegerOption($options, 'concurrency', 16, 1, $operations); + $warmup = parseIntegerOption($options, 'warmup', 100, 0, 100_000); + + $application = TestbenchApplication::create( + options: ['load_environment_variables' => false], + ); + + try { + $config = $application->make(Repository::class); + $databaseConnection = $options['database-connection'] ?? null; + + if ($databaseConnection !== null) { + if (! is_string($databaseConnection) || $databaseConnection === '') { + throw new InvalidArgumentException('--database-connection must be a non-empty string.'); + } + + $config->set('rate-limiter.stores.database.connection', $databaseConnection); + } + + $runId = sprintf('%d-%s', getmypid(), bin2hex(random_bytes(6))); + $config->set('rate-limiter.prefix', 'benchmark_rate_limiter_' . $runId); + + $benchmark = new RateLimiterBenchmark( + $config, + $application->make(RateLimiter::class), + $stores, + $operations, + $concurrency, + $warmup, + $runId, + ); + + $executionException = null; + + $completed = run(function () use ($benchmark, &$executionException): void { + try { + $benchmark->execute(); + } catch (Throwable $throwable) { + $executionException = $throwable; + } + }); + + if ($executionException !== null) { + throw $executionException; + } + + if (! $completed) { + throw new RuntimeException('The benchmark coroutine did not complete.'); + } + } finally { + $application->terminate(); + } + } catch (Throwable $throwable) { + fwrite(STDERR, sprintf("Benchmark failed: %s: %s\n", $throwable::class, $throwable->getMessage())); + + return 1; + } + + return 0; +} + +/** + * Parse a comma-separated store list. + * + * @return list + */ +function parseStores(mixed $value): array +{ + if (! is_string($value)) { + throw new InvalidArgumentException('--stores must be a comma-separated string.'); + } + + $stores = array_values(array_unique(array_filter( + array_map(trim(...), explode(',', $value)), + static fn (string $store): bool => $store !== '', + ))); + + if ($stores === []) { + throw new InvalidArgumentException('--stores must contain at least one store name.'); + } + + return $stores; +} + +/** + * Parse and validate one integer option. + * + * @param array|false|string> $options + */ +function parseIntegerOption(array $options, string $name, int $default, int $minimum, int $maximum): int +{ + $value = $options[$name] ?? (string) $default; + + if (! is_string($value) || filter_var($value, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException("--{$name} must be an integer."); + } + + $value = (int) $value; + + if ($value < $minimum || $value > $maximum) { + throw new InvalidArgumentException("--{$name} must be between {$minimum} and {$maximum}."); + } + + return $value; +} + +/** + * Print command usage. + */ +function printUsage(): void +{ + echo <<<'TEXT' +Usage: php tests/Benchmarks/RateLimiter/benchmark.php [options] + +Options: + --stores=LIST Comma-separated configured stores (default: redis,swoole,database) + --operations=COUNT Operations measured per output row (default: 10000) + --concurrency=COUNT Clients used for the concurrent rows (default: 16) + --warmup=COUNT Unmeasured warmup operations per row (default: 100) + --database-connection=NAME Database connection used by the database limiter store + --help Show this help + +TEXT; +} + +exit(main()); From 3168557c7593c0b7b0f3c0dc339f1fec52f6754a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:16:41 +0000 Subject: [PATCH 19/41] Document the rate limiter component Document typed policies, decisions, direct APIs, stores, configuration, database migration and pruning, distribution guarantees, failure behavior, and custom drivers. Point facade users at the canonical Hypervel RateLimiter manager and keep rate-limiting.md as the single user guide. --- src/boost/docs/facades.md | 2 +- src/boost/docs/rate-limiting.md | 410 ++++++++++++++++++++++++++------ 2 files changed, 332 insertions(+), 80 deletions(-) diff --git a/src/boost/docs/facades.md b/src/boost/docs/facades.md index c8e66a14f..f7803f390 100644 --- a/src/boost/docs/facades.md +++ b/src/boost/docs/facades.md @@ -240,7 +240,7 @@ Below you will find many of Hypervel's facades and their underlying classes. Thi | Queue (Base Class) | [Hypervel\Queue\Queue](https://api.hypervel.org/docs/{{version}}/Hypervel/Queue/Queue.html) |   | | Queue (Instance) | [Hypervel\Contracts\Queue\Queue](https://api.hypervel.org/docs/{{version}}/Hypervel/Contracts/Queue/Queue.html) | `queue.connection` | | Queue | [Hypervel\Queue\QueueManager](https://api.hypervel.org/docs/{{version}}/Hypervel/Queue/QueueManager.html) | `queue` | -| RateLimiter | [Hypervel\Cache\RateLimiter](https://api.hypervel.org/docs/{{version}}/Hypervel/Cache/RateLimiter.html) |   | +| RateLimiter | [Hypervel\RateLimiter\RateLimiter](https://api.hypervel.org/docs/{{version}}/Hypervel/RateLimiter/RateLimiter.html) |   | | Redirect | [Hypervel\Routing\Redirector](https://api.hypervel.org/docs/{{version}}/Hypervel/Routing/Redirector.html) | `redirect` | | Redis (Instance) | [Hypervel\Redis\RedisProxy](https://api.hypervel.org/docs/{{version}}/Hypervel/Redis/RedisProxy.html) | `redis.connection` | | Redis | [Hypervel\Redis\RedisManager](https://api.hypervel.org/docs/{{version}}/Hypervel/Redis/RedisManager.html) | `redis` | diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index fc640ad77..a0e552b24 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -1,160 +1,412 @@ # Rate Limiting - [Introduction](#introduction) - - [Cache Configuration](#cache-configuration) -- [Basic Usage](#basic-usage) - - [Manually Incrementing Attempts](#manually-incrementing-attempts) - - [Retrieving Attempts](#retrieving-attempts) - - [Clearing Attempts](#clearing-attempts) +- [Configuration](#configuration) + - [Available Stores](#available-stores) + - [Database Store](#database-store) + - [Swoole Store](#swoole-store) +- [Defining Policies](#defining-policies) + - [Fixed Windows](#fixed-windows) + - [Leaky Buckets](#leaky-buckets) + - [Weighted Operations](#weighted-operations) + - [Unlimited Policies](#unlimited-policies) +- [Using the Rate Limiter](#using-the-rate-limiter) + - [Consuming Capacity](#consuming-capacity) + - [Inspecting State](#inspecting-state) + - [Attempting Operations](#attempting-operations) + - [Clearing State](#clearing-state) + - [Selecting a Store](#selecting-a-store) +- [Exponential Backoff](#exponential-backoff) +- [Named Rate Limiters](#named-rate-limiters) +- [Custom Stores](#custom-stores) +- [Failure Behavior](#failure-behavior) ## Introduction -Hypervel includes a simple to use rate limiting abstraction which, in conjunction with your application's [cache](/docs/{{version}}/cache), provides an easy way to limit any action during a specified window of time. +Hypervel includes a powerful rate limiter that you may use to limit HTTP routes, queued jobs, authentication attempts, external API calls, and other operations. Rate limit state is stored using dedicated, atomic operations instead of Hypervel's general-purpose cache. + +The rate limiter supports: + +- fixed-window limits; +- continuously replenishing leaky buckets; +- weighted operations; +- capped exponential failure backoff; +- Redis, Swoole, database, and worker-local array stores; and +- custom stores registered through Hypervel's familiar manager extension API. + +Every rate limit operation returns a result containing whether the operation was allowed, its remaining capacity, and any retry or reset delay. Your application does not need to perform another store lookup after consuming capacity. > [!NOTE] -> If you are interested in rate limiting incoming HTTP requests, please consult the [rate limiter middleware documentation](/docs/{{version}}/routing#rate-limiting). +> If you are limiting incoming HTTP requests, consult the [routing rate limiter documentation](/docs/{{version}}/routing#rate-limiting). For queued jobs, consult the [queue middleware documentation](/docs/{{version}}/queues#rate-limiting). - -### Cache Configuration + +## Configuration -Typically, the rate limiter utilizes your default application cache as defined by the `default` key within your application's `cache` configuration file. However, you may specify which cache driver the rate limiter should use by defining a `limiter` key within your application's `cache` configuration file: +The default rate limiter configuration is stored in your application's `config/rate-limiter.php` file: ```php -'default' => env('CACHE_STORE', 'database'), +return [ + 'default' => env('RATE_LIMITER_STORE', 'database'), + + 'stores' => [ + 'database' => [ + 'driver' => 'database', + 'connection' => env('RATE_LIMITER_DB_CONNECTION'), + 'table' => env('RATE_LIMITER_DB_TABLE', 'rate_limits'), + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('RATE_LIMITER_REDIS_CONNECTION', 'default'), + ], + + 'swoole' => [ + 'driver' => 'swoole', + 'rows' => (int) env('RATE_LIMITER_SWOOLE_ROWS', 65536), + 'conflict_proportion' => 0.2, + 'memory_limit_buffer' => 0.05, + 'prune_interval' => 60, + ], + + 'worker-array' => [ + 'driver' => 'worker-array', + ], + ], + + 'prefix' => env('RATE_LIMITER_PREFIX', app_id() . '_rate_limiter'), +]; +``` + +The `prefix` keeps limiter state separate when multiple applications use the same backend. Hypervel includes this value when generating its hashed limiter keys. + + +### Available Stores + +Hypervel includes four rate limiter stores: + +| Store | Scope | Recommended Use | +|---|---|---| +| `redis` | Shared across application servers | High-throughput distributed rate limiting | +| `database` | Shared across application servers | Distributed rate limiting without requiring Redis | +| `swoole` | Workers belonging to one Swoole server instance | Very high-throughput local limiting | +| `worker-array` | One worker process | Tests and deliberately worker-local workloads | + +The Redis store performs each rate limit operation using one pooled connection checkout and one cached Lua script. The database store uses transactions and row locks, making it a portable choice when Redis is not available, though it does not offer the same throughput as Redis. + +The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but it is not shared by independent Hypervel server instances or different machines. The `worker-array` store is not shared between workers at all. It does not prune entries in the background, so an expired entry remains until the same key is updated or cleared, or the worker restarts. Reverb clears its per-connection message limit when the connection closes. + + +### Database Store + +The database store uses a dedicated `rate_limits` table. Fresh Hypervel applications include this migration by default. Existing applications may generate it using the `make:rate-limiter-table` command: + +```shell +php artisan make:rate-limiter-table -'limiter' => 'redis', // [tl! add] +php artisan migrate ``` - -## Basic Usage +The `rate-limiter:table` command is also available as an alias. -The `Hypervel\Support\Facades\RateLimiter` facade may be used to interact with the rate limiter. The simplest method offered by the rate limiter is the `attempt` method, which rate limits a given callback for a given number of seconds. +Do not change database rate limit state while the store's selected connection is already inside a transaction. This restriction applies to consuming capacity, recording failures, clearing state, and pruning expired rows. Hypervel will throw a `LogicException` when one of these operations is called inside an active transaction. -The `attempt` method returns `false` when the callback has no remaining attempts available; otherwise, the `attempt` method will return the callback's result or `true`. The first argument accepted by the `attempt` method is a rate limiter "key", which may be any string of your choosing that represents the action being rate limited: +If your application must rate limit from inside a transaction, configure the rate limiter store to use a separate named database connection through its `connection` option. The connection may use the same database server or a dedicated rate limiter database. Run the `rate_limits` migration on every connection used by a database rate limiter store. + +PostgreSQL limiter connections must use the default `READ COMMITTED` transaction isolation level. MySQL and MariaDB's default `REPEATABLE READ` isolation level is supported. + +The `inspect` method remains available inside a transaction because it does not change rate limit state. However, when using `REPEATABLE READ` with MySQL or MariaDB, it reads the outer transaction's snapshot and may not include changes committed after that transaction began. + +Expired database rows should be pruned periodically. You may schedule the prune command to run hourly: ```php -use Hypervel\Support\Facades\RateLimiter; +use Hypervel\Support\Facades\Schedule; -$executed = RateLimiter::attempt( - 'send-message:'.$user->id, - $perMinute = 5, - function() { - // Send message... - } -); +Schedule::command('rate-limiter:prune')->hourly(); +``` -if (! $executed) { - return 'Too many messages sent!'; -} +You may provide a store name and batch size when necessary: + +```shell +php artisan rate-limiter:prune database --chunk=2000 ``` -If necessary, you may provide a fourth argument to the `attempt` method, which is the "decay rate", or the number of seconds until the available attempts are reset. For example, we can modify the example above to allow five attempts every two minutes: + +### Swoole Store + +The Swoole store allocates its table before server workers are forked. Changes to its table settings therefore require a server restart. + +Size the table for the peak number of concurrently active physical limiter keys, plus headroom. A key remains active for its fixed window, leaky-bucket refill period, or backoff inactivity period. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. + +Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, and fails closed if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. + +Expired rows are pruned by worker zero at the configured `prune_interval`, in seconds. A mutating operation also replaces expired state for its key. Inspection treats expired state as empty without changing the table. + + +## Defining Policies + +Rate limit policies are immutable. Methods such as `by`, `cost`, and `burst` return a new policy without changing the original, so policies may be safely reused by long-running workers. + +Use the `by` method to scope a policy to a user, tenant, IP address, or any other stable identifier: ```php -$executed = RateLimiter::attempt( - 'send-message:'.$user->id, - $perTwoMinutes = 5, - function() { - // Send message... - }, - $decayRate = 120, -); +use Hypervel\RateLimiter\Limit; + +$policy = Limit::perMinute(60)->by('user:'.$user->id); ``` - -### Manually Incrementing Attempts +String-backed and integer-backed enums, strings, integers, stringable objects, and `null` are accepted as keys. A `null` key represents the same shared policy as an empty string. + + +### Fixed Windows -If you would like to manually interact with the rate limiter, a variety of other methods are available. For example, you may invoke the `tooManyAttempts` method to determine if a given rate limiter key has exceeded its maximum number of allowed attempts per minute: +The `Limit` class defines a fixed window whose timer begins with its first accepted operation: ```php -use Hypervel\Support\Facades\RateLimiter; +use Hypervel\RateLimiter\Limit; -if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $perMinute = 5)) { - return 'Too many attempts!'; -} +$perSecond = Limit::perSecond(10); +$perMinute = Limit::perMinute(60); +$perFiveMinutes = Limit::perMinutes(5, 300); +$perHour = Limit::perHour(1000); +$perDay = Limit::perDay(10_000); +``` + +Each factory also accepts a duration multiplier. For example, the following policy allows 120 operations during a two-minute window: + +```php +$policy = Limit::perMinute(120, decayMinutes: 2); +``` + +A denied operation does not consume capacity or extend the active window. + + +### Leaky Buckets + +The `LeakyBucket` policy replenishes capacity continuously instead of resetting all capacity at one window boundary. Hypervel implements this behavior using the Generic Cell Rate Algorithm, which requires only one timestamp per limiter key. + +```php +use Hypervel\RateLimiter\LeakyBucket; + +$policy = LeakyBucket::perSecond(100) + ->burst(200) + ->by('api-token:'.$token->id); +``` + +This policy sustains 100 operations per second while allowing an initial burst of up to 200 operations. The burst value is the total immediately available capacity, not additional capacity beyond the configured rate. + +If `burst` is omitted, it defaults to the rate supplied to the factory. To strictly smooth a policy to one immediately available operation, explicitly use `burst(1)`: + +```php +$policy = LeakyBucket::perSecond(100)->burst(1); +``` -RateLimiter::increment('send-message:'.$user->id); +The same `perMinute`, `perMinutes`, `perHour`, and `perDay` factories available on `Limit` are also available on `LeakyBucket`. -// Send message... + +### Weighted Operations + +By default, an operation consumes one unit of capacity. Use `cost` when some operations should consume more: + +```php +$policy = Limit::perMinute(100) + ->cost(5) + ->by('uploads:'.$user->id); ``` -Alternatively, you may use the `remaining` method to retrieve the number of attempts remaining for a given key. If a given key has retries remaining, you may invoke the `increment` method to increment the number of total attempts: +The cost may not exceed the fixed-window capacity or leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. + + +### Unlimited Policies + +Use `Limit::none()` when a named limiter should deliberately allow all operations: + +```php +return $user->isAdministrator() + ? Limit::none() + : Limit::perMinute(60)->by($user->id); +``` + +Unlimited policies do not access the configured store. + + +## Using the Rate Limiter + +You may interact with the rate limiter using the `Hypervel\Support\Facades\RateLimiter` facade. By default, operations use the store configured by the `default` option. You may also inject `Hypervel\RateLimiter\RateLimiter` into your classes. + + +### Consuming Capacity + +The `consume` method atomically decides whether the requested capacity is available and, when allowed, commits the operation: ```php +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; -if (RateLimiter::remaining('send-message:'.$user->id, $perMinute = 5)) { - RateLimiter::increment('send-message:'.$user->id); +$result = RateLimiter::consume( + Limit::perMinute(5)->by('send-message:'.$user->id), +); - // Send message... +if ($result->denied()) { + return 'Try again in '.$result->retryAfter().' seconds.'; } ``` -If you would like to increment the value for a given rate limiter key by more than one, you may provide the desired amount to the `increment` method: +A `LimitResult` provides: + +- `allowed()` and `denied()`; +- `limit()`, the fixed-window capacity or leaky-bucket burst capacity; +- `remaining()`, the whole capacity immediately available after the decision; +- `retryAfter()`, the minimum whole seconds until this policy's cost may be accepted; and +- `resetAfter()`, the whole seconds until the fixed window expires or the leaky bucket becomes full. + +Durations are rounded up, ensuring a caller is never instructed to retry before capacity is actually available. + + +### Inspecting State + +The `inspect` method returns a decision without consuming capacity or creating state: ```php -RateLimiter::increment('send-message:'.$user->id, amount: 5); +$result = RateLimiter::inspect($policy); + +if ($result->allowed()) { + // The policy's configured cost is currently available... +} ``` -If you would like to decrement the value for a given rate limiter key, you may use the `decrement` method: +Inspection is useful when your application must decide whether to begin expensive work before recording a separate event. Because another request may consume capacity immediately afterward, you should not use `inspect` followed by `consume` as a replacement for a single atomic `consume` call. + + +### Attempting Operations + +The `attempt` method consumes capacity before executing a callback. It returns `false` when the policy is denied; otherwise, it returns the callback result. A `null` callback result is converted to `true`: ```php -RateLimiter::decrement('send-message:'.$user->id); +$executed = RateLimiter::attempt($policy, function () use ($message): void { + $message->send(); +}); + +if ($executed === false) { + return 'Too many messages sent!'; +} ``` - -### Retrieving Attempts +The accepted capacity remains consumed if the callback throws an exception. This preserves the atomic admission decision and avoids allowing repeated failing work for free. + + +### Clearing State -You may use the `attempts` method to retrieve the number of attempts for a given rate limiter key: +The `clear` method removes the state addressed by a policy: ```php -$attempts = RateLimiter::attempts('send-message:'.$user->id); +RateLimiter::clear( + Limit::perMinute(5)->by('send-message:'.$user->id), +); ``` - -#### Determining Limiter Availability +Policy type and stable parameters are part of the stored identity. Therefore, `clear` must receive the same policy type, capacity, window or refill settings, key, and global scope that created the state. Changing policy parameters intentionally starts fresh state while the old entry expires naturally. + +Callbacks and operation cost are not part of the stable policy identity. This allows the same bucket to charge operations with different costs. -When a key has no more attempts left, the `availableIn` method returns the number of seconds remaining until more attempts will be available: + +### Selecting a Store + +Use `store` to perform an operation against a configured store other than the default: ```php +$result = RateLimiter::store('redis')->consume($policy); +``` + +The store name may also be an enum. You should configure the default store during application boot instead of changing it during a request, since the configured default is shared by the entire worker. + + +## Exponential Backoff + +An exponential backoff policy tracks failures rather than admitted requests. This makes it suitable for authentication failures or unstable external services: + +```php +use Hypervel\RateLimiter\Backoff; use Hypervel\Support\Facades\RateLimiter; -if (RateLimiter::tooManyAttempts('send-message:'.$user->id, $perMinute = 5)) { - $seconds = RateLimiter::availableIn('send-message:'.$user->id); +$backoff = Backoff::exponential( + after: 5, + initialDelay: 1, + maxDelay: 300, + resetAfter: 3600, +)->by('login:'.$username.':'.$request->ip()); + +$decision = RateLimiter::inspect($backoff); - return 'You may try again in '.$seconds.' seconds.'; +if ($decision->denied()) { + return 'Try again in '.$decision->retryAfter().' seconds.'; } -RateLimiter::increment('send-message:'.$user->id); +try { + $this->authenticate($request); + RateLimiter::clear($backoff); +} catch (AuthenticationException $exception) { + RateLimiter::recordFailure($backoff); -// Send message... + throw $exception; +} ``` - -### Clearing Attempts +The failure identified by `after` creates the initial delay. Every subsequent recorded failure doubles that delay until `maxDelay` is reached. If no failure is recorded during `resetAfter`, the history expires. A successful operation should call `clear`. + +The returned `BackoffResult` provides `allowed()`, `denied()`, `failures()`, and `retryAfter()`. + + +## Named Rate Limiters -You may reset the number of attempts for a given rate limiter key using the `clear` method. For example, you may reset the number of attempts when a given message is read by the receiver: +Named rate limiters are registered with the facade's `for` method and may select their own store: ```php -use App\Models\Message; +use Hypervel\RateLimiter\LeakyBucket; use Hypervel\Support\Facades\RateLimiter; -/** - * Mark the message as read. - */ -public function read(Message $message): Message -{ - $message->markAsRead(); +RateLimiter::for('api', function ($request) { + return LeakyBucket::perSecond(100) + ->burst(200) + ->by($request->user()?->getAuthIdentifier() ?? $request->ip()); +}, store: 'redis'); +``` + +You should register named limiters during application boot because their definitions are shared for the lifetime of the worker. Named limiters may be used by routing and queue middleware. The routing documentation covers [attaching named limiters to routes](/docs/{{version}}/routing#attaching-rate-limiters-to-routes), response callbacks, global policies, and stacked policies. - RateLimiter::clear('send-message:'.$message->user_id); + +## Custom Stores + +Custom drivers implement `Hypervel\RateLimiter\Contracts\Store`. Register the driver from a service provider's `boot` method using the manager's `extend` method: + +```php +use Hypervel\Contracts\Foundation\Application; +use Hypervel\RateLimiter\Contracts\Store; +use Hypervel\RateLimiter\RateLimiter; - return $message; +public function boot(RateLimiter $rateLimiter): void +{ + $rateLimiter->extend('custom', function (Application $app, array $config): Store { + return new CustomRateLimiterStore(/* ... */); + }); } ``` -If you would like to reset the number of attempts for a given rate limiter key without clearing the lockout timer, you may use the `resetAttempts` method: +Then add the driver to `rate-limiter.stores`: ```php -RateLimiter::resetAttempts('send-message:'.$user->id); +'custom' => [ + 'driver' => 'custom', +], ``` + +The manager also passes the requested store name to the driver callback as `$config['name']`. This value is set by the manager and replaces any `name` entry in the store configuration. + +A store receives a validated policy and a fixed-length key. It must implement `consume` atomically, provide a non-mutating `inspect` operation, record backoff failures, and clear keyed state. Custom stores should follow the same decision and timing semantics as Hypervel's built-in stores. + + +## Failure Behavior + +Rate limiting fails closed. Backend, connection pool, script, table allocation, and database errors are thrown instead of silently allowing the operation or falling back to a worker-local store. + +Choose and operate the store according to the availability requirements of the protected operation. Hypervel never changes to a weaker store automatically because doing so would produce different limits on different workers or application servers. From b9b10f7d534f7995cfa3426e7959c58032ef66b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:16:55 +0000 Subject: [PATCH 20/41] Document unified HTTP rate limiting Update routing and middleware guidance for named stores, weighted costs, leaky buckets, response-based charging, headers, and the single throttle middleware. Remove the old Redis middleware switch and explain backend selection through rate-limiter stores. --- src/boost/docs/middleware.md | 23 ++------------- src/boost/docs/routing.md | 55 +++++++++++++++++++++++------------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/src/boost/docs/middleware.md b/src/boost/docs/middleware.md index 02fdd998d..55a1a1a79 100644 --- a/src/boost/docs/middleware.md +++ b/src/boost/docs/middleware.md @@ -296,25 +296,7 @@ use Hypervel\Foundation\Configuration\Middleware; }) ``` -If you would like API throttling to use Redis, you may pass the `redis` argument to the `throttleApi` method: - -```php -use Hypervel\Foundation\Configuration\Middleware; - -->withMiddleware(function (Middleware $middleware): void { - $middleware->throttleApi(redis: true); -}) -``` - -If you are registering throttling middleware manually and still want the `throttle` alias to use Redis, you may call the `throttleWithRedis` method: - -```php -use Hypervel\Foundation\Configuration\Middleware; - -->withMiddleware(function (Middleware $middleware): void { - $middleware->throttleWithRedis(); -}) -``` +API throttling uses the store registered for the named limiter, or your application's default rate limiter store. Please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#configuration) to learn how to configure stores. If you would like to append or prepend middleware to these groups, you may use the `web` and `api` methods within your application's `bootstrap/app.php` file. The `web` and `api` methods are convenient alternatives to the `appendToGroup` method: @@ -422,7 +404,7 @@ For convenience, some of Hypervel's built-in middleware are aliased by default. | `password.confirm` | `Hypervel\Auth\Middleware\RequirePassword` | | `precognitive` | `Hypervel\Foundation\Http\Middleware\HandlePrecognitiveRequests` | | `signed` | `Hypervel\Routing\Middleware\ValidateSignature` | -| `throttle` | `Hypervel\Routing\Middleware\ThrottleRequests` or `Hypervel\Routing\Middleware\ThrottleRequestsWithRedis` | +| `throttle` | `Hypervel\Routing\Middleware\ThrottleRequests` | | `verified` | `Hypervel\Auth\Middleware\EnsureEmailIsVerified` | @@ -444,7 +426,6 @@ use Hypervel\Foundation\Configuration\Middleware; \Hypervel\View\Middleware\ShareErrorsFromSession::class, \Hypervel\Contracts\Auth\Middleware\AuthenticatesRequests::class, \Hypervel\Routing\Middleware\ThrottleRequests::class, - \Hypervel\Routing\Middleware\ThrottleRequestsWithRedis::class, \Hypervel\Contracts\Session\Middleware\AuthenticatesSessions::class, \Hypervel\Routing\Middleware\SubstituteBindings::class, \Hypervel\Auth\Middleware\Authorize::class, diff --git a/src/boost/docs/routing.md b/src/boost/docs/routing.md index 04112adb9..4a1009de4 100644 --- a/src/boost/docs/routing.md +++ b/src/boost/docs/routing.md @@ -24,7 +24,10 @@ - [Fallback Routes](#fallback-routes) - [Rate Limiting](#rate-limiting) - [Defining Rate Limiters](#defining-rate-limiters) + - [Segmenting Rate Limits](#segmenting-rate-limits) - [Scoping Named Rate Limits](#scoping-named-rate-limits) + - [Multiple Rate Limits](#multiple-rate-limits) + - [Response-Based Rate Limiting](#response-base-rate-limiting) - [Attaching Rate Limiters to Routes](#attaching-rate-limiters-to-routes) - [Form Method Spoofing](#form-method-spoofing) - [Accessing the Current Route](#accessing-the-current-route) @@ -856,8 +859,8 @@ Hypervel includes powerful and customizable rate limiting services that you may Rate limiters may be defined within the `boot` method of your application's `App\Providers\AppServiceProvider` class: ```php -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; /** @@ -871,11 +874,11 @@ public function boot(): void } ``` -Rate limiters are defined using the `RateLimiter` facade's `for` method. The `for` method accepts a rate limiter name and a closure that returns the limit configuration that should apply to routes that are assigned to the rate limiter. Limit configuration are instances of the `Hypervel\Cache\RateLimiting\Limit` class. This class contains helpful "builder" methods so that you can quickly define your limit. The rate limiter name may be any string you wish: +Rate limiters are defined using the `RateLimiter` facade's `for` method. The `for` method accepts a rate limiter name and a closure that returns the limit configuration that should apply to routes assigned to the limiter. The rate limiter name may be any string you wish: ```php -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; /** @@ -889,6 +892,8 @@ public function boot(): void } ``` +The `Limit` class defines a fixed-window limit and provides convenient `perSecond`, `perMinute`, `perMinutes`, `perHour`, and `perDay` methods. Rate limit policies are immutable, so modifier methods such as `by`, `cost`, and `response` return a new policy instead of changing the original. + If the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Hypervel. If you would like to define your own response that should be returned by a rate limit, you may use the `response` method: ```php @@ -948,16 +953,24 @@ RateLimiter::resolveKeyScopeUsing(function (string $limiter): ?string { Register the resolver only during application boot. It receives the named limiter and runs when Hypervel builds a key for route or queue rate limiting. Returning `null` keeps the normal key. -The resolver applies only to named rate limiters. If a named limit should remain shared across every scope, return a `GlobalLimit`: +The resolver applies only to named rate limiters. If a named limit should remain shared across every scope, use the `globally` method: ```php -use Hypervel\Cache\RateLimiting\GlobalLimit; - RateLimiter::for('shared-api', function () { - return new GlobalLimit(maxAttempts: 1000, decaySeconds: 60); + return Limit::perMinute(1000)->globally(); }); ``` +Named route limiters may use any policy supported by Hypervel, including fixed-window and leaky-bucket limits with weighted costs. To learn more about the available policies, please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#defining-policies). + +The optional third argument to `RateLimiter::for` selects a configured store for the named limiter. When omitted, Hypervel uses the default rate limiter store: + +```php +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?? $request->ip()); +}, store: 'redis'); +``` + #### Multiple Rate Limits @@ -972,17 +985,19 @@ RateLimiter::for('login', function (Request $request) { }); ``` -If you're assigning multiple rate limits segmented by identical `by` values, you should ensure that each `by` value is unique. The easiest way to achieve this is to prefix the values given to the `by` method: +Policy type and stable parameters are part of the stored identity, so different windows or algorithms may safely use the same `by` value: ```php RateLimiter::for('uploads', function (Request $request) { return [ - Limit::perMinute(10)->by('minute:'.$request->user()->id), - Limit::perDay(1000)->by('day:'.$request->user()->id), + Limit::perMinute(10)->by($request->user()->id), + Limit::perDay(1000)->by($request->user()->id), ]; }); ``` +Policies are consumed in the order they are returned. If a later policy denies the request, capacity already consumed by earlier policies is not restored. + #### Response-Based Rate Limiting @@ -991,8 +1006,8 @@ In addition to rate limiting incoming requests, Hypervel allows you to rate limi The `after` method accepts a closure that receives the response and should return `true` if the response should be counted toward the rate limit, or `false` if it should be ignored. This is particularly useful for preventing enumeration attacks by limiting consecutive 404 responses, or allowing users to retry requests that fail validation without exhausting their rate limit on an endpoint that should only throttle successful operations: ```php -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; use Symfony\Component\HttpFoundation\Response; @@ -1006,6 +1021,8 @@ RateLimiter::for('resource-not-found', function (Request $request) { }); ``` +Hypervel inspects a response-based policy before invoking the route, then consumes it only when the callback returns `true`. Another request may consume the final capacity while the response is being produced. In that case, Hypervel does not reject the completed response, but its rate limit headers reflect the final decision. + ### Attaching Rate Limiters to Routes @@ -1023,20 +1040,18 @@ Route::middleware(['throttle:uploads'])->group(function () { }); ``` - -#### Throttling With Redis - -By default, the `throttle` middleware is mapped to the `Hypervel\Routing\Middleware\ThrottleRequests` class. However, if you are using Redis as your application's cache driver, you may wish to instruct Hypervel to use Redis to manage rate limiting. To do so, you should use the `throttleWithRedis` method in your application's `bootstrap/app.php` file. This method maps the `throttle` middleware to the `Hypervel\Routing\Middleware\ThrottleRequestsWithRedis` middleware class: +You may also define an inline fixed-window limit by providing the maximum attempts and decay minutes directly to the middleware: ```php -use Hypervel\Foundation\Configuration\Middleware; - -->withMiddleware(function (Middleware $middleware): void { - $middleware->throttleWithRedis(); +Route::middleware(['throttle:60,1'])->group(function () { // ... -}) +}); ``` +Hypervel uses one `Hypervel\Routing\Middleware\ThrottleRequests` implementation for every rate limiter store. To use Redis, configure the named limiter's store as shown above or select Redis as `rate-limiter.default`; no separate Redis middleware is required. + +Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining`. A denied request receives a 429 response with `Retry-After` and `X-RateLimit-Reset` headers in addition to the limit and remaining headers. + ## Form Method Spoofing From d61e22dac48673da45875c505f85ff28b4973f86 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:17:12 +0000 Subject: [PATCH 21/41] Update rate-limited framework consumer documentation Update queue, authentication, exception-reporting, and starter-kit examples to use the dedicated rate-limiter policies and store selectors. Remove Redis-specific queue middleware and old Cache namespace examples while preserving Laravel-style task-focused prose. --- src/boost/docs/errors.md | 6 +++--- src/boost/docs/fortify.md | 2 +- src/boost/docs/queues.md | 38 ++++++++++------------------------ src/boost/docs/starter-kits.md | 2 +- 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/src/boost/docs/errors.md b/src/boost/docs/errors.md index d7e07fc36..23cb11d3e 100644 --- a/src/boost/docs/errors.md +++ b/src/boost/docs/errors.md @@ -417,7 +417,7 @@ You may also rate limit exceptions logged or sent to an external error tracking ```php use Hypervel\Broadcasting\BroadcastException; -use Hypervel\Cache\RateLimiting\Limit; +use Hypervel\RateLimiter\Limit; use Throwable; ->withExceptions(function (Exceptions $exceptions): void { @@ -433,7 +433,7 @@ By default, limits will use the exception's class as the rate limit key. You can ```php use Hypervel\Broadcasting\BroadcastException; -use Hypervel\Cache\RateLimiting\Limit; +use Hypervel\RateLimiter\Limit; use Throwable; ->withExceptions(function (Exceptions $exceptions): void { @@ -450,7 +450,7 @@ Of course, you may return a mixture of `Lottery` and `Limit` instances for diffe ```php use App\Exceptions\ApiMonitoringException; use Hypervel\Broadcasting\BroadcastException; -use Hypervel\Cache\RateLimiting\Limit; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Lottery; use Throwable; diff --git a/src/boost/docs/fortify.md b/src/boost/docs/fortify.md index dd74753ee..e204053cc 100644 --- a/src/boost/docs/fortify.md +++ b/src/boost/docs/fortify.md @@ -298,8 +298,8 @@ The published configuration sets those limiters to `login` and `passkeys`, and t The two-factor challenge submit route is throttled by default with `throttle:5,1`. You may set `fortify.limiters.two-factor` to a different throttle string or to a named limiter if your application needs custom keying. ```php -use Hypervel\Cache\RateLimiting\Limit; use Hypervel\Http\Request; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; RateLimiter::for('login', function (Request $request): Limit { diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index 3cd8b21b9..a907754f7 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -674,7 +674,7 @@ Although we just demonstrated how to write your own rate limiting job middleware For example, you may wish to allow users to backup their data once per hour while imposing no such limit on premium customers. To accomplish this, you may define a `RateLimiter` in the `boot` method of your `AppServiceProvider`: ```php -use Hypervel\Cache\RateLimiting\Limit; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; /** @@ -698,6 +698,8 @@ return Limit::perMinute(50)->by($job->user->id); Named queue rate limiters use the same [key scope resolver](/docs/{{version}}/routing#scoping-named-rate-limits) as named route rate limiters. +Queue rate limiters may use fixed-window or leaky-bucket policies, weighted costs, and multiple ordered policies. When multiple policies are returned, they are consumed sequentially; capacity accepted by an earlier policy remains consumed if a later policy denies the job. + Once you have defined your rate limit, you may attach the rate limiter to your job using the `Hypervel\Queue\Middleware\RateLimited` middleware. Each time the job exceeds the rate limit, this middleware will release the job back to the queue with an appropriate delay based on the rate limit duration: ```php @@ -744,25 +746,16 @@ public function middleware(): array } ``` - -#### Rate Limiting With Redis - -If you are using Redis, you may use the `Hypervel\Queue\Middleware\RateLimitedWithRedis` middleware, which is fine-tuned for Redis and more efficient than the basic rate limiting middleware: +The named limiter uses the store registered through `RateLimiter::for`, or the default rate limiter store when none was registered. You may override that selection for a job using the `store` method: ```php -use Hypervel\Queue\Middleware\RateLimitedWithRedis; - public function middleware(): array { - return [new RateLimitedWithRedis('backups')]; + return [(new RateLimited('backups'))->store('redis')]; } ``` -The `connection` method may be used to specify which Redis connection the middleware should use: - -```php -return [(new RateLimitedWithRedis('backups'))->connection('limiter')]; -``` +The same `RateLimited` middleware supports every configured rate limiter store; no Redis-specific middleware class is required. ### Preventing Job Overlaps @@ -941,7 +934,7 @@ return [(new ThrottlesExceptions(10, 5 * 60))->backoff( )]; ``` -Internally, this middleware uses Hypervel's cache system to implement rate limiting, and the job's class name is utilized as the cache "key". You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and you would like them to share a common throttling "bucket" ensuring they respect a single shared limit: +Internally, this middleware uses Hypervel's rate limiter, and the job's class name is used as the policy key. You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and would like them to share a common throttling bucket: ```php use Hypervel\Queue\Middleware\ThrottlesExceptions; @@ -957,7 +950,7 @@ public function middleware(): array } ``` -You may use the `byJob` method if each job should maintain its own throttling bucket. If you need to customize the cache key namespace, you may use the `withPrefix` method: +You may use the `byJob` method if each job should maintain its own throttling bucket. If you need to customize the key prefix, you may use the `withPrefix` method: ```php return [ @@ -1032,25 +1025,16 @@ public function middleware(): array } ``` - -#### Throttling Exceptions With Redis - -If you are using Redis, you may use the `Hypervel\Queue\Middleware\ThrottlesExceptionsWithRedis` middleware, which is fine-tuned for Redis and more efficient than the basic exception throttling middleware: +By default, exception throttling uses the default rate limiter store. You may select another configured store using the `store` method: ```php -use Hypervel\Queue\Middleware\ThrottlesExceptionsWithRedis; - public function middleware(): array { - return [new ThrottlesExceptionsWithRedis(10, 10 * 60)]; + return [(new ThrottlesExceptions(10, 10 * 60))->store('redis')]; } ``` -The `connection` method may be used to specify which Redis connection the middleware should use: - -```php -return [(new ThrottlesExceptionsWithRedis(10, 10 * 60))->connection('limiter')]; -``` +The middleware's `backoff` method controls the ordinary queue retry delay after an individual exception. It is separate from the rate limiter's [exponential backoff policy](/docs/{{version}}/rate-limiting#exponential-backoff). ### Releasing Jobs diff --git a/src/boost/docs/starter-kits.md b/src/boost/docs/starter-kits.md index f95fdf74f..ceec7c62c 100644 --- a/src/boost/docs/starter-kits.md +++ b/src/boost/docs/starter-kits.md @@ -224,7 +224,7 @@ The `confirm` option requires users to verify a code before 2FA is fully enabled Rate limiting prevents brute-forcing and repeated login attempts from overwhelming your authentication endpoints. You can customize Fortify's rate limiting behavior in your application's `FortifyServiceProvider`: ```php -use Hypervel\Cache\RateLimiting\Limit; +use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\RateLimiter; RateLimiter::for('login', function ($request) { From f7fd4cd2f0faca93fe725e6fb2504981bf43bf03 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:17:24 +0000 Subject: [PATCH 22/41] Record Hypervel rate limiter differences Record the dedicated Hypervel RateLimiter namespace and typed atomic API as an intentional Laravel difference for agents and porting work. Direct Cache users to the canonical rate-limiting guide and correct the Redis integration workflow inventory. --- AGENTS.md | 7 +++---- src/cache/README.md | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7dabb7d1..e0551fa9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ When working on Hypervel, start from this frame: - Per-request state must live in coroutine-scoped storage (CoroutineContext), not process-global state. - Laravel source is the default parity reference, but Laravel internals often assume per-request bootstrap and are not optimized to take advantage of static caching of immutable state. - Hyperf source can be useful for Swoole/coroutine behavior, but Hyperf container/config/listener patterns are not the target architecture. +- Laravel's rate limiter lives under `Illuminate\Cache`; Hypervel's canonical implementation is the dedicated `hypervel/rate-limiter` package under `Hypervel\RateLimiter`. It uses typed policies and dedicated atomic stores and has no `Hypervel\Cache` alias or primitive counter API. Use this package directly when porting rate-limited Laravel code. ## Repository Layout and Commands @@ -33,7 +34,7 @@ Key paths: | `src/testbench/` | Hypervel's testbench package (port of `orchestra/testbench`). Contains `TestCase`, attributes (`WithConfig`, `WithMigration`), and bootstrap logic. Part of the monorepo, not a vendor dependency. | | `src/testbench/hypervel/` | Committed Hypervel app skeleton. On bootstrap, testbench clones this to a disposable temp directory (`/tmp/hypervel-components-testbench-{token}-{pid}/`) and points `BASE_PATH` at the clone — tests that write files under `BASE_PATH` (generated providers, migrations, fixtures, etc.) hit the temp copy, not this committed path. The clone is deleted on shutdown and stale copies from crashed runs are cleaned up. Testbench also exports `TESTBENCH_BASE_PATH` so subprocesses can locate the active runtime. | | `src/testbench/workbench/` | Committed shared test fixtures (NOT cloned). Subdirs are psr-4-mapped from the monorepo root as `Workbench\App\*`, `Workbench\Database\Factories\*`, `Workbench\Database\Seeders\*` so multiple tests can reuse the same models/factories/seeders without redefining them. Not the runtime app — that's the disposable clone of `src/testbench/hypervel/`. | -| `docs/ai/` | Supplementary agent guides, including `porting-hyperf.md` (Hyperf conversion mechanics) and `differences-vs-laravel.md` (user-facing Laravel differences). | +| `docs/ai/` | Supplementary agent guides, including `porting-hyperf.md` (Hyperf conversion mechanics). | | `docs/todo.md` | Tracked gaps and improvements worth doing. | ### Running tests @@ -196,8 +197,6 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan Hypervel's container keeps Laravel's API surface — `bind()`, `singleton()`, `scoped()`, `instance()`, aliases, contextual bindings — with resolution adapted for long-lived Swoole workers. `make()` and `get()` resolve identically; `get()` is just the PSR-compliant exception wrapper. Use `make()`, and use it instead of array access too: `offsetGet()` always returns `mixed`, while `make()` carries class-string generics phpstan can follow, `make()` can take parameters, and `$app[$key] = $value` is a hidden `bind()`. Converting `$app['...']` in ported code to `make()` is an approved modernization (see Policy under Porting Packages). `Container::getInstance()` auto-creates via `??= new static()`, so it always returns a container. -A user-facing summary of these differences lives in `docs/ai/differences-vs-laravel.md` — keep it consistent with this section when container behavior changes. - ### Resolution semantics vs Laravel The critical difference: **unbound concrete classes are auto-singletoned**. In Laravel, `make()` on a class with no binding builds a fresh instance every call. In Hypervel, the first resolution caches the instance (in `$autoSingletons`) for the worker lifetime — in Swoole's long-running process model services are stateless singletons by design, and re-creating them on every resolution wastes CPU and memory. Explicit bindings override this (bound classes follow their binding type), and `SelfBuilding` classes are excluded. @@ -673,7 +672,7 @@ Each integration group has its own workflow file in `.github/workflows/`: |----------|------|-----------| | `engine.yml` | HTTP test servers | `tests/Integration/Engine`, `tests/Integration/HttpServer` | | `databases.yml` | MySQL, MariaDB, PostgreSQL, SQLite | `tests/Integration/Database` | -| `redis.yml` | Redis, Valkey | `tests/Integration/Cache/Redis`, `tests/Redis/Integration` | +| `redis.yml` | Redis, Valkey | `tests/Integration/Auth`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/RateLimiter`, `tests/Integration/Redis` | | `scout.yml` | Meilisearch, Typesense | `tests/Integration/Scout/*` | When adding integration tests that need a new service, either add them to an existing workflow or create a new one. The workflow must spin up the service container and set the appropriate env vars. diff --git a/src/cache/README.md b/src/cache/README.md index 36fe863f3..90b675f61 100644 --- a/src/cache/README.md +++ b/src/cache/README.md @@ -5,6 +5,8 @@ Cache for Hypervel ## Differences From Laravel +Laravel provides rate limiting through its Cache component. Hypervel provides it through the dedicated `hypervel/rate-limiter` package and `Hypervel\RateLimiter` namespace instead. See the [rate limiting documentation](https://hypervel.org/docs/rate-limiting). + The `array` cache store is request-local in Hypervel. Laravel can keep array-store values on the store object because the PHP process normally ends after each request; Hypervel workers are long-lived, so mutable array-store data lives in `CoroutineContext` and resets when the current unit of work finishes. 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. From dc19347315e9ba8b05aacfc5734349ba1dc2dd11 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:17:40 +0000 Subject: [PATCH 23/41] Update rate limiter follow-up work Remove completed legacy limiter and middleware defects from the framework TODO list. Retain the focused future INCREX revisit and connection-owned backend capability detection work with their real ecosystem constraints. --- docs/todo.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 73ae6e1db..d4739d84b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -47,13 +47,6 @@ - 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. - Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then. -- Remove request-local state from the auto-singletoned `ThrottleRequestsWithRedis` middleware. Its `$decaysAt` and `$remaining` arrays persist for the worker lifetime: concurrent requests using the same limiter key can overwrite response-header state while an earlier request is running the downstream handler, and distinct keys accumulate without bound. Keep each `DurationLimiter::acquire()` result local to the request while preserving atomic Redis admission and the middleware's supported extension surface; add concurrent same-key header-isolation coverage and worker-lifetime state cleanup coverage. -- Make `ThrottleRequestsWithRedis` honor `Limit::after()`. The current middleware acquires every limit before running the downstream handler and never evaluates `afterCallback`, so responses that the named limit explicitly excludes are still counted; current Laravel checks first and records the hit after the response when the callback accepts it. Preserve Hypervel's one-call atomic consume path for limits without an after callback, use a non-consuming check followed by a conditional consume only for response-dependent limits, and port the upstream behavior coverage together with Redis concurrency tests. - -## Rate Limiting - -- Replace the cache-bound limiter and Redis-specific middleware branches with a first-party `hypervel/rate-limiter` package under the canonical `Hypervel\RateLimiter` namespace, with no Cache namespace shim. Use typed immutable fixed-window, GCRA/leaky-bucket, unlimited, and capped exponential-backoff policies; dedicated atomic Redis Lua, shared-memory Swoole, dedicated-table database, and worker-local array stores; one complete decision per operation; and a driver extension boundary that never routes native state through generic cache serialization. Refactor routing, queue, Fortify, exception reporting, Reverb, the facade, configuration, official metapackage, and application skeleton to the new API; remove the three Redis-specific middleware classes/switches and every stale cache-limiter symbol/config/test/doc; close the two Redis middleware defects listed above; and require shared semantic, concurrency, integration, static-analysis, and end-to-end performance coverage before removing this TODO. - ## Collections ## Horizon From 908fd63c1dd056bfb2475e05b836f3eac87bd3c7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:17:56 +0000 Subject: [PATCH 24/41] Reconcile the rate limiter implementation plan Bring the committed implementation plan into exact agreement with the reviewed final code. Record the final database locking, timer startup, shared clock, Redis portability, Testbench, Swoole release, consumer, testing, and cleanup decisions used during implementation. --- .../2026-08-04-1543-rate-limiter-package.md | 105 ++++++++++-------- 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index b511ce7ed..9d1dae1b8 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -181,12 +181,14 @@ RateLimiter::store('redis')->consume( ); ``` -`store()` accepts `UnitEnum|string|null` and normalizes enums through `enum_value()`. Built-in `create*Driver()` methods and custom `extend()` callbacks return a `Contracts\Store`; the manager's protected `resolve()` wraps that store in one `Limiter`. This keeps key resolution and unlimited handling out of drivers while giving third-party drivers a small native-operation contract. A custom creator therefore has the familiar shape `fn (Application $app, array $config): Store` rather than having to construct a framework wrapper. +`store()` accepts `UnitEnum|string|null` and normalizes enums through `enum_value()`. Built-in `create*Driver()` methods and custom `extend()` callbacks return a `Contracts\Store`; the manager's protected `resolve()` wraps that store in one `Limiter`. This keeps key resolution and unlimited handling out of drivers while giving third-party drivers a small native-operation contract. A custom creator therefore has the familiar shape `fn (Application $app, array $config): Store` rather than having to construct a framework wrapper. The manager supplies `config['name']` as the requested store key after merging application configuration, overwriting any configured value so built-in and third-party drivers may treat it as authoritative. The optional third argument to `for()` selects the store for that named limiter, so an application can keep login lockouts in the database while routing API traffic through Redis. `limiterStore()` exposes that normalized registration to framework consumers; `null` means use the current default store. Queue middleware's explicit `store()` modifier overrides the registered store. Keep the callback and store in synchronized manager-owned maps rather than adding a named-limiter descriptor class. Resolved stores capture immutable configuration. `setDefaultInstance()`, `for()`, `resolveKeyScopeUsing()`, `extend()`, `forgetInstance()`, and `purge()` are explicitly boot/test-only under the repository's coroutine rules. The `Limiter` receives a resolver closure owned by the manager so a named-policy key can include the limiter name without mutating the policy object. +`MultipleInstanceManager::setApplication()` is tests-only and must refresh both the application and its cached configuration repository without rebuilding already resolved instances. Apply the same correction to `MailManager`, which keeps the same cached configuration reference, and cover both setters with focused same-test application-swap regressions. Normal test teardown already discards container-owned managers, so do not add subscriber cleanup or manager `flushState()` methods. + ### Fixed-window policy Keep Laravel's most recognizable policy name and factories while making the value immutable from the caller's perspective. Fluent modifiers return a new copy rather than mutating a cached definition. @@ -417,7 +419,7 @@ interface PrunableStore ### Configuration -As an always-installed framework component, the canonical defaults live in `src/foundation/config/rate-limiter.php`, alongside Cache, Queue, and Concurrency configuration. Mirror the database-default file into the application skeleton. Testbench carries the same stores but overrides only its default to `worker-array`, matching Testbench's deliberate in-memory cache default; its standard database migration remains available for database-store integration tests. Add `'rate-limiter' => ['stores']` to `LoadConfiguration::mergeableOptions()` so application stores merge by name; this declares merge policy only and does not duplicate configuration values. `RateLimiterServiceProvider` must not merge or publish a second package-owned config file. +As an always-installed framework component, the canonical defaults live in `src/foundation/config/rate-limiter.php`, alongside Cache, Queue, and Concurrency configuration. Mirror the database-default file into the application skeleton. Testbench carries the same stores but uses `worker-array` in both normal and package-test modes, because throttle middleware can run without Testbench's opt-in standard migrations; its standard database migration remains available for tests that explicitly select the database store. Add `'rate-limiter' => ['stores']` to `LoadConfiguration::mergeableOptions()` so application stores merge by name; this declares merge policy only and does not duplicate configuration values. `RateLimiterServiceProvider` must not merge or publish a second package-owned config file. ```php return [ @@ -452,7 +454,7 @@ return [ ]; ``` -Use typed config getters and validate every store at resolution. The service provider registers the database migration generator and prune commands. +Use typed config getters and validate every store at resolution. Swoole's `conflict_proportion` must be a float in the inclusive range `[0.2, 1.0]`, which is the range `Swoole\Table` honors without silently clamping. The service provider registers the database migration generator and prune commands. `prefix` is an application namespace included in the canonical identity before its final hash; it is not concatenated onto the 32-character physical key. This preserves cross-application isolation without variable-length Swoole keys. It is separate from Redis `OPT_PREFIX` and database table prefixes. @@ -528,6 +530,8 @@ State is failure count, blocked-until time, and expiration/inactivity time. On ` - Use one Redis string plus TTL for a fixed counter, one Redis string TAT plus TTL for GCRA, and one small hash (`failures`, `available_at`) plus inactivity TTL for backoff. The policy fingerprint fixes the type for a key, so no strategy tag or JSON envelope is needed. - Set TTL atomically in the script. Every algorithm returns the same five-integer tuple—accepted flag, limit, remaining, retry microseconds, reset microseconds—even when the Redis command uses milliseconds. Convert `PTTL` milliseconds to microseconds inside the fixed-window script and validate the converted values; result decoding never guesses a unit from the policy type. - Validate every returned tuple's arity, integer types, flags, and non-negative/range invariants before constructing a result; `false`, `nil`, truncation, or malformed data must throw rather than cast into an allowed decision. +- Reject a physically present backoff hash whose failure count is zero. Its positive TTL is the Redis equivalent of the non-empty expiry state rejected by the shared PHP calculator. Do not add policy-relative corruption checks or reconstruct an absolute expiry from `PTTL`: the other stores do not validate state against the current policy, and Redis's `TIME` and backend-owned millisecond expiry clocks cannot provide the exact timestamp comparison available to numeric stores. +- Prefix every package-authored general-purpose `redis.error_reply()` message with `ERR`, following Redis's error-code convention. The existing `evalWithShaCache()` path wraps those script/data failures in `LuaScriptException`, while native `RedisException` values for server state, cluster routing, authentication, and transport failures propagate unchanged. Do not catch or reclassify native Redis exceptions. - Keep script bodies as private constants or dedicated internal operation classes only if file length warrants it. Do not build a generic script framework. Fixed-window Lua shape: @@ -550,17 +554,17 @@ if not raw then end if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then - return redis.error_reply('CORRUPT rate limiter counter') + return redis.error_reply('ERR corrupt rate limiter counter') end local current = tonumber(raw) if not current or current < 0 or current > limit or current % 1 ~= 0 then - return redis.error_reply('CORRUPT rate limiter counter') + return redis.error_reply('ERR corrupt rate limiter counter') end local ttl = redis.call('PTTL', KEYS[1]) if ttl == -1 then - return redis.error_reply('CORRUPT rate limiter counter has no expiry') + return redis.error_reply('ERR corrupt rate limiter counter has no expiry') end if ttl <= 0 then return start_window() @@ -578,7 +582,7 @@ return {1, limit, limit - incremented, 0, ttl * 1000} The production script must keep every result numeric, provide an inspect mode without creating a missing key (returning `{1, limit, limit, 0, 0}`), and include the required `@TODO` immediately beside it. A present fixed-window key with a noncanonical integer string (including a leading-zero value other than `0`), negative/out-of-range count, or no expiry (`PTTL == -1`) is corrupt: raise a Lua error and propagate it rather than deleting the key and potentially failing open. A zero/expired TTL is a real boundary condition and starts a fresh window atomically only for consume. Validate impossible costs in PHP so the script never creates an over-capacity first value. On an accepted existing window, use the integer returned by `INCRBY` for the decision and verify through integration coverage that the original TTL is retained; do not replace the value with `SET ... KEEPTTL`. -Do not alter `RedisConnection::callEvalsha()` for this package; the correct `evalWithShaCache()` path already exists and has real Redis integration coverage. +Do not alter `RedisConnection::callEvalsha()` or `evalWithShaCache()` behavior for this package; the existing path correctly distinguishes script/data replies returned by phpredis from native Redis server/cluster/authentication/transport exceptions. Correct the method documentation to describe both exception paths, and cover one `ERR` reply and one natively thrown `OOM` reply in real Redis integration tests. ### Swoole store @@ -587,15 +591,15 @@ Do not alter `RedisConnection::callEvalsha()` for this package; the correct `eva - Use a fixed 32-character hashed key. - Resolve the package-local `Swoole\TableManager` as an unbound concrete, using Hypervel's auto-singleton behavior rather than an explicit container binding. `Listeners\InitializeSwooleTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. - Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. After creating the configured tables, `InitializeSwooleTables` seals the manager. Before sealing, console/tests may explicitly initialize named tables; after sealing, `get()` returns only a pre-created state and an unknown name throws instead of allocating worker-private state. The sealed flag is set before fork and inherited by workers. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. -- Extract the proven 64-stripe Atomic lock coordinator from cache's `SwooleTableState` into a small `Hypervel\Core\Swoole\StripedLock` primitive used by both Cache and RateLimiter. It owns key-to-stripe selection, short spin/backoff acquisition, all-lock acquisition required by Cache, and release; it does not own a table, cache columns, limiter state, or arbitrary multi-key transactions. Keep both packages' table managers and state formats independent. -- Use the existing `Hypervel\Coordinator\Timer` from `Listeners\RegisterPruneTimer`, with its default `WORKER_EXIT` coordinator. It already provides injectable repeating timers, exception reporting, cancellation, and automatic worker-exit cleanup; do not add another timer wrapper, timer-ID registry, or `OnWorkerExit` listener. Register only on worker 0 and never in task workers. `prune_interval` is seconds and is passed directly to `Timer::tick()`. +- Extract the proven 64-stripe Atomic lock coordinator from cache's `SwooleTableState` into a small `Hypervel\Core\Swoole\StripedLock` primitive used by Cache, RateLimiter, and Reverb. It owns key-to-stripe selection, short spin/backoff acquisition, selected-key acquisition required by Reverb, all-lock acquisition required by Cache, and release; it does not own a table, cache columns, or limiter state. `withLocks(list $keys, callable $callback)` maps logical keys to stripes internally, deduplicates shared stripes, and acquires them in ascending stripe-index order. Every multi-stripe path follows that same global order and releases in reverse so selected-key and all-lock callers cannot deadlock. Keep each package's table manager and state format independent. +- Use the existing `Hypervel\Coordinator\Timer` from `Listeners\RegisterPruneTimer`, with its default `WORKER_EXIT` coordinator. It already provides injectable repeating timers, exception reporting, cancellation, and automatic worker-exit cleanup; do not add another timer wrapper, timer-ID registry, or `OnWorkerExit` listener. Register only on worker 0 and never in task workers. In separate passes, validate every configured prune interval, resolve every target store, and only then register timers; retain rollback for genuine registration failures. `prune_interval` is seconds and is passed directly to `Timer::tick()`. - Perform read/check/write within one row lock. No serialization, closures, cache repository, or generic eviction policy appears in the hot path. -- Use epoch microseconds for worker-array and Swoole: `(int) (microtime(true) * 1_000_000)` normally and `(int) CarbonImmutable::now()->getPreciseTimestamp(6)` under `CarbonImmutable::hasTestNow()`. Both branches must use the same origin and unit so switching test time after creating state cannot manufacture an expiry. This also aligns local state with Redis `TIME` and database wall clocks; accepting wall-clock adjustment behavior is preferable to a separate monotonic-offset test abstraction that the distributed stores could not share. -- Expired rows are reclaimed on access. Worker 0 owns the coordinator-backed periodic expiry scan. Timer/full-table pruning must lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. +- Use one `CalculatesRateLimits` epoch-microsecond helper for worker-array, Swoole, and local SQLite: `(int) (microtime(true) * 1_000_000)` normally and `(int) CarbonImmutable::now()->getPreciseTimestamp(6)` under `CarbonImmutable::hasTestNow()`. Both branches must use the same origin and unit so switching test time after creating state cannot manufacture an expiry. This also aligns local state with Redis `TIME` and database wall clocks; accepting wall-clock adjustment behavior is preferable to a separate monotonic-offset test abstraction that the distributed stores could not share. +- Expired rows are reclaimed on access. Worker 0 owns the coordinator-backed periodic expiry scan. Timer/full-table pruning must capture one cutoff, collect candidate keys without mutating or yielding inside the `Swoole\Table` iteration, then lock and re-read each candidate before deletion so a concurrent renewal cannot be removed from under another worker. Deleting while iterating is incorrect because collision-row promotion shifts Swoole's positional iterator and skips rows; yielding inside the scan also lets another coroutine in the worker reset the shared iterator. A rare cross-process chain mutation can still make one scan under-collect and fail closed early. Do not add a prune mutex or hold every stripe across the scan: neither excludes ordinary writers without broad hot-path coordination, and the residual never allows excess traffic. - After each periodic prune, use `Swoole\Table::stats()` to calculate the same O(1) conflict/fill pressure signal as Cache: warn through injected `Psr\Log\LoggerInterface` when either ratio exceeds `1 - memory_limit_buffer`. This signals exhausted headroom off the request path while avoiding warnings for pressure relieved by expired-row pruning. -- If insertion fails, perform one synchronous expired-row prune and retry once. If the table remains full of live rows, throw `SwooleTableFullException`; normal exception reporting supplies the hard-failure signal. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. +- If insertion fails, perform one synchronous expired-row prune and retry once. If Swoole still cannot allocate the new entry, throw `SwooleTableFullException` with allocation-accurate wording; normal exception reporting supplies the hard-failure signal. Never evict a live limiter entry, because eviction would fail open. A full scan is permitted only on this exceptional capacity path or the background timer, never on ordinary admission. - Document that Swoole is host-local and is not a distributed rate limiter across servers. -- Document sizing as `rows >= peak concurrently live physical keys × headroom`, where a key remains live for its window/refill/inactivity TTL. Include examples for per-IP cardinality and explain that periodic pressure warnings indicate exhausted headroom before a live-only table begins failing closed. +- Document sizing as `rows >= peak concurrently live physical keys × headroom`, where a key remains live for its window/refill/inactivity TTL. Explain that Swoole rounds `rows` up to a power of two with a minimum of 64, allocates a separate `rows × conflict_proportion` collision pool, and can reject a colliding key while unrelated base slots remain, so `count()` need not reach `getSize()` before an allocation failure. Include examples for per-IP cardinality and explain that periodic pressure warnings indicate exhausted headroom before allocation begins failing closed. ### Database store @@ -624,17 +628,7 @@ Mutating operation: ```php return $connection->transaction(function ($connection) use ($key, $policy) { - $connection->table($table)->insertOrIgnore([ - 'key' => $key, - 'value' => 0, - 'available_at' => 0, - 'expires_at' => 0, - ]); - - $row = $connection->table($table) - ->where('key', $key) - ->lockForUpdate() - ->first(); + $row = $this->stateForUpdate($connection, $key); $now = $this->currentTimeInMicroseconds($connection); @@ -642,9 +636,15 @@ return $connection->transaction(function ($connection) use ($key, $policy) { }, attempts: 3); ``` -The initial `insertOrIgnore` solves the first-row race and also obtains SQLite's writer lock before reading; `lockForUpdate` provides row serialization on MySQL/MariaDB/PostgreSQL. Fetch time after lock acquisition so lock wait does not make the decision's timestamp stale. +For MySQL, MariaDB, and PostgreSQL, `stateForUpdate()` first reads the primary-key row with `lockForUpdate()`. Established keys therefore enter a direct exclusive record-lock queue without issuing an insert. If the row is absent, insert it with `insertOrIgnore()`, lock/read it again, and fail closed if it is still absent. MySQL/MariaDB may deadlock while concurrent first-use transactions convert compatible absent-key gap locks into insert-intention locks; the transaction's three attempts are load-bearing because one winner creates the row and retries then converge on the established-row path. Do not describe this path as deadlock-free or assert engine deadlock counters. A no-op upsert would avoid the first-use deadlock, but it would perform update-style work on every established-key operation and create dead tuples on PostgreSQL, so do not use it. + +SQLite deliberately keeps the opposite order: call `insertOrIgnore()` before the first read so the write acquires SQLite's database writer lock, because SQLite's grammar omits `FOR UPDATE`. Do not generalize this into a database capability layer. Fetch time only after the final row lock/write lock is held so waiting cannot make the decision timestamp stale. + +Before `consume()`, `recordFailure()`, `clear()`, or `pruneExpired()` mutates state, require `transactionLevel() === 0` on the selected connection and throw `LogicException` otherwise. A nested limiter update would remain inside the caller's physical transaction: rollback could undo an accepted charge/failure, clear could silently disappear, pruning would accumulate every batch's delete locks until the outer commit, and the store's own retry is disabled for nested concurrency errors. Applications that need limiter decisions while another connection is transactional must configure a separately named limiter connection. -`inspect()` is intentionally different: select without inserting or locking, read the clock, and return a best-effort snapshot. Both its row query (`useWritePdo()`) and server-time scalar must use the primary/write PDO so a configured read replica cannot return stale limiter state or a clock from a different server, but it must not create, refresh, delete, or otherwise mutate a row. `clear()` is a direct keyed delete. Only `consume()` and `recordFailure()` use the insert/lock transaction. +A PostgreSQL connection selected for this store must use `READ COMMITTED`, PostgreSQL's default isolation. Contended hot-row writes at `REPEATABLE READ`/`SERIALIZABLE` can exhaust any fixed immediate retry count with `40001`; do not add larger attempt counts, retry sleeps, runtime isolation probing, or `SET TRANSACTION` overrides. This restriction does not apply to MySQL or MariaDB, whose default `REPEATABLE READ` lock behavior is supported. + +`inspect()` is intentionally different: select without inserting or locking, read the clock, and return a best-effort snapshot. Both its row query (`useWritePdo()`) and server-time scalar must use the primary/write PDO so a configured read replica cannot return stale limiter state or a clock from a different server, but it must not create, refresh, delete, or otherwise mutate a row. Inside a MySQL/MariaDB outer transaction at the default `REPEATABLE READ`, the snapshot may be as old as that transaction. `clear()` is a direct keyed delete. Only `consume()` and `recordFailure()` use the insert/lock transaction. Use database-server microsecond time for MySQL/MariaDB and PostgreSQL: `FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)` for MySQL/MariaDB and `FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint` for PostgreSQL. These exact expressions were executed successfully during plan review against MySQL 9.5, MariaDB 10.11, and PostgreSQL 17. PostgreSQL must use `clock_timestamp()`, not the transaction-start timestamp returned by `CURRENT_TIMESTAMP`. SQLite is local/non-distributed and may use application wall-clock microseconds. Return the scalar as a decimal/integer and range-check it before casting. Keep driver-specific clock SQL private and covered by integration tests; reject unsupported database drivers instead of silently choosing an application clock, and do not create a general database capability framework in this package. @@ -660,7 +660,7 @@ The database driver is correctness-first and will require several SQL statements - Use the `worker-array` name established by Hypervel Cache for worker-lifetime state. It is not coroutine-local and not shared across workers; do not call it `array`, which Hypervel documentation reserves for request-local scratch state. - It is suitable for tests and deliberately local workloads such as Reverb per-connection message limits, because a connection remains owned by one worker and Reverb clears its key on close. - Operations contain no suspension point, so a transition is atomic within one cooperative worker; it does not coordinate processes or hosts. -- Lazily discard an expired entry whenever its key is touched. Do not add an abandoned-key scheduler/expiry index in the initial store or perform an unbounded whole-array sweep in a request hot path; rely on explicit `clear()`, Reverb close cleanup, and worker recycling for this deliberately local/test store. +- Mutating operations replace expired state, while `inspect()` treats it as empty without changing storage. An expired entry whose key is never touched again remains for the worker lifetime; rely on explicit `clear()`, Reverb close cleanup, another mutating operation on that key, and worker recycling for this deliberately local/test store. Do not add an abandoned-key scheduler, expiry index, or unbounded whole-array sweep in a request hot path. ## Framework consumer refactor @@ -698,7 +698,7 @@ This refactor must close, and then remove, both existing Redis entries in `docs/ ### Queue `ThrottlesExceptions` - Represent its existing “N failures in decay window” behavior with a fixed `Limit` keyed to the job. -- `inspect()` before running the job; `consume()` only when a qualifying exception occurs; `clear()` after success. +- `inspect()` before running the job; `consume()` only when a qualifying exception occurs; `clear()` after success. If the post-failure consume is denied because a concurrent failure filled the window, release using its circuit-open retry delay rather than the ordinary pre-limit `backoff()` delay. - Add the same `store()` selector and remove `ThrottlesExceptionsWithRedis`; this middleware constructs its policy directly, so it uses the default store unless explicitly overridden. - Persist only the selected store name with the middleware/job; resolve the manager/wrapper inside `handle()` and never serialize a resolved backend store or Redis proxy. - Keep its existing `backoff()` method for the ordinary queue retry delay; do not conflate that delay with the package's server-enforced exponential `Backoff` policy. @@ -732,6 +732,8 @@ Change `Handler::throttle()` from `Lottery|Limit|null` to `Lottery|AdmissionPoli Inject/resolve the new manager and use `store('worker-array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. +Also replace Reverb's duplicated 64-stripe Atomic implementation with an explicitly injected Core `StripedLock`, created in the existing eager pre-fork provider block. Single-key operations use `withLock()` and presence operations use `withLocks([$channelKey, $userKey], ...)`; do not substitute `withAllLocks()`. Remove Reverb's local lock constants, arrays, acquisition helpers, and primitive-only tests while retaining call-site coverage for post-release reporting, shared-stripe deduplication, and opposite input ordering. + ### Facade and provider - `Hypervel\RateLimiter\RateLimiter` is a concrete manager and therefore uses Hypervel's normal unbound-concrete auto-singleton behavior; do not add a redundant container binding or alias. `RateLimiterServiceProvider` registers only its commands and lifecycle listeners. Foundation owns the default config. @@ -746,16 +748,16 @@ Add/update all of the following: - root `composer.json` PSR-4 mapping for `Hypervel\RateLimiter\`; - root `replace` entry for `hypervel/rate-limiter`; - `src/rate-limiter/composer.json`, auto-discovered provider, authors/support/branch alias, sorted requirements; -- exact direct requirements for `ext-swoole`, `hypervel/collections` (including `enum_value()`), config, console, container, contracts, coordinator, core events/primitives, database, Redis, support, `psr/log`, and `symfony/console`, pruning anything implementation does not actually import; PHP's mandatory Hash extension needs no Composer requirement; +- exact direct requirements for `ext-swoole`, `hypervel/collections` (including `enum_value()`), console, container, contracts, coordinator, core events/primitives, database, Redis, support, `psr/log`, and `symfony/console`, pruning anything implementation does not actually import; PHP's mandatory Hash extension needs no Composer requirement; - `hypervel/rate-limiter` dependencies in routing, queue, Fortify, foundation, and Reverb package manifests; - remove `hypervel/cache` from packages where the limiter was its only cache use; retain unrelated cache/Redis dependencies after checking all imports; - facade API documentation metadata; - `Hypervel\RateLimiter` package entry in any package inventories/documentation lists. -Move only the generic striped-lock behavior described above into Core and update Cache imports/tests in the same change. Also make Coordinator's existing `Timer` the one Swoole maintenance mechanism across both touched packages: +Move only the generic striped-lock behavior described above into Core and update Cache and Reverb imports/tests in the same change. Reverb already directly requires Core, so this adds no package dependency. Do not change Cache's internal lock construction merely for constructor symmetry; only Reverb needs explicit injection for its retained call-site test doubles. Also make Coordinator's existing `Timer` the one Swoole maintenance mechanism across both touched packages: - add `hypervel/coordinator` as Cache's direct dependency; -- replace Cache's `CreateSwooleTimers` with the accurately named `RegisterSwooleMaintenanceTimers`, inject `Coordinator\Timer`, and register its eviction/interval-refresh callbacks with the default `WORKER_EXIT` coordinator; +- replace Cache's `CreateSwooleTimers` with the accurately named `RegisterSwooleMaintenanceTimers`, inject `Coordinator\Timer`, validate every configured store's two intervals and resolve every target `SwooleStore` before registering any timer, then capture those stores in the eviction/interval-refresh callbacks registered with the default `WORKER_EXIT` coordinator. This deliberately makes the configured stores eager on worker 0, but they only wrap pre-fork shared tables and open no external connections. More importantly, a resolution failure fails worker startup before any timer exists instead of being swallowed and logged again on every tick by `Coordinator\Timer`; under Swoole, a throwing `AfterWorkerStart` listener appears as a worker respawn loop until the configuration is corrected; - retain Cache's established millisecond config values and documentation, read each named store's complete interval values through typed config getters without duplicating inline defaults, require both integers to be positive, and divide by `1000` once during worker-start registration before calling the seconds-based `Timer::tick()`; this preserves subsecond configuration and avoids silently reinterpreting existing/skeleton values, while RateLimiter's independently documented `prune_interval` remains seconds; - remove the old `=== false` registration guards and their `RuntimeException` messages because `Coordinator\Timer::tick(): int` either returns an ID or throws; retain thrown-registration rollback with only a method-local list of returned IDs, then discard it; - delete the listener's persistent ID registry and `stop()` method because coordinator shutdown owns cleanup; @@ -774,12 +776,16 @@ Coordinate the two adjacent official repositories in the same release: - add `hypervel/rate-limiter` to `contrib/hypervel/framework/composer.json`, sorted with the other split components; - add `config/rate-limiter.php` to the `contrib/hypervel/hypervel` application skeleton; - because the skeleton selects the database limiter store by default, add `database/migrations/0001_01_01_000008_create_rate_limits_table.php` after its current `000007` failed-jobs migration so a fresh application works immediately, while retaining the generator for existing applications; +- set `RATE_LIMITER_STORE=worker-array` in the skeleton `phpunit.xml`, matching its other test-local stores so `RefreshDatabase` feature tests do not nest limiter mutations inside the test transaction; +- reconcile the skeleton's stale `app.php` entries with Foundation: replace `stdout_log_level` with the `stdout_log.level`/`stdout_log.format` structure read by `StdoutLogger`, expose `force_https`, normalize `APP_PREVIOUS_KEYS` to a string, and retain the complete maintenance driver/refresh configuration required by `FoundationServiceProvider`; - add `RATE_LIMITER_STORE=database` and commented connection/prefix overrides to the skeleton environment example, update lock/config documentation, and run each repository's own metadata/config/migration tests. Do not modify the private `packages/hypervel` repositories unless a concrete import audit finds an actual consumer. Keep provider auto-discovery metadata in the split package so it works when independently required, matching other core components, but also assert `RateLimiterServiceProvider`'s presence in `DefaultProviders`. Discovery is not the framework's availability mechanism; its alphabetical placement is a code-style requirement, not runtime behavior that needs a brittle order test. Within components, add `src/testbench/hypervel/config/rate-limiter.php` with `worker-array` as its test default and `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php` after the current `000007` failed-jobs migration. Update `CommanderTest`'s expected migration inventory, every `WithMigration`/default-database assertion that enumerates framework tables, and rollback/refresh coverage. The migration lets database-store tests opt in without pushing unrelated container-resolved limiter tests through SQLite; limiter tests must not create the standard table ad hoc. +Keep config-publishing tests isolated from that worker-shared Testbench skeleton. The ordinary `--all --force` test must preserve every config name discovered from Foundation before publishing into the clone. The `dontMergeFrameworkConfiguration()` case must use Testbench's `#[UsesFrameworkConfiguration]` bootstrap path, switch the command destination to a throwaway config directory after application creation, and delete that directory after the test. Before publishing, assert that the disposable path is active and every destination is absent so the test cannot pass by comparing the Foundation source directory to itself. Hypervel intentionally ships no config stubs today; retain the dormant Laravel-compatible source-selection branch without adding a stub or production change. + ## Removal and cleanup inventory Delete after consumers compile against the new package: @@ -826,6 +832,7 @@ Update every applicable Boost document, not just the main rate-limiting page: - `middleware.md`: one throttle middleware class; - database docs: `make:rate-limiter-table`, schema purpose, pruning schedule; - package README: only the package heading, the canonical Boost documentation link, and concise public `Differences From Laravel`; omit an upstream link because this independently maintained package does not track a source package. +- Cache README: add a concise `Differences From Laravel` entry directing developers to the dedicated `hypervel/rate-limiter` package and canonical documentation. `src/boost/docs-ported.md` already registers `rate-limiting.md`; retain that single inventory entry and do not add `rate-limiter.md` there or anywhere else. @@ -885,9 +892,12 @@ Time control is store-appropriate rather than abstracted into a production clock ### Concurrency tests -- Worker-array: multiple coroutines in one worker admit exactly capacity. -- Swoole: multiple coroutines and forked workers admit exactly capacity and do not lose updates. -- Database: concurrent transactions against an absent key and an existing key admit exactly capacity; include SQLite writer serialization plus MySQL/PostgreSQL row locks in integration CI. +Tagged Swoole releases currently stall the two coroutine-hooked SQLite concurrency cases because the AIO scheduler can leave the lock holder's continuation queued behind lock waiters. Temporarily override only those two inherited SQLite tests as skipped, with a focused `@TODO` linking [Swoole PR #6140](https://github.com/swoole/swoole-src/pull/6140). Remove the overrides and TODO as soon as Hypervel's minimum tagged Swoole release contains that fix. Keep the test bodies unchanged in the shared database contract, keep every non-concurrency SQLite test active, and add no pool cap, timeout change, serialization, version branch, or package workaround. + +`Parallel::wait()` is a coroutine-only API. Validate that precondition before resetting state or executing callbacks and throw the existing `RunningInNonCoroutineException` when no coroutine is active. This is fail-fast API hardening, not a non-coroutine execution path; standalone callers must enter a coroutine container through `run()`. Add focused coverage proving misuse executes no callback. Do not use `parallel()` to claim same-process contention coverage for a store operation with no suspension point, because those callbacks cannot interleave. + +- Swoole: forked workers sharing the pre-fork table and Atomic stripes admit exactly capacity and do not lose updates; propagate framed child-process failures to the parent and reap each child with its own bounded deadline. +- Database: run the shared contract through the production pooled resolver by enabling `pool.testing_enabled` on the configured default connection. Concurrent transactions against an absent key and an existing key admit exactly capacity; include SQLite writer serialization plus MySQL/PostgreSQL row locks in integration CI. SQLite uses a pre-created plain file under `ParallelTesting::tempDir()` with a multi-connection pool rather than Testbench's in-memory/static-connection resolver, so the unchanged tests exercise independent PDOs and real writer locking. The file path intentionally overrides Testbench's earlier parallel-database suffix because the temp directory is already worker-scoped. - Redis: many concurrent pooled clients admit exactly capacity for fixed and leaky bucket; test weighted costs. - Structural Redis tests assert every limiter script is invoked with exactly one key; configured-prefix integration coverage verifies the key path. Do not add a Redis Cluster service merely to test an impossible CROSSSLOT case for one-key scripts. - No driver allows stored state above capacity. @@ -895,32 +905,35 @@ Time control is store-appropriate rather than abstracted into a production clock ### Redis-specific tests - Steady path calls `evalSha` and a node's first NOSCRIPT response falls back to `eval` through existing `evalWithShaCache()`. -- Script false/nil/error handling is not mistaken for NOSCRIPT. +- Script `false`/nil handling is not mistaken for NOSCRIPT, a package-style `ERR` reply becomes `LuaScriptException`, and a natively thrown `OOM` reply remains `RedisException`. - Serializer/compression configuration does not affect limiter state. - Redis connection `OPT_PREFIX` is applied once. - `TIME`-based leaky/backoff calculations ignore application-clock skew. - TTL is applied atomically, is unchanged on denial, and an accepted existing fixed-window increment uses portable `INCRBY` without changing the original TTL. - A stored leading-zero counter such as `010` is rejected by the package's explicit corruption branch before `INCRBY`, while zero and canonical non-zero decimal counters remain valid. +- A physically present backoff hash with zero failures is rejected as corrupt; do not add timing-dependent timestamp-versus-`PTTL` assertions. - The validated `9_007_199_254_740_991` ceiling survives fixed-window `SET`/`INCRBY` command arguments and computed Redis state without scientific-notation truncation; do not assert against Lua `tostring()`, which is not the command bridge. - Redis 8 and Valkey 9 run the exact same Lua implementation. -- Add a focused assertion/test fixture guarding the required `@TODO`/portable path only if repository conventions permit source-shape tests; otherwise the docs TODO and code comment are sufficient. +- Do not add a source-shape test for the `@TODO`. The code comment and matching `docs/todo.md` entry are the maintenance record; tests cover the portable Lua behavior rather than comment text. ### Swoole-specific tests -- Core `StripedLock` preserves Cache's row/all-lock behavior and timeout coverage after extraction; RateLimiter creates the same lock primitive before fork. -- `RegisterPruneTimer` uses Coordinator `Timer` to register the prune callback only for worker 0/non-task workers and stops it through the existing `WORKER_EXIT` coordinator without package timer IDs or exit listeners. -- Cache's renamed maintenance listener reads complete positive millisecond intervals through typed config getters, converts them to seconds at registration, removes unreachable native-false guards, rolls back earlier registrations if a later registration throws, and relies on the same worker-exit coordinator in its recycle test. Missing, wrong-type, zero, and negative intervals fail before timer registration. Cache has no duplicated listener defaults, native Swoole timer wrapper, persistent timer-ID registry, `stop()` path, or provider-owned `OnWorkerExit` closure afterward. +- Core `StripedLock` preserves Cache's row/all-lock behavior and timeout coverage after extraction; selected-key coverage proves shared-stripe deduplication, ascending stripe ordering, and partial-acquisition rollback. RateLimiter and Reverb create the same lock primitive before fork. Do not add a timing-based selected-key-versus-all-lock deadlock test; the documented common ascending-index invariant is the proof. +- `RegisterPruneTimer` validates every interval before resolving any store, resolves every target before registering any timer, registers the prune callbacks only for worker 0/non-task workers, and stops them through the existing `WORKER_EXIT` coordinator without package timer IDs or exit listeners. +- Cache's renamed maintenance listener reads complete positive millisecond intervals through typed config getters, resolves every target Swoole store, converts the intervals to seconds at registration, removes unreachable native-false guards, rolls back earlier registrations if a later registration throws, and relies on the same worker-exit coordinator in its recycle test. The standalone recycle fixture binds the real cache manager and creates its configured table through `CreateSwooleTable` before server start, so replacement workers inherit the shared table instead of allocating worker-local tables. Missing, wrong-type, zero, and negative intervals and target-store resolution failures occur before timer registration. The callbacks capture the resolved stores, so timer execution performs no container lookup. Cache has no duplicated listener defaults, native Swoole timer wrapper, persistent timer-ID registry, `stop()` path, or provider-owned `OnWorkerExit` closure afterward. - Table columns are 8-byte integers and table creation occurs before fork. -- `TableManager` allows explicit creation before sealing, is sealed by `InitializeSwooleTables` before fork, and rejects unknown tables afterward. +- `TableManager` allows explicit creation before sealing, is sealed by `InitializeSwooleTables` before fork, and rejects unknown tables afterward. It reads structural values through typed config getters, accepts conflict proportions `0.2` and `1.0`, and rejects `0.1`/`1.5` rather than letting Swoole silently clamp them. - Same-key locks isolate transitions; different stripes can proceed independently. -- Expired rows are pruned by timer and on access. -- Periodic pruning logs pressure only when post-prune conflict/fill ratios cross the configured buffer; full insertion retries after one synchronous prune and then throws without evicting live state. +- Expired rows are pruned by timer and on access. A collision-chain regression proves one collect-then-delete pass removes every expired candidate without mutating the table iterator. +- Periodic pruning logs pressure only when post-prune conflict/fill ratios cross the configured buffer; allocation tests discover an exact failing key from `getSize()`/`stats()`, prove one synchronous prune/retry can reclaim a conflict slice, and then prove the allocation-accurate exception without assuming that table count reached configured rows. +- Reverb retains call-site tests for reporting after stripe release, one acquisition when two logical keys share a stripe, and deadlock-free opposite input ordering, while generic spin/backoff/timeout tests live only in Core. - Store state never serializes a PHP value. ### Database-specific tests - Generated migration SQL/schema is valid for all four supported database families. -- `insertOrIgnore` plus lock handles simultaneous first use. +- Non-SQLite drivers lock established rows before inserting missing state; simultaneous first use converges through the three transaction attempts, while SQLite inserts first to acquire its writer lock. +- `consume`, `recordFailure`, `clear`, and pruning reject an active transaction on the selected connection before issuing limiter SQL; inspection remains available as a best-effort transactional snapshot. - Server time is read after lock acquisition. - PostgreSQL uses current wall time rather than transaction-start time. - Prune command rejects non-prunable stores, targets a named database store, and deletes only expired rows. @@ -970,6 +983,8 @@ Do not weaken PHPStan types, suppress errors, or widen return types to accommoda Add a reproducible developer-only CLI harness under `tests/Benchmarks/RateLimiter/`, including documented backend inputs; do not register a production Artisan command or treat PHPUnit timing as a benchmark. The harness must exercise the framework manager, pool, driver, result decoding, and middleware-relevant operation—not only a raw backend command—so its numbers represent the code being shipped. +The standalone harness must call Testbench's `Bootstrapper::bootstrap()` and create its application without an explicit base path. This keeps package manifests, compiled files, logs, databases, and every other runtime write inside Testbench's disposable skeleton copy rather than the committed `src/testbench/hypervel` source. + Measure at minimum: - fixed-window and leaky-bucket consume through Redis, Swoole, and one explicitly labeled configured database backend; @@ -995,7 +1010,7 @@ This order keeps the tree buildable while still delivering one final cut with no 2. Add immutable policies, fingerprints/key resolver, decisions, contracts, manager, shared typed PHP calculator, and per-store `Limiter` wrapper with unit tests. 3. Implement worker-array store with the shared calculator and run the full store contract against it. 4. Implement Redis Lua transitions using `evalWithShaCache()`, including the required focused `@TODO`; run the existing Redis 8/Valkey 9 integration jobs and concurrency tests after adding their explicit RateLimiter path. -5. Extract the generic striped lock into Core and update Cache; converge Cache's Swoole maintenance timers on Coordinator `Timer`; then implement the independent numeric Swoole table/state plus Coordinator-backed pruning listener and multi-worker tests. +5. Extract the generic striped lock into Core and update Cache and Reverb; converge Cache's Swoole maintenance timers on Coordinator `Timer`; then implement the independent numeric Swoole table/state plus Coordinator-backed pruning listener and multi-worker tests. 6. Implement database store with the shared calculator, migration/prune commands, server clocks, default migrations, and database integration/concurrency tests. 7. Rewrite routing and Foundation middleware configuration; delete the Redis-specific request middleware/switch once tests pass. 8. Rewrite queue middleware and remove the two Redis-specific queue classes. @@ -1013,7 +1028,7 @@ No step should add a temporary alias or dual API. If intermediate local compilat - [ ] `Hypervel\RateLimiter` is the sole namespace; its facade and unconditional default provider resolve the new manager with no Cache shim or dual API. - [ ] Fixed, GCRA/leaky-bucket, unlimited, and exponential-backoff policies are typed; no strategy/driver enum, descriptor bag, or speculative algorithm exists. -- [ ] Redis/Swoole/database/worker-array pass the shared semantic and concurrency suites; failures never fail open and no driver routes through generic cache serialization. +- [ ] Redis/Swoole/database/worker-array pass the shared semantic suite, and Redis/Swoole/database pass their applicable real-contention concurrency suites; failures never fail open and no driver routes through generic cache serialization. - [ ] Redis admission is one cached Lua call on the existing Redis 8 and Valkey 9 services; Swoole uses shared numeric state without live eviction and documents/logs capacity pressure; database uses only `rate_limits`. - [ ] Foundation, the application skeleton, and Testbench carry the same stores/migration, with database as the application default and worker-array as the deliberate Testbench default; named stores merge without duplicate package config. - [ ] Routing retains its Laravel-facing helpers, syntax, callbacks, exceptions, headers, and registered-store selection with one middleware; queue and Reverb have no Redis/cache limiter branches. From 0450c36717fe0648a952fba020a6dbbf39a5b2d3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:15:58 +0000 Subject: [PATCH 25/41] Require consent before using subagents Add a public working rule that prevents agents from spawning or delegating work to subagents unless the user explicitly requests or approves their use. This keeps delegation under the user's control across all work in the components repository. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index e0551fa9b..390610d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,7 @@ The Working rules and the Avoid overengineering rules apply to all work in this ### Working rules +- **Never use subagents without explicit user consent** — Do not spawn or delegate work to subagents unless the user explicitly requests or approves their use. - **Avoid bulk modification tools** — tools like `sed` and `replace_all` often have unwanted side effects. Never use bulk modification tools without explicit user approval; prefer manual edits. When approved, run them in multiple passes that each target long, exact, case-sensitive strings to avoid accidental changes. - **One file at a time** — never work on multiple files simultaneously. This governs manual editing; package-manager and formatter runs may touch multiple files. - **Never use Write to overwrite files** — always use Edit for targeted updates. From 2c40cf67faf9fb88bc9ba8aaf017e8862f359320 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:02:19 +0000 Subject: [PATCH 26/41] Document the rate limiter package Rewrite the canonical rate limiting guide around typed rate limits, atomic stores, fixed windows, leaky buckets, weighted operations, backoff, store selection, pruning, custom drivers, identity rules, and failure behavior. Align routing and queue guidance with the unified limiter API, registered store selection, ordered consumption, response-based charging, result headers, and queue backoff semantics while preserving the wording and examples inherited from Laravel wherever no Hypervel adaptation is required. Record public Laravel differences in the package and Foundation READMEs, fix the Redis throttle description and facade table of contents, document the retained native Redis optimization work, and keep benchmark terminology consistent with the public API. Add the matching real-time facade removal marker to the Foundation bootstrap test so the intentional omission is recorded alongside its source and README documentation. --- docs/todo.md | 1 + src/boost/docs/facades.md | 1 - src/boost/docs/middleware.md | 2 +- src/boost/docs/queues.md | 10 +- src/boost/docs/rate-limiting.md | 148 ++++++++++++------ src/boost/docs/redis.md | 4 +- src/boost/docs/routing.md | 36 +++-- src/foundation/README.md | 2 + src/rate-limiter/README.md | 13 +- tests/Benchmarks/RateLimiter/README.md | 4 +- .../Bootstrap/RegisterFacadesTest.php | 2 + 11 files changed, 140 insertions(+), 83 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index d4739d84b..51e5ecef1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -47,6 +47,7 @@ - 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. - Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then. + ## Collections ## Horizon diff --git a/src/boost/docs/facades.md b/src/boost/docs/facades.md index f7803f390..4fabf671c 100644 --- a/src/boost/docs/facades.md +++ b/src/boost/docs/facades.md @@ -5,7 +5,6 @@ - [Facades vs. Dependency Injection](#facades-vs-dependency-injection) - [Facades vs. Helper Functions](#facades-vs-helper-functions) - [How Facades Work](#how-facades-work) -- [Real-Time Facades](#real-time-facades) - [Facade Class Reference](#facade-class-reference) diff --git a/src/boost/docs/middleware.md b/src/boost/docs/middleware.md index 55a1a1a79..687633b03 100644 --- a/src/boost/docs/middleware.md +++ b/src/boost/docs/middleware.md @@ -404,7 +404,7 @@ For convenience, some of Hypervel's built-in middleware are aliased by default. | `password.confirm` | `Hypervel\Auth\Middleware\RequirePassword` | | `precognitive` | `Hypervel\Foundation\Http\Middleware\HandlePrecognitiveRequests` | | `signed` | `Hypervel\Routing\Middleware\ValidateSignature` | -| `throttle` | `Hypervel\Routing\Middleware\ThrottleRequests` | +| `throttle` | `Hypervel\Routing\Middleware\ThrottleRequests` | | `verified` | `Hypervel\Auth\Middleware\EnsureEmailIsVerified` | diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index a907754f7..ab853084c 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -698,7 +698,7 @@ return Limit::perMinute(50)->by($job->user->id); Named queue rate limiters use the same [key scope resolver](/docs/{{version}}/routing#scoping-named-rate-limits) as named route rate limiters. -Queue rate limiters may use fixed-window or leaky-bucket policies, weighted costs, and multiple ordered policies. When multiple policies are returned, they are consumed sequentially; capacity accepted by an earlier policy remains consumed if a later policy denies the job. +Queue rate limiters may use fixed-window or leaky-bucket rate limits, and each operation may have a weighted cost. If a named limiter returns several rate limits, Hypervel consumes them in the listed order. When a later rate limit denies the job, capacity already consumed by earlier rate limits is not restored. Once you have defined your rate limit, you may attach the rate limiter to your job using the `Hypervel\Queue\Middleware\RateLimited` middleware. Each time the job exceeds the rate limit, this middleware will release the job back to the queue with an appropriate delay based on the rate limit duration: @@ -755,7 +755,7 @@ public function middleware(): array } ``` -The same `RateLimited` middleware supports every configured rate limiter store; no Redis-specific middleware class is required. +The `RateLimited` middleware supports every configured rate limiter store. ### Preventing Job Overlaps @@ -934,7 +934,9 @@ return [(new ThrottlesExceptions(10, 5 * 60))->backoff( )]; ``` -Internally, this middleware uses Hypervel's rate limiter, and the job's class name is used as the policy key. You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and would like them to share a common throttling bucket: +The middleware's `backoff` method controls the ordinary queue retry delay after an individual exception. It is separate from the rate limiter's [exponential backoff policy](/docs/{{version}}/rate-limiting#exponential-backoff). + +Internally, this middleware uses Hypervel's rate limiter, and the job's display name is used as the rate limit key. You may override this key by calling the `by` method when attaching the middleware to your job. This may be useful if you have multiple jobs interacting with the same third-party service and would like them to share a common throttling bucket: ```php use Hypervel\Queue\Middleware\ThrottlesExceptions; @@ -1034,8 +1036,6 @@ public function middleware(): array } ``` -The middleware's `backoff` method controls the ordinary queue retry delay after an individual exception. It is separate from the rate limiter's [exponential backoff policy](/docs/{{version}}/rate-limiting#exponential-backoff). - ### Releasing Jobs diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index a0e552b24..8781c4029 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -5,11 +5,11 @@ - [Available Stores](#available-stores) - [Database Store](#database-store) - [Swoole Store](#swoole-store) -- [Defining Policies](#defining-policies) +- [Defining Rate Limits](#defining-rate-limits) - [Fixed Windows](#fixed-windows) - [Leaky Buckets](#leaky-buckets) - [Weighted Operations](#weighted-operations) - - [Unlimited Policies](#unlimited-policies) + - [Unlimited](#unlimited) - [Using the Rate Limiter](#using-the-rate-limiter) - [Consuming Capacity](#consuming-capacity) - [Inspecting State](#inspecting-state) @@ -33,9 +33,9 @@ The rate limiter supports: - weighted operations; - capped exponential failure backoff; - Redis, Swoole, database, and worker-local array stores; and -- custom stores registered through Hypervel's familiar manager extension API. +- custom rate limiter stores. -Every rate limit operation returns a result containing whether the operation was allowed, its remaining capacity, and any retry or reset delay. Your application does not need to perform another store lookup after consuming capacity. +After consuming capacity, Hypervel returns the decision, remaining capacity, and retry or reset delay. Your application does not need to query the store again. > [!NOTE] > If you are limiting incoming HTTP requests, consult the [routing rate limiter documentation](/docs/{{version}}/routing#rate-limiting). For queued jobs, consult the [queue middleware documentation](/docs/{{version}}/queues#rate-limiting). @@ -78,7 +78,7 @@ return [ ]; ``` -The `prefix` keeps limiter state separate when multiple applications use the same backend. Hypervel includes this value when generating its hashed limiter keys. +The `prefix` keeps rate limit state separate when multiple applications use the same backend. Hypervel includes this value when generating its hashed keys. ### Available Stores @@ -92,9 +92,9 @@ Hypervel includes four rate limiter stores: | `swoole` | Workers belonging to one Swoole server instance | Very high-throughput local limiting | | `worker-array` | One worker process | Tests and deliberately worker-local workloads | -The Redis store performs each rate limit operation using one pooled connection checkout and one cached Lua script. The database store uses transactions and row locks, making it a portable choice when Redis is not available, though it does not offer the same throughput as Redis. +The Redis store evaluates each fixed-window, leaky-bucket, and backoff decision atomically in a single cached Lua script, using one pooled connection checkout per operation. The database store uses transactions and row locks. It is a portable shared option when Redis is not available, but does not offer the same throughput. -The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but it is not shared by independent Hypervel server instances or different machines. The `worker-array` store is not shared between workers at all. It does not prune entries in the background, so an expired entry remains until the same key is updated or cleared, or the worker restarts. Reverb clears its per-connection message limit when the connection closes. +The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but not by independent Hypervel server instances or different machines. The `worker-array` store is limited to one worker. It does not prune entries in the background, so an expired entry remains until its key is updated or cleared, or the worker restarts. ### Database Store @@ -109,13 +109,15 @@ php artisan migrate The `rate-limiter:table` command is also available as an alias. -Do not change database rate limit state while the store's selected connection is already inside a transaction. This restriction applies to consuming capacity, recording failures, clearing state, and pruning expired rows. Hypervel will throw a `LogicException` when one of these operations is called inside an active transaction. +> [!WARNING] +> You may not consume capacity, record failures, clear state, or prune expired rows while the selected database connection is already inside a transaction. Hypervel will throw a `LogicException` if you attempt to do so. -If your application must rate limit from inside a transaction, configure the rate limiter store to use a separate named database connection through its `connection` option. The connection may use the same database server or a dedicated rate limiter database. Run the `rate_limits` migration on every connection used by a database rate limiter store. +If your application needs to rate limit while another connection is inside a transaction, configure a separate named connection using the store's `connection` option. The connection may use the same database server or a dedicated rate limiter database. Run the `rate_limits` migration on every connection used by a database rate limiter store. PostgreSQL limiter connections must use the default `READ COMMITTED` transaction isolation level. MySQL and MariaDB's default `REPEATABLE READ` isolation level is supported. -The `inspect` method remains available inside a transaction because it does not change rate limit state. However, when using `REPEATABLE READ` with MySQL or MariaDB, it reads the outer transaction's snapshot and may not include changes committed after that transaction began. +> [!NOTE] +> The `inspect` method remains available inside a transaction because it does not change rate limit state. Under MySQL or MariaDB's `REPEATABLE READ` isolation, it reads the outer transaction's snapshot and may not include changes committed after the transaction began. Expired database rows should be pruned periodically. You may schedule the prune command to run hourly: @@ -136,26 +138,28 @@ php artisan rate-limiter:prune database --chunk=2000 The Swoole store allocates its table before server workers are forked. Changes to its table settings therefore require a server restart. -Size the table for the peak number of concurrently active physical limiter keys, plus headroom. A key remains active for its fixed window, leaky-bucket refill period, or backoff inactivity period. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. +Set `rows` higher than the greatest number of rate limit keys that may be active at once. A key remains active for its fixed window, leaky-bucket refill time, or backoff inactivity time. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. -Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, and fails closed if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. +Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, and throws `Hypervel\RateLimiter\Exceptions\SwooleTableFullException` if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. -Expired rows are pruned by worker zero at the configured `prune_interval`, in seconds. A mutating operation also replaces expired state for its key. Inspection treats expired state as empty without changing the table. +Worker zero prunes expired rows at the configured `prune_interval`, in seconds. Consuming capacity, recording a failure, or clearing a key also replaces or removes expired state for that key. Inspection treats expired state as empty without changing the table. - -## Defining Policies + +## Defining Rate Limits -Rate limit policies are immutable. Methods such as `by`, `cost`, and `burst` return a new policy without changing the original, so policies may be safely reused by long-running workers. +Rate limits are immutable. Methods such as `by`, `cost`, and `burst` return a new rate limit without changing the original, so you may safely reuse them in long-running workers. -Use the `by` method to scope a policy to a user, tenant, IP address, or any other stable identifier: +Use the `by` method to scope a rate limit to a user, tenant, IP address, or any other stable identifier: ```php use Hypervel\RateLimiter\Limit; -$policy = Limit::perMinute(60)->by('user:'.$user->id); +$limit = Limit::perMinute(60)->by('user:'.$user->id); ``` -String-backed and integer-backed enums, strings, integers, stringable objects, and `null` are accepted as keys. A `null` key represents the same shared policy as an empty string. +Enums, strings, integers, stringable objects, and `null` are accepted as keys. Backed enums use their values, while unit enums use their case names. A `null` key represents the same shared rate limit as an empty string. + +Invalid rate limit settings throw `Hypervel\RateLimiter\Exceptions\InvalidRateLimitException` before the store is changed. ### Fixed Windows @@ -172,10 +176,10 @@ $perHour = Limit::perHour(1000); $perDay = Limit::perDay(10_000); ``` -Each factory also accepts a duration multiplier. For example, the following policy allows 120 operations during a two-minute window: +Each factory also accepts a duration multiplier. For example, the following rate limit allows 120 operations during a two-minute window: ```php -$policy = Limit::perMinute(120, decayMinutes: 2); +$limit = Limit::perMinute(120, decayMinutes: 2); ``` A denied operation does not consume capacity or extend the active window. @@ -183,22 +187,22 @@ A denied operation does not consume capacity or extend the active window. ### Leaky Buckets -The `LeakyBucket` policy replenishes capacity continuously instead of resetting all capacity at one window boundary. Hypervel implements this behavior using the Generic Cell Rate Algorithm, which requires only one timestamp per limiter key. +The `LeakyBucket` class replenishes capacity continuously instead of resetting all capacity at one window boundary. Hypervel implements this leaky-bucket behavior using the Generic Cell Rate Algorithm (GCRA). ```php use Hypervel\RateLimiter\LeakyBucket; -$policy = LeakyBucket::perSecond(100) +$limit = LeakyBucket::perSecond(100) ->burst(200) ->by('api-token:'.$token->id); ``` -This policy sustains 100 operations per second while allowing an initial burst of up to 200 operations. The burst value is the total immediately available capacity, not additional capacity beyond the configured rate. +This rate limit sustains 100 operations per second while allowing an initial burst of up to 200 operations. The burst value is the total immediately available capacity, not additional capacity beyond the configured rate. -If `burst` is omitted, it defaults to the rate supplied to the factory. To strictly smooth a policy to one immediately available operation, explicitly use `burst(1)`: +If `burst` is omitted, it defaults to the rate supplied to the factory. To keep only one operation immediately available at a time, use `burst(1)`: ```php -$policy = LeakyBucket::perSecond(100)->burst(1); +$limit = LeakyBucket::perSecond(100)->burst(1); ``` The same `perMinute`, `perMinutes`, `perHour`, and `perDay` factories available on `Limit` are also available on `LeakyBucket`. @@ -209,15 +213,15 @@ The same `perMinute`, `perMinutes`, `perHour`, and `perDay` factories available By default, an operation consumes one unit of capacity. Use `cost` when some operations should consume more: ```php -$policy = Limit::perMinute(100) +$limit = Limit::perMinute(100) ->cost(5) ->by('uploads:'.$user->id); ``` The cost may not exceed the fixed-window capacity or leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. - -### Unlimited Policies + +### Unlimited Use `Limit::none()` when a named limiter should deliberately allow all operations: @@ -227,7 +231,7 @@ return $user->isAdministrator() : Limit::perMinute(60)->by($user->id); ``` -Unlimited policies do not access the configured store. +Unlimited rate limits do not access the configured store. ## Using the Rate Limiter @@ -257,7 +261,7 @@ A `LimitResult` provides: - `allowed()` and `denied()`; - `limit()`, the fixed-window capacity or leaky-bucket burst capacity; - `remaining()`, the whole capacity immediately available after the decision; -- `retryAfter()`, the minimum whole seconds until this policy's cost may be accepted; and +- `retryAfter()`, the minimum whole seconds until the same cost may be accepted; and - `resetAfter()`, the whole seconds until the fixed window expires or the leaky bucket becomes full. Durations are rounded up, ensuring a caller is never instructed to retry before capacity is actually available. @@ -268,10 +272,15 @@ Durations are rounded up, ensuring a caller is never instructed to retry before The `inspect` method returns a decision without consuming capacity or creating state: ```php -$result = RateLimiter::inspect($policy); +use Hypervel\RateLimiter\Limit; +use Hypervel\Support\Facades\RateLimiter; + +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); + +$result = RateLimiter::inspect($limit); if ($result->allowed()) { - // The policy's configured cost is currently available... + // The requested capacity is currently available... } ``` @@ -280,10 +289,15 @@ Inspection is useful when your application must decide whether to begin expensiv ### Attempting Operations -The `attempt` method consumes capacity before executing a callback. It returns `false` when the policy is denied; otherwise, it returns the callback result. A `null` callback result is converted to `true`: +The `attempt` method consumes capacity before executing a callback. It returns `false` when the rate limit is denied; otherwise, it returns the callback result. A `null` callback result is converted to `true`: ```php -$executed = RateLimiter::attempt($policy, function () use ($message): void { +use Hypervel\RateLimiter\Limit; +use Hypervel\Support\Facades\RateLimiter; + +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); + +$executed = RateLimiter::attempt($limit, function () use ($message): void { $message->send(); }); @@ -297,17 +311,20 @@ The accepted capacity remains consumed if the callback throws an exception. This ### Clearing State -The `clear` method removes the state addressed by a policy: +The `clear` method removes the state for a rate limit: ```php -RateLimiter::clear( - Limit::perMinute(5)->by('send-message:'.$user->id), -); +use Hypervel\RateLimiter\Limit; +use Hypervel\Support\Facades\RateLimiter; + +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); + +RateLimiter::clear($limit); ``` -Policy type and stable parameters are part of the stored identity. Therefore, `clear` must receive the same policy type, capacity, window or refill settings, key, and global scope that created the state. Changing policy parameters intentionally starts fresh state while the old entry expires naturally. +To clear existing state, use the same rate limit type, capacity, window or refill settings, key, and global scope that created it. Changing any of these settings starts fresh state while the old entry expires naturally. -Callbacks and operation cost are not part of the stable policy identity. This allows the same bucket to charge operations with different costs. +Callbacks and operation cost do not change the stored identity. This allows the same rate limit to charge operations with different costs. ### Selecting a Store @@ -315,7 +332,12 @@ Callbacks and operation cost are not part of the stable policy identity. This al Use `store` to perform an operation against a configured store other than the default: ```php -$result = RateLimiter::store('redis')->consume($policy); +use Hypervel\RateLimiter\Limit; +use Hypervel\Support\Facades\RateLimiter; + +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); + +$result = RateLimiter::store('redis')->consume($limit); ``` The store name may also be an enum. You should configure the default store during application boot instead of changing it during a request, since the configured default is shared by the entire worker. @@ -323,9 +345,10 @@ The store name may also be an enum. You should configure the default store durin ## Exponential Backoff -An exponential backoff policy tracks failures rather than admitted requests. This makes it suitable for authentication failures or unstable external services: +Exponential backoff tracks failures rather than admitted requests. This makes it suitable for authentication failures or unstable external services: ```php +use Hypervel\Auth\AuthenticationException; use Hypervel\RateLimiter\Backoff; use Hypervel\Support\Facades\RateLimiter; @@ -372,12 +395,39 @@ RateLimiter::for('api', function ($request) { }, store: 'redis'); ``` -You should register named limiters during application boot because their definitions are shared for the lifetime of the worker. Named limiters may be used by routing and queue middleware. The routing documentation covers [attaching named limiters to routes](/docs/{{version}}/routing#attaching-rate-limiters-to-routes), response callbacks, global policies, and stacked policies. +You should register named limiters during application boot because their definitions are shared for the lifetime of the worker. Named limiters may be used by routing and queue middleware. The routing documentation covers [attaching named limiters to routes](/docs/{{version}}/routing#attaching-rate-limiters-to-routes), response callbacks, global rate limits, and stacked rate limits. ## Custom Stores -Custom drivers implement `Hypervel\RateLimiter\Contracts\Store`. Register the driver from a service provider's `boot` method using the manager's `extend` method: +Custom drivers implement `Hypervel\RateLimiter\Contracts\Store`. The contract contains the following methods: + +```php +use Hypervel\RateLimiter\AdmissionPolicy; +use Hypervel\RateLimiter\Backoff; +use Hypervel\RateLimiter\BackoffResult; +use Hypervel\RateLimiter\LimitResult; + +interface Store +{ + public function consume(string $key, AdmissionPolicy $policy): LimitResult; + + public function inspect( + string $key, + AdmissionPolicy|Backoff $policy, + ): LimitResult|BackoffResult; + + public function recordFailure(string $key, Backoff $backoff): BackoffResult; + + public function clear(string $key): bool; +} +``` + +A custom store receives validated `Limit` and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores. + +If your custom store retains expired state, it may also implement `Hypervel\RateLimiter\Contracts\PrunableStore` so it can be targeted by the `rate-limiter:prune` command. + +You may register a custom driver from a service provider's `boot` method using the manager's `extend` method: ```php use Hypervel\Contracts\Foundation\Application; @@ -400,13 +450,11 @@ Then add the driver to `rate-limiter.stores`: ], ``` -The manager also passes the requested store name to the driver callback as `$config['name']`. This value is set by the manager and replaces any `name` entry in the store configuration. - -A store receives a validated policy and a fixed-length key. It must implement `consume` atomically, provide a non-mutating `inspect` operation, record backoff failures, and clear keyed state. Custom stores should follow the same decision and timing semantics as Hypervel's built-in stores. +The manager also passes the requested store name to the driver callback as `$config['name']`. This value replaces any `name` entry in the store configuration. ## Failure Behavior -Rate limiting fails closed. Backend, connection pool, script, table allocation, and database errors are thrown instead of silently allowing the operation or falling back to a worker-local store. +If the configured store fails, Hypervel throws an exception. It never silently allows the operation or switches to another store. -Choose and operate the store according to the availability requirements of the protected operation. Hypervel never changes to a weaker store automatically because doing so would produce different limits on different workers or application servers. +Choose a store that provides the availability and sharing your application requires. Switching stores automatically would produce different limits on different workers or application servers. diff --git a/src/boost/docs/redis.md b/src/boost/docs/redis.md index 0cc05c065..d80896e5a 100644 --- a/src/boost/docs/redis.md +++ b/src/boost/docs/redis.md @@ -537,7 +537,9 @@ Redis::throttle('api') }); ``` -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. +Unlike `funnel`, a throttle does not return a releasable lease. It counts executions in a fixed window that begins with the first execution and resets once the configured duration has elapsed. + +For application rate limiting through Redis or another configured store, consult the [rate limiting documentation](/docs/{{version}}/rate-limiting). #### Deleting Keys by Pattern diff --git a/src/boost/docs/routing.md b/src/boost/docs/routing.md index 4a1009de4..0884ed6f3 100644 --- a/src/boost/docs/routing.md +++ b/src/boost/docs/routing.md @@ -892,7 +892,7 @@ public function boot(): void } ``` -The `Limit` class defines a fixed-window limit and provides convenient `perSecond`, `perMinute`, `perMinutes`, `perHour`, and `perDay` methods. Rate limit policies are immutable, so modifier methods such as `by`, `cost`, and `response` return a new policy instead of changing the original. +The `Limit` class defines a fixed-window limit and provides convenient `perSecond`, `perMinute`, `perMinutes`, `perHour`, and `perDay` methods. Rate limits are immutable, so modifier methods such as `by`, `cost`, and `response` return a new rate limit instead of changing the original. If the incoming request exceeds the specified rate limit, a response with a 429 HTTP status code will automatically be returned by Hypervel. If you would like to define your own response that should be returned by a rate limit, you may use the `response` method: @@ -914,6 +914,16 @@ RateLimiter::for('uploads', function (Request $request) { }); ``` +Named route limiters may use fixed-window or leaky-bucket rate limits, including weighted costs. To learn more about defining rate limits, please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#defining-rate-limits). + +The optional third argument to `RateLimiter::for` selects a configured store for the named limiter. When omitted, Hypervel uses the default rate limiter store: + +```php +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); +}, store: 'redis'); +``` + #### Segmenting Rate Limits @@ -961,16 +971,6 @@ RateLimiter::for('shared-api', function () { }); ``` -Named route limiters may use any policy supported by Hypervel, including fixed-window and leaky-bucket limits with weighted costs. To learn more about the available policies, please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#defining-policies). - -The optional third argument to `RateLimiter::for` selects a configured store for the named limiter. When omitted, Hypervel uses the default rate limiter store: - -```php -RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id ?? $request->ip()); -}, store: 'redis'); -``` - #### Multiple Rate Limits @@ -985,7 +985,7 @@ RateLimiter::for('login', function (Request $request) { }); ``` -Policy type and stable parameters are part of the stored identity, so different windows or algorithms may safely use the same `by` value: +Hypervel includes each rate limit's type and algorithm settings in its stored key. Therefore, you may reuse the same `by` value for different windows or algorithms: ```php RateLimiter::for('uploads', function (Request $request) { @@ -996,7 +996,9 @@ RateLimiter::for('uploads', function (Request $request) { }); ``` -Policies are consumed in the order they are returned. If a later policy denies the request, capacity already consumed by earlier policies is not restored. +Hypervel consumes the rate limits in the order they are returned. If a later rate limit denies the request, capacity already consumed by earlier rate limits is not restored. + +When several rate limits apply, the `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers describe the rate limit with the least remaining capacity. Hypervel also leaves a lower `X-RateLimit-Remaining` value already set by your application in place. #### Response-Based Rate Limiting @@ -1016,12 +1018,14 @@ RateLimiter::for('resource-not-found', function (Request $request) { ->by($request->user()?->id ?: $request->ip()) ->after(function (Response $response) { // Only count 404 responses toward the rate limit to prevent enumeration... - return $response->status() === 404; + return $response->getStatusCode() === 404; }); }); ``` -Hypervel inspects a response-based policy before invoking the route, then consumes it only when the callback returns `true`. Another request may consume the final capacity while the response is being produced. In that case, Hypervel does not reject the completed response, but its rate limit headers reflect the final decision. +Hypervel checks the rate limit without consuming capacity before invoking the route. After the route returns a response, capacity is consumed only when the callback returns `true`. + +Another request may consume the final capacity while the response is being produced. If this occurs, Hypervel still returns the completed response and uses the denied decision for its rate limit headers. ### Attaching Rate Limiters to Routes @@ -1048,7 +1052,7 @@ Route::middleware(['throttle:60,1'])->group(function () { }); ``` -Hypervel uses one `Hypervel\Routing\Middleware\ThrottleRequests` implementation for every rate limiter store. To use Redis, configure the named limiter's store as shown above or select Redis as `rate-limiter.default`; no separate Redis middleware is required. +The throttle middleware automatically uses the selected rate limiter store. To use Redis, select it on the named limiter as shown above or configure it as `rate-limiter.default`. Successful responses include `X-RateLimit-Limit` and `X-RateLimit-Remaining`. A denied request receives a 429 response with `Retry-After` and `X-RateLimit-Reset` headers in addition to the limit and remaining headers. diff --git a/src/foundation/README.md b/src/foundation/README.md index d3bbf9036..686f33125 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -12,6 +12,8 @@ alias, and priority management surface because framework and package providers configure middleware through that contract. Custom HTTP kernels must implement the same surface, and its mutators are intended for application boot. +Laravel's real-time facades are intentionally not supported. Define explicit facade classes or inject services from the container instead. + Laravel's deprecated `Middleware::validateCsrfTokens()` alias is intentionally not ported. Configure request-forgery protection with `preventRequestForgery()`. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. diff --git a/src/rate-limiter/README.md b/src/rate-limiter/README.md index a59dd2395..9a9e4674c 100644 --- a/src/rate-limiter/README.md +++ b/src/rate-limiter/README.md @@ -5,10 +5,9 @@ Documentation: https://hypervel.org/docs/rate-limiting ## Differences From Laravel -Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and the `Hypervel\RateLimiter` namespace instead of Laravel's cache-bound limiter. Policies are consumed atomically through dedicated Redis, Swoole, database, or worker-array stores; the primitive counter methods under `Illuminate\Cache\RateLimiter` are not available. - -Hypervel uses immutable typed policies. `Limit` defines a fixed window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential lockout. Use `globally()` instead of Laravel's `GlobalLimit` class. - -Every physical limiter key is hashed and includes its policy parameters. Changing a policy starts new state, and `clear()` must receive the same policy parameters that created the state. Sequential stacked policies retain earlier successful charges when a later policy denies, weighted denials report the actual unused capacity, and `attempt()` consumes before the callback and retains the charge if the callback throws. - -Redis is selected as a regular rate-limiter store. Hypervel does not provide Redis-specific routing or queue middleware classes, a `throttleWithRedis()` switch, or an opt-out from canonical key hashing. +- Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and `Hypervel\RateLimiter` namespace instead of Laravel's Cache component. +- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class. +- The `consume`, `inspect`, `attempt`, `recordFailure`, and `clear` methods replace Laravel's split primitive counter API. Dedicated Redis, Swoole, database, and worker-array stores perform their state changes atomically. +- Rate limit keys are always hashed and include the rate limit type, its stable algorithm settings, and its global scope. Cost and callbacks do not affect identity. Changing identity settings starts new state, and `clear()` must receive the same settings that created the state. +- When several rate limits are consumed in order, earlier successful charges remain if a later rate limit denies the operation. Weighted denials report the actual unused capacity. The `attempt()` method consumes before invoking its callback and retains the charge if the callback throws. +- Redis is selected through the normal rate limiter store API. Hypervel does not provide Redis-specific routing or queue middleware classes, a `throttleWithRedis()` switch, a `redis` argument on `Middleware::throttleApi()`, or a way to disable key hashing. diff --git a/tests/Benchmarks/RateLimiter/README.md b/tests/Benchmarks/RateLimiter/README.md index 6e2455c2c..e8c1be6fa 100644 --- a/tests/Benchmarks/RateLimiter/README.md +++ b/tests/Benchmarks/RateLimiter/README.md @@ -1,6 +1,6 @@ # Rate Limiter Benchmark -This developer-only harness measures complete rate limiter operations through Hypervel's application container, manager, store wrapper, backend pool, atomic transition, and result decoding. It is not registered as an Artisan command and is not part of the PHPUnit suite. +This developer-only harness measures end-to-end rate limiter operations, including Hypervel's application container, manager, store wrapper, backend pool, atomic state update, and result decoding. It is not registered as an Artisan command and is not part of the PHPUnit suite. Run the default Redis, Swoole, and database workloads from the components repository root: @@ -23,7 +23,7 @@ php tests/Benchmarks/RateLimiter/benchmark.php \ Each output row records operations per second and p50, p95, and p99 operation latency. The heading records the PHP and Swoole versions, workload size, warmup, concurrency, and generated rate limiter prefix. Each store also prints its non-secret connection, driver, and sizing inputs. -The harness measures fixed-window and leaky-bucket policies on both allowed-heavy and denied-heavy paths. It runs each path with one client and with the requested number of clients contending for the same logical policy. Redis and pooled database operations can overlap while awaiting I/O. Swoole operations do not suspend inside one worker, so its concurrent row measures the normal single-worker coroutine workload rather than cross-process lock contention; the forked-worker test suite covers cross-process correctness. +The harness measures fixed-window and leaky-bucket rate limits on both allowed-heavy and denied-heavy paths. It runs each path with one client and with the requested number of clients contending for the same rate limit. Redis and pooled database operations can overlap while awaiting I/O. Swoole operations do not suspend inside one worker, so its concurrent row measures the normal single-worker coroutine workload rather than cross-process lock contention; the forked-worker test suite covers cross-process correctness. Use a configured MySQL, MariaDB, or PostgreSQL connection when comparing production database behavior. SQLite results are explicitly labeled and should not be treated as representative of a networked database. diff --git a/tests/Foundation/Bootstrap/RegisterFacadesTest.php b/tests/Foundation/Bootstrap/RegisterFacadesTest.php index 436ef5632..3248f42ae 100644 --- a/tests/Foundation/Bootstrap/RegisterFacadesTest.php +++ b/tests/Foundation/Bootstrap/RegisterFacadesTest.php @@ -13,6 +13,8 @@ class RegisterFacadesTest extends TestCase { + // REMOVED: Laravel's AliasLoader real-time facade path; Hypervel supports + // only explicit facade aliases. public function testRegisterAliases(): void { $config = m::mock(Repository::class); From e21b50fae4a8b78b58e427b6ec491656ba5adccc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:20:02 +0000 Subject: [PATCH 27/41] Document the prunable rate limiter store contract Show the exact pruneExpired method required by custom durable stores so driver authors can integrate with the rate-limiter:prune command without leaving the canonical documentation. --- src/boost/docs/rate-limiting.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index 8781c4029..7387c0133 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -427,6 +427,15 @@ A custom store receives validated `Limit` and `LeakyBucket` objects through the If your custom store retains expired state, it may also implement `Hypervel\RateLimiter\Contracts\PrunableStore` so it can be targeted by the `rate-limiter:prune` command. +The `PrunableStore` contract contains one method: + +```php +interface PrunableStore +{ + public function pruneExpired(int $chunkSize = 1000): int; +} +``` + You may register a custom driver from a service provider's `boot` method using the manager's `extend` method: ```php From b73e86c73a779a74052138be9e75d4d402d9adf3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:04:20 +0000 Subject: [PATCH 28/41] Use period terminology for leaky bucket limits Rename the LeakyBucket factory duration parameters and validation labels from decay to period so named arguments match the continuously replenished GCRA model. Convert each public duration unit directly to microseconds. This removes a redundant checked conversion and factory call while preserving every accepted range and reporting overflow errors in the unit supplied by the caller. Document the period multiplier with its sustained-rate meaning, update the implementation plan, and cover every named factory argument and unit-specific overflow boundary. --- .../2026-08-04-1543-rate-limiter-package.md | 2 +- src/boost/docs/rate-limiting.md | 6 +++- src/rate-limiter/src/LeakyBucket.php | 32 +++++++++++++------ tests/RateLimiter/LeakyBucketTest.php | 30 ++++++++++++++--- 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index 9d1dae1b8..763845e1a 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -238,7 +238,7 @@ RateLimiter::for('api', function (Request $request) { }, store: 'redis'); ``` -Factories mirror `Limit`'s names and argument order: `perSecond(int $rate, int $decaySeconds = 1)`, `perMinute(int $rate, int $decayMinutes = 1)`, `perMinutes(int $decayMinutes, int $rate)`, `perHour(int $rate, int $decayHours = 1)`, and `perDay(int $rate, int $decayDays = 1)`. The rate is the sustained number of tokens emitted over the period. `burst(int $capacity)` is the total immediately available capacity, not “extra” capacity. It defaults to the factory's rate argument, matching the least-surprising reading of `perSecond(100)` while still replenishing continuously; strict smoothing is the explicit `->burst(1)` case. `cost()` cannot exceed `burst()`. +Factories mirror `Limit`'s names and argument order while using period terminology appropriate to a continuously replenished bucket: `perSecond(int $rate, int $periodSeconds = 1)`, `perMinute(int $rate, int $periodMinutes = 1)`, `perMinutes(int $periodMinutes, int $rate)`, `perHour(int $rate, int $periodHours = 1)`, and `perDay(int $rate, int $periodDays = 1)`. Each factory converts its public period unit directly to the stored microseconds so validation errors name the caller's unit without a second conversion. The rate is the sustained number of tokens emitted over the period. `burst(int $capacity)` is the total immediately available capacity, not “extra” capacity. It defaults to the factory's rate argument, matching the least-surprising reading of `perSecond(100)` while still replenishing continuously; strict smoothing is the explicit `->burst(1)` case. `cost()` cannot exceed `burst()`. Document that the backend implementation is GCRA, which provides leaky-bucket behavior with constant state rather than running a leak timer. diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index 7387c0133..2c0bc6c86 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -205,7 +205,11 @@ If `burst` is omitted, it defaults to the rate supplied to the factory. To keep $limit = LeakyBucket::perSecond(100)->burst(1); ``` -The same `perMinute`, `perMinutes`, `perHour`, and `perDay` factories available on `Limit` are also available on `LeakyBucket`. +The same `perMinute`, `perMinutes`, `perHour`, and `perDay` factories available on `Limit` are also available on `LeakyBucket`. Each factory accepts a period multiplier. For example, the following rate limit sustains 120 operations every two minutes: + +```php +$limit = LeakyBucket::perMinute(120, periodMinutes: 2); +``` ### Weighted Operations diff --git a/src/rate-limiter/src/LeakyBucket.php b/src/rate-limiter/src/LeakyBucket.php index 32d15c090..c4a91eef0 100644 --- a/src/rate-limiter/src/LeakyBucket.php +++ b/src/rate-limiter/src/LeakyBucket.php @@ -47,11 +47,11 @@ public function __construct( /** * Create a new per-second leaky-bucket limit. */ - public static function perSecond(int $rate, int $decaySeconds = 1): static + public static function perSecond(int $rate, int $periodSeconds = 1): static { return new static( $rate, - static::multiply($decaySeconds, 1_000_000, 'decay seconds'), + static::multiply($periodSeconds, 1_000_000, 'period seconds'), $rate, ); } @@ -59,33 +59,45 @@ public static function perSecond(int $rate, int $decaySeconds = 1): static /** * Create a new per-minute leaky-bucket limit. */ - public static function perMinute(int $rate, int $decayMinutes = 1): static + public static function perMinute(int $rate, int $periodMinutes = 1): static { - return static::perSecond($rate, static::multiply($decayMinutes, 60, 'decay minutes')); + return new static( + $rate, + static::multiply($periodMinutes, 60_000_000, 'period minutes'), + $rate, + ); } /** * Create a new leaky-bucket limit using minutes as the period. */ - public static function perMinutes(int $decayMinutes, int $rate): static + public static function perMinutes(int $periodMinutes, int $rate): static { - return static::perMinute($rate, $decayMinutes); + return static::perMinute($rate, $periodMinutes); } /** * Create a new per-hour leaky-bucket limit. */ - public static function perHour(int $rate, int $decayHours = 1): static + public static function perHour(int $rate, int $periodHours = 1): static { - return static::perSecond($rate, static::multiply($decayHours, 3600, 'decay hours')); + return new static( + $rate, + static::multiply($periodHours, 3_600_000_000, 'period hours'), + $rate, + ); } /** * Create a new per-day leaky-bucket limit. */ - public static function perDay(int $rate, int $decayDays = 1): static + public static function perDay(int $rate, int $periodDays = 1): static { - return static::perSecond($rate, static::multiply($decayDays, 86400, 'decay days')); + return new static( + $rate, + static::multiply($periodDays, 86_400_000_000, 'period days'), + $rate, + ); } /** diff --git a/tests/RateLimiter/LeakyBucketTest.php b/tests/RateLimiter/LeakyBucketTest.php index 1be97b9fc..7e6970ed4 100644 --- a/tests/RateLimiter/LeakyBucketTest.php +++ b/tests/RateLimiter/LeakyBucketTest.php @@ -7,16 +7,36 @@ use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; use Hypervel\RateLimiter\LeakyBucket; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class LeakyBucketTest extends TestCase { public function testFactoriesCreateLeakyBucketPolicies(): void { - $this->assertPolicy(LeakyBucket::perSecond(2, 3), 2, 3_000_000, 2); - $this->assertPolicy(LeakyBucket::perMinute(4, 5), 4, 300_000_000, 4); - $this->assertPolicy(LeakyBucket::perMinutes(6, 7), 7, 360_000_000, 7); - $this->assertPolicy(LeakyBucket::perHour(8, 2), 8, 7_200_000_000, 8); - $this->assertPolicy(LeakyBucket::perDay(10, 2), 10, 172_800_000_000, 10); + $this->assertPolicy(LeakyBucket::perSecond(rate: 2, periodSeconds: 3), 2, 3_000_000, 2); + $this->assertPolicy(LeakyBucket::perMinute(rate: 4, periodMinutes: 5), 4, 300_000_000, 4); + $this->assertPolicy(LeakyBucket::perMinutes(periodMinutes: 6, rate: 7), 7, 360_000_000, 7); + $this->assertPolicy(LeakyBucket::perHour(rate: 8, periodHours: 2), 8, 7_200_000_000, 8); + $this->assertPolicy(LeakyBucket::perDay(rate: 10, periodDays: 2), 10, 172_800_000_000, 10); + } + + #[DataProvider('periodOverflowProvider')] + public function testFactoryOverflowNamesItsPublicPeriodUnit(callable $factory, string $unit): void + { + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage("The rate limit period {$unit} exceeds the maximum supported duration."); + + $factory(); + } + + public static function periodOverflowProvider(): array + { + return [ + 'seconds' => [static fn () => LeakyBucket::perSecond(rate: 1, periodSeconds: 9_007_199_255), 'seconds'], + 'minutes' => [static fn () => LeakyBucket::perMinute(rate: 1, periodMinutes: 150_119_988), 'minutes'], + 'hours' => [static fn () => LeakyBucket::perHour(rate: 1, periodHours: 2_502_000), 'hours'], + 'days' => [static fn () => LeakyBucket::perDay(rate: 1, periodDays: 104_250), 'days'], + ]; } public function testBurstAndCostMayBeConfiguredInEitherOrder(): void From 5307b14ccc4efb65530812199ae0687a94200142 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:09:43 +0000 Subject: [PATCH 29/41] Skip rate limiter compression test without LZF PhpRedis only defines COMPRESSION_LZF when the extension is compiled with LZF support. The Redis and Valkey CI jobs use a phpredis build without optional compression codecs, so resolving the constant failed before the limiter behavior could be exercised. Guard the integration test with the same capability check used by the existing Redis and cache test suites. Builds with LZF continue to run the serializer and compression assertions, while builds without it report a clear skip instead of an error. --- tests/Integration/RateLimiter/RedisStoreTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Integration/RateLimiter/RedisStoreTest.php b/tests/Integration/RateLimiter/RedisStoreTest.php index 17713b33f..85af74cd7 100644 --- a/tests/Integration/RateLimiter/RedisStoreTest.php +++ b/tests/Integration/RateLimiter/RedisStoreTest.php @@ -201,6 +201,10 @@ public function testConcurrentWeightedClientsNeverAdmitBeyondCapacity(): void public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): void { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + $connection = $this->createRedisConnectionWithOptions('rate_limiter_encoded', [ 'prefix' => 'rate-limiter-encoded:', 'serializer' => PhpRedis::SERIALIZER_PHP, From 444f10d995b8811cbcea08e79344abfa0e29c934 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:56:58 +0000 Subject: [PATCH 30/41] Document rate limiter store cleanup requirements Warn that database limiter rows require scheduled pruning to prevent the rate_limits table from continuing to grow. Restrict the worker-array store guidance to automated tests, explaining its per-worker isolation and retained expired-key memory in both the public documentation and framework/Testbench configuration. --- src/boost/docs/rate-limiting.md | 12 +++++++++--- src/foundation/config/rate-limiter.php | 4 ++++ src/testbench/hypervel/config/rate-limiter.php | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index 2c0bc6c86..52e661c7b 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -90,11 +90,14 @@ Hypervel includes four rate limiter stores: | `redis` | Shared across application servers | High-throughput distributed rate limiting | | `database` | Shared across application servers | Distributed rate limiting without requiring Redis | | `swoole` | Workers belonging to one Swoole server instance | Very high-throughput local limiting | -| `worker-array` | One worker process | Tests and deliberately worker-local workloads | +| `worker-array` | One worker process | Automated tests only | The Redis store evaluates each fixed-window, leaky-bucket, and backoff decision atomically in a single cached Lua script, using one pooled connection checkout per operation. The database store uses transactions and row locks. It is a portable shared option when Redis is not available, but does not offer the same throughput. -The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but not by independent Hypervel server instances or different machines. The `worker-array` store is limited to one worker. It does not prune entries in the background, so an expired entry remains until its key is updated or cleared, or the worker restarts. +The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but not by independent Hypervel server instances or different machines. + +> [!WARNING] +> Do not use the `worker-array` store for application rate limiting. It maintains independent state in every worker, so limits are not shared across workers or servers. Expired unused keys remain in memory until the worker exits, causing memory usage to keep growing as new keys are encountered. Applications should select this store only for automated tests. ### Database Store @@ -119,7 +122,10 @@ PostgreSQL limiter connections must use the default `READ COMMITTED` transaction > [!NOTE] > The `inspect` method remains available inside a transaction because it does not change rate limit state. Under MySQL or MariaDB's `REPEATABLE READ` isolation, it reads the outer transaction's snapshot and may not include changes committed after the transaction began. -Expired database rows should be pruned periodically. You may schedule the prune command to run hourly: +> [!WARNING] +> The database store does not delete expired rows during rate limit checks. Schedule the `rate-limiter:prune` command regularly or the `rate_limits` table will continue growing as new limiter keys are encountered. + +You may schedule the command to run hourly: ```php use Hypervel\Support\Facades\Schedule; diff --git a/src/foundation/config/rate-limiter.php b/src/foundation/config/rate-limiter.php index be0f38ad0..a1c8adf30 100644 --- a/src/foundation/config/rate-limiter.php +++ b/src/foundation/config/rate-limiter.php @@ -48,6 +48,10 @@ 'prune_interval' => 60, // seconds ], + // Do not use worker-array for application rate limiting. Its state is not + // shared across workers or servers, and expired unused keys remain in + // memory until the worker exits. Applications should select it only for + // automated tests. 'worker-array' => [ 'driver' => 'worker-array', ], diff --git a/src/testbench/hypervel/config/rate-limiter.php b/src/testbench/hypervel/config/rate-limiter.php index 4d0009d12..372d4fdb4 100644 --- a/src/testbench/hypervel/config/rate-limiter.php +++ b/src/testbench/hypervel/config/rate-limiter.php @@ -48,6 +48,10 @@ 'prune_interval' => 60, // seconds ], + // Do not use worker-array for application rate limiting. Its state is not + // shared across workers or servers, and expired unused keys remain in + // memory until the worker exits. Applications should select it only for + // automated tests. 'worker-array' => [ 'driver' => 'worker-array', ], From 75e7f0ceb6371494e09ccd91c3f5066f1e1dbb59 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:46:40 +0000 Subject: [PATCH 31/41] Decouple Reverb message rate limiting Inject a directly composed worker-local limiter into the Pusher server so internal per-connection admission cannot be changed by application rate-limiter store configuration. Allow key resolvers to omit named scope callbacks for direct package composition. Deliver the 4301 response before terminating rate-limited WebSocket connections, preserve termination when error reporting fails, require complete enabled settings, and normalize numeric config values from environment-backed or custom application providers. Expand Reverb and key resolver coverage for configuration independence, limiter cleanup, delivery ordering, failure cleanup, numeric strings, and deterministic transport behavior. Document the public Reverb settings and record the final design in the implementation plan. --- .../2026-08-04-1543-rate-limiter-package.md | 27 ++- src/boost/docs/reverb.md | 2 + src/rate-limiter/src/KeyResolver.php | 6 +- src/reverb/src/Protocols/Pusher/Server.php | 31 ++- src/reverb/src/ReverbServiceProvider.php | 14 ++ tests/RateLimiter/KeyResolverTest.php | 10 + tests/Reverb/Fixtures/FakeConnection.php | 5 + tests/Reverb/Protocols/Pusher/ServerTest.php | 189 +++++++++++++++--- 8 files changed, 232 insertions(+), 52 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index 763845e1a..4c3af5ab7 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -368,6 +368,7 @@ src/rate-limiter/ ├── Exceptions/ │ ├── InvalidRateLimitException.php │ └── SwooleTableFullException.php + ├── KeyResolver.php ├── LeakyBucket.php ├── Limit.php ├── Limiter.php @@ -388,7 +389,7 @@ src/rate-limiter/ Avoid an `Algorithms` service hierarchy. Policies hold validated immutable configuration. Worker-array, Swoole, and database stores share typed integer transition math through `CalculatesRateLimits`; Redis implements the same semantics in Lua. Both paths use a small exhaustive `instanceof` dispatch, never descriptor arrays or strategy enums. An unsupported policy throws `InvalidRateLimitException`. -`RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver. Built-in `createWorkerArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. Resolve/freeze static key configuration such as the validated prefix when the lazy store wrapper is created, but have the resolver read the manager's current optional scope callback on every named operation. That single property read keeps `resolveKeyScopeUsing()` effective even if a store was resolved first. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry. +`RateLimiter::resolve()` calls the parent driver resolver, asserts that the built-in/custom creator returned `Contracts\Store`, and constructs the `Limiter` with a physical-key resolver. Built-in `createWorkerArrayDriver()`, `createDatabaseDriver()`, `createRedisDriver()`, and `createSwooleDriver()` therefore return stores, not wrappers. `KeyResolver` accepts only the prefix when no named scope is needed; its optional scope callback is invoked only for a scoped named limiter. Resolve/freeze static key configuration such as the validated prefix when the lazy store wrapper is created, but have the manager supply a live closure that reads its current optional scope callback on every named operation. That single property read keeps `resolveKeyScopeUsing()` effective even if a store was resolved first. Keep the manager's instance cache as the sole wrapper/store cache; do not add a second registry, factory, or on-demand manager construction API. ### Store contract @@ -456,6 +457,8 @@ return [ Use typed config getters and validate every store at resolution. Swoole's `conflict_proportion` must be a float in the inclusive range `[0.2, 1.0]`, which is the range `Swoole\Table` honors without silently clamping. The service provider registers the database migration generator and prune commands. +The configured `worker-array` store is for automated tests only. Its state is isolated to one worker and expired untouched entries remain in memory until the worker exits, so production framework consumers that have a proven worker-local ownership model must compose `Limiter`, `WorkerArrayStore`, and `KeyResolver` directly rather than depend on a user-configurable store name. + `prefix` is an application namespace included in the canonical identity before its final hash; it is not concatenated onto the 32-character physical key. This preserves cross-application isolation without variable-length Swoole keys. It is separate from Redis `OPT_PREFIX` and database table prefixes. Do not add algorithm defaults to global config. Rates belong to typed application policy definitions, not storage configuration. @@ -658,9 +661,10 @@ The database driver is correctness-first and will require several SQL statements - Use an in-process numeric state array and the same epoch-microsecond clock/test seam as Swoole. - Use the `worker-array` name established by Hypervel Cache for worker-lifetime state. It is not coroutine-local and not shared across workers; do not call it `array`, which Hypervel documentation reserves for request-local scratch state. -- It is suitable for tests and deliberately local workloads such as Reverb per-connection message limits, because a connection remains owned by one worker and Reverb clears its key on close. +- The configured store is suitable only for automated tests. General application limiting would split state between workers and expired untouched keys would continue consuming worker memory. +- The store class remains a reusable package primitive for first-party code with a proven worker-local ownership boundary. Reverb composes it directly for per-connection message limits because each connection remains owned by one worker and its key is cleared on close; this internal use must not depend on the public store configuration. - Operations contain no suspension point, so a transition is atomic within one cooperative worker; it does not coordinate processes or hosts. -- Mutating operations replace expired state, while `inspect()` treats it as empty without changing storage. An expired entry whose key is never touched again remains for the worker lifetime; rely on explicit `clear()`, Reverb close cleanup, another mutating operation on that key, and worker recycling for this deliberately local/test store. Do not add an abandoned-key scheduler, expiry index, or unbounded whole-array sweep in a request hot path. +- Mutating operations replace expired state, while `inspect()` treats it as empty without changing storage. An expired entry whose key is never touched again remains for the worker lifetime; rely on explicit `clear()`, another mutating operation on that key, and worker recycling for this deliberately local/test store. Do not add an abandoned-key scheduler, expiry index, or unbounded whole-array sweep in a request hot path. ## Framework consumer refactor @@ -730,7 +734,13 @@ Change `Handler::throttle()` from `Lottery|Limit|null` to `Lottery|AdmissionPoli ### Reverb -Inject/resolve the new manager and use `store('worker-array')` for per-connection message limiting. Build the same fixed policy for consume and close-time clear. Remove direct construction of a cache `RateLimiter` and the dependency on `cache.worker-array` for this feature. +Inject `Limiter` directly into the Pusher protocol server and register a contextual binding that constructs it from `WorkerArrayStore` and `KeyResolver('reverb-message-rate-limiter')`. Do not resolve the `RateLimiter` manager or a configured store name. Hypervel auto-singletons the unbound Pusher server, so this object graph is built once per worker and retained on the server rather than constructed or resolved per message. + +Keep a concise comment at the binding explaining why worker-local state is correct: `WebSocketHandler` retains connections in a per-worker static registry keyed by file descriptor, and a connection remains owned by that worker for its lifetime. A shared backend would add I/O to every client message without extending the state to any worker that can use it. The contextual binding preserves normal constructor injection and deliberate test/application rebinding while keeping internal correctness independent of `rate-limiter.default`, the presence of `stores.worker-array`, and custom creators registered for the public `worker-array` driver name. + +Build the same fixed policy for consume and close-time clear. Promote the injected limiter as the server's protected constructor property instead of retaining a separate assignment. + +Treat an enabled Reverb application's `max_attempts` and `decay_seconds` values as required configuration; do not silently substitute a different rate-limit period for custom application providers. Cast both values to integers when building the policy because environment-backed and custom application configuration may contain numeric strings. When `terminate_on_limit` is enabled, attempt the Pusher 4301 error frame before disconnecting the socket. Keep termination in a `finally` block so a throwing `MessageSent` listener or logger cannot leave a connection open after it exceeded the limit. The connection test fake must ignore sends after termination like the real transport, and focused tests must distinguish the original terminate-before-send ordering from a naive send-then-terminate reorder without `finally`. Also replace Reverb's duplicated 64-stripe Atomic implementation with an explicitly injected Core `StripedLock`, created in the existing eager pre-fork provider block. Single-key operations use `withLock()` and presence operations use `withLocks([$channelKey, $userKey], ...)`; do not substitute `withAllLocks()`. Remove Reverb's local lock constants, arrays, acquisition helpers, and primitive-only tests while retaining call-site coverage for post-release reporting, shared-stripe deduplication, and opposite input ordering. @@ -830,6 +840,7 @@ Update every applicable Boost document, not just the main rate-limiting page: - `fortify.md`, `errors.md`, `starter-kits.md`: imports and new typed calls; - `facades.md`: canonical accessor/class; - `middleware.md`: one throttle middleware class; +- `reverb.md`: explain that message limits are per connection, define `max_attempts` and `decay_seconds`, and state that `terminate_on_limit` closes the connection after the rate-limit error; - database docs: `make:rate-limiter-table`, schema purpose, pruning schedule; - package README: only the package heading, the canonical Boost documentation link, and concise public `Differences From Laravel`; omit an upstream link because this independently maintained package does not track a source package. - Cache README: add a concise `Differences From Laravel` entry directing developers to the dedicated `hypervel/rate-limiter` package and canonical documentation. @@ -867,6 +878,7 @@ Create `tests/RateLimiter` and use the repository-required base test/coroutine c - Concrete copy hooks preserve readonly shared/algorithm fields without reflection or post-clone writes, and cross-field validation makes `cost()`/`burst()` fluent order irrelevant. - `globally`, scope, callbacks, cost, and response callbacks are retained correctly. - Policy fingerprints are stable for a limiter prefix, change when the prefix or policy parameters change, distinguish policy types, and exclude cost/callbacks. +- A `KeyResolver` without a scope callback produces the same physical key as one whose callback returns `null`; keep the manager's separate live-callback test for late `resolveKeyScopeUsing()` changes. - Arbitrary key segments cannot create ambiguous preimages before hashing. - Unlimited performs no store operation. - `LimitResult` and `BackoffResult` round timing up correctly and never expose negative remaining/retry values. @@ -959,7 +971,8 @@ Tagged Swoole releases currently stall the two coroutine-hooked SQLite concurren - Fortify fixed lockout and clearing. - Foundation exception report throttling. - Foundation's default `Limit::none()` throttle path satisfies the widened `Lottery|AdmissionPolicy|null` return type. -- Reverb per-connection isolation and close cleanup with worker-array store. +- Reverb per-connection isolation and close cleanup use the contextually injected limiter. A per-test `DefineEnvironment` callback removes the configured `worker-array` store and selects a missing default before provider boot, so the current manager-backed constructor fails during test setup rather than being masked by cached manager state. The cleanup test resets captured output after close and proves a fresh direct message is accepted with cleared state. +- Reverb sends the 4301 rate-limit error before terminating a connection, still terminates if error delivery or reporting throws, requires the complete enabled rate-limit configuration without a hidden one-second fallback, and enforces limits configured with environment-shaped numeric strings. - Facade resolves the canonical manager. - An existing provider/application integration test asserts that `DefaultProviders` contains `RateLimiterServiceProvider`, independently of package discovery; do not create composer-manifest tests or assert alphabetical order as runtime behavior. - Middleware configuration contains only `ThrottleRequests` and has no Redis switch. @@ -1014,7 +1027,7 @@ This order keeps the tree buildable while still delivering one final cut with no 6. Implement database store with the shared calculator, migration/prune commands, server clocks, default migrations, and database integration/concurrency tests. 7. Rewrite routing and Foundation middleware configuration; delete the Redis-specific request middleware/switch once tests pass. 8. Rewrite queue middleware and remove the two Redis-specific queue classes. -9. Rewrite Fortify, foundation exception throttling, Reverb, and facade access. +9. Rewrite Fortify, foundation exception throttling, Reverb, and facade access. Reverb receives a contextually bound direct `Limiter` so its internal per-connection limiter does not depend on application rate-limiter configuration. 10. Move/replace rate-limiter tests into `tests/RateLimiter`; remove cache rate-limiter classes/config/binding/tests. 11. Update every composer dependency, Boost document, minimal README/divergence record, facade annotation, AGENTS divergence/stale references, package inventory, and explicit Redis workflow path. 12. Update the official framework metapackage and application skeleton dependency/config/base migration, verifying those repositories under their own instructions. @@ -1031,7 +1044,7 @@ No step should add a temporary alias or dual API. If intermediate local compilat - [ ] Redis/Swoole/database/worker-array pass the shared semantic suite, and Redis/Swoole/database pass their applicable real-contention concurrency suites; failures never fail open and no driver routes through generic cache serialization. - [ ] Redis admission is one cached Lua call on the existing Redis 8 and Valkey 9 services; Swoole uses shared numeric state without live eviction and documents/logs capacity pressure; database uses only `rate_limits`. - [ ] Foundation, the application skeleton, and Testbench carry the same stores/migration, with database as the application default and worker-array as the deliberate Testbench default; named stores merge without duplicate package config. -- [ ] Routing retains its Laravel-facing helpers, syntax, callbacks, exceptions, headers, and registered-store selection with one middleware; queue and Reverb have no Redis/cache limiter branches. +- [ ] Routing retains its Laravel-facing helpers, syntax, callbacks, exceptions, headers, and registered-store selection with one middleware; queue has no Redis/cache limiter branches, and Reverb's direct worker-local limiter is independent of application rate-limiter configuration. - [ ] The framework metapackage, package dependencies, facade metadata, Boost's single `rate-limiting.md`, minimal README, AGENTS guidance, and required source/test difference markers agree. - [ ] Old namespaces, classes, config, tests, docs, switches, stale state, and obsolete TODOs are absent; the INCREX and capability TODOs remain accurate. - [ ] Existing Redis Duration/Concurrency limiters use the tested SHA-cache path without API changes. diff --git a/src/boost/docs/reverb.md b/src/boost/docs/reverb.md index fe6642bfb..0edf0bdd3 100644 --- a/src/boost/docs/reverb.md +++ b/src/boost/docs/reverb.md @@ -165,6 +165,8 @@ The `max_message_size` option limits the size of each WebSocket message sent by ], ``` +The `rate_limiting` option limits messages received from each connected client, including Pusher protocol messages such as `pusher:subscribe` and `pusher:ping`. Rate limiting is applied only when `enabled` is `true`. Each connection may send up to `max_attempts` messages during the configured `decay_seconds` period. When this limit is exceeded, Reverb returns error code 4301. Setting `terminate_on_limit` to `true` sends the error and then terminates the connection. + ### SSL diff --git a/src/rate-limiter/src/KeyResolver.php b/src/rate-limiter/src/KeyResolver.php index c6d7e2df5..966d9414a 100644 --- a/src/rate-limiter/src/KeyResolver.php +++ b/src/rate-limiter/src/KeyResolver.php @@ -14,11 +14,11 @@ class KeyResolver /** * Create a new physical key resolver. * - * @param Closure(string): ?string $scopeResolver + * @param null|Closure(string): ?string $scopeResolver */ public function __construct( protected string $prefix, - protected Closure $scopeResolver, + protected ?Closure $scopeResolver = null, ) { if ($prefix === '') { throw new InvalidRateLimitException('The rate limiter prefix may not be empty.'); @@ -43,7 +43,7 @@ public function resolve(AdmissionPolicy|Backoff $policy, ?string $limiterName = $identity .= $this->segment('limiter', $limiterName); if (! ($policy instanceof AdmissionPolicy && $policy->global)) { - $scope = ($this->scopeResolver)($limiterName); + $scope = $this->scopeResolver?->__invoke($limiterName); if ($scope !== null) { $identity .= $this->segment('scope', $scope); diff --git a/src/reverb/src/Protocols/Pusher/Server.php b/src/reverb/src/Protocols/Pusher/Server.php index effa4cede..4d3e214dc 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -6,7 +6,6 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; -use Hypervel\RateLimiter\RateLimiter; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\Events\ConnectionClosed; use Hypervel\Reverb\Events\ConnectionEstablished; @@ -26,20 +25,14 @@ class Server { - /** - * The per-connection message limiter. - */ - protected Limiter $messageRateLimiter; - /** * Create a new server instance. */ public function __construct( protected ChannelManager $channels, protected EventHandler $handler, - RateLimiter $rateLimiter, + protected Limiter $messageRateLimiter, ) { - $this->messageRateLimiter = $rateLimiter->store('worker-array'); } /** @@ -144,7 +137,19 @@ public function message(Connection $from, string $message): void MessageReceived::dispatch($from, $message); } } catch (Throwable $e) { - $this->error($from, $e); + $terminateOnLimit = $e instanceof RateLimitExceeded + && ($from->app()->rateLimiting()['terminate_on_limit'] ?? false); + + try { + $this->error($from, $e); + } finally { + // Attempt the 4301 frame before closing: a push to a disconnected fd fails + // Sender::check() and is dropped. The finally keeps termination guaranteed + // when a MessageSent listener or the logger throws. + if ($terminateOnLimit) { + $from->terminate(); + } + } } } @@ -274,12 +279,6 @@ protected function ensureWithinRateLimit(Connection $connection): void } if ($this->messageRateLimiter->consume($this->messageLimit($connection))->denied()) { - $config = $connection->app()->rateLimiting(); - - if ($config['terminate_on_limit'] ?? false) { - $connection->terminate(); - } - throw new RateLimitExceeded; } @@ -293,7 +292,7 @@ protected function messageLimit(Connection $connection): Limit { $config = $connection->app()->rateLimiting(); - return Limit::perSecond($config['max_attempts'], $config['decay_seconds'] ?? 1) + return Limit::perSecond((int) $config['max_attempts'], (int) $config['decay_seconds']) ->by('reverb:message:' . $connection->id()); } diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 90a9181eb..ef4800601 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -10,6 +10,9 @@ use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\OnPipeMessage; use Hypervel\Core\Events\OnWorkerExit; +use Hypervel\RateLimiter\KeyResolver; +use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\WorkerArrayStore; use Hypervel\Reverb\Console\Commands\InstallCommand; use Hypervel\Reverb\Contracts\ApplicationProvider; use Hypervel\Reverb\Contracts\Logger; @@ -32,6 +35,7 @@ use Hypervel\Reverb\Protocols\Pusher\MetricsHandler; use Hypervel\Reverb\Protocols\Pusher\MetricType; use Hypervel\Reverb\Protocols\Pusher\PendingMetric; +use Hypervel\Reverb\Protocols\Pusher\Server as PusherServer; use Hypervel\Reverb\Protocols\Pusher\UserConnectionTerminator; use Hypervel\Reverb\Servers\Hypervel\ChannelBroadcastPipeMessage; use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubProvider; @@ -97,6 +101,16 @@ public function register(): void $this->app->bind(ChannelConnectionManager::class, ArrayChannelConnectionManager::class); } + // WebSocketHandler keeps each connection in its owning worker for its lifetime, + // so worker-local state reaches every message without shared I/O. Construct the + // limiter directly so application rate-limiter config cannot replace the store. + $this->app->when(PusherServer::class) + ->needs(Limiter::class) + ->give(static fn (): Limiter => new Limiter( + new WorkerArrayStore, + new KeyResolver('reverb-message-rate-limiter'), + )); + $this->app->singleton(WebhookDispatcher::class, HttpWebhookDispatcher::class); $this->app->singleton(DeferredWebhookManager::class); diff --git a/tests/RateLimiter/KeyResolverTest.php b/tests/RateLimiter/KeyResolverTest.php index f97a28268..e6f54183c 100644 --- a/tests/RateLimiter/KeyResolverTest.php +++ b/tests/RateLimiter/KeyResolverTest.php @@ -70,6 +70,16 @@ public function testRequestCostAndCallbacksDoNotChangeIdentity(): void $this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited'))); } + public function testMissingScopeResolverMatchesAResolverReturningNull(): void + { + $policy = Limit::perMinute(60)->by('user:1'); + + $this->assertSame( + (new KeyResolver('app', static fn (): ?string => null))->resolve($policy, 'api'), + (new KeyResolver('app'))->resolve($policy, 'api'), + ); + } + public function testEquivalentCallerKeysNormalizeToTheSameIdentity(): void { $resolver = new KeyResolver('app', static fn (): ?string => null); diff --git a/tests/Reverb/Fixtures/FakeConnection.php b/tests/Reverb/Fixtures/FakeConnection.php index 44354ac8c..1ac1dce16 100644 --- a/tests/Reverb/Fixtures/FakeConnection.php +++ b/tests/Reverb/Fixtures/FakeConnection.php @@ -100,6 +100,11 @@ public function setHasBeenPinged(): void */ public function send(string $message): void { + // The real transport drops pushes after its file descriptor disconnects. + if ($this->wasTerminated) { + return; + } + $this->messages[] = $message; } diff --git a/tests/Reverb/Protocols/Pusher/ServerTest.php b/tests/Reverb/Protocols/Pusher/ServerTest.php index a23f642b3..1dbfe8854 100644 --- a/tests/Reverb/Protocols/Pusher/ServerTest.php +++ b/tests/Reverb/Protocols/Pusher/ServerTest.php @@ -5,23 +5,25 @@ namespace Hypervel\Tests\Reverb\Protocols\Pusher; use Hypervel\Contracts\Debug\ExceptionHandler; -use Hypervel\RateLimiter\Limit; -use Hypervel\RateLimiter\RateLimiter; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Reverb\Connection; use Hypervel\Reverb\Contracts\WebSocketConnection; use Hypervel\Reverb\Events\ConnectionClosed; use Hypervel\Reverb\Events\ConnectionEstablished; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelManager; use Hypervel\Reverb\Protocols\Pusher\EventHandler; +use Hypervel\Reverb\Protocols\Pusher\Exceptions\RateLimitExceeded; use Hypervel\Reverb\Protocols\Pusher\Managers\ScopedChannelManager; use Hypervel\Reverb\Protocols\Pusher\Server; use Hypervel\Reverb\Servers\Hypervel\Contracts\SharedState; use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Tests\Reverb\Fixtures\FakeConnection; use Hypervel\Tests\Reverb\ReverbTestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Throwable; class ServerTest extends ReverbTestCase { @@ -173,11 +175,7 @@ public function testReportsUnexpectedMessageFailuresWithoutChangingTheClientPayl $exceptionHandler->shouldReceive('report')->once()->with($exception); $this->app->instance(ExceptionHandler::class, $exceptionHandler); - $server = new Server( - $this->app->make(ChannelManager::class), - $handler, - $this->app->make(RateLimiter::class), - ); + $server = $this->app->makeWith(Server::class, ['handler' => $handler]); $server->message( $connection = new FakeConnection, json_encode([ @@ -671,7 +669,44 @@ public function testRejectsAMessageWhenTheRateLimitIsExceeded(): void $this->assertFalse($connection->wasTerminated); } - public function testMessageRateLimiterUsesWorkerArrayStore(): void + public function testEnforcesRateLimitConfiguredWithNumericStrings(): void + { + $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ + 'enabled' => true, + 'max_attempts' => '1', + 'decay_seconds' => '60', + 'terminate_on_limit' => false, + ]); + + $this->server->open($connection = new FakeConnection); + + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel'], + ]) + ); + + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel-overflow'], + ]) + ); + + $connection->assertReceived([ + 'event' => 'pusher:error', + 'data' => json_encode([ + 'code' => 4301, + 'message' => 'Rate limit exceeded', + ]), + ]); + } + + #[DefineEnvironment('withInvalidRateLimiterConfiguration')] + public function testMessageRateLimiterIsIndependentOfRateLimiterConfiguration(): void { $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ 'enabled' => true, @@ -679,13 +714,10 @@ public function testMessageRateLimiterUsesWorkerArrayStore(): void 'decay_seconds' => 60, 'terminate_on_limit' => false, ]); - $this->app['config']->set('rate-limiter.default', 'missing'); - $this->app->forgetInstance(Server::class); - $server = $this->app->make(Server::class); - $server->open($connection = new FakeConnection); + $this->server->open($connection = new FakeConnection); - $server->message( + $this->server->message( $connection, json_encode([ 'event' => 'pusher:subscribe', @@ -693,15 +725,13 @@ public function testMessageRateLimiterUsesWorkerArrayStore(): void ]) ); - $policy = Limit::perSecond(1, 60)->by('reverb:message:' . $connection->id()); - $result = $this->app->make(RateLimiter::class) - ->store('worker-array') - ->inspect($policy); - - $this->assertTrue($result->denied()); - $this->assertSame(0, $result->remaining()); + $connection->assertReceived([ + 'event' => 'pusher_internal:subscription_succeeded', + 'data' => '{}', + 'channel' => 'test-channel', + ]); - $server->message( + $this->server->message( $connection, json_encode([ 'event' => 'pusher:subscribe', @@ -720,6 +750,16 @@ public function testMessageRateLimiterUsesWorkerArrayStore(): void $this->assertFalse($connection->wasTerminated); } + protected function withInvalidRateLimiterConfiguration(ApplicationContract $app): void + { + $config = $app->make('config'); + $stores = $config->array('rate-limiter.stores'); + unset($stores['worker-array']); + + $config->set('rate-limiter.default', 'missing'); + $config->set('rate-limiter.stores', $stores); + } + public function testCloseClearsInitializedMessageRateLimiterState(): void { $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ @@ -738,16 +778,28 @@ public function testCloseClearsInitializedMessageRateLimiterState(): void ]) ); - $limiter = $this->app->make(RateLimiter::class)->store('worker-array'); - $policy = Limit::perSecond(1, 60)->by('reverb:message:' . $connection->id()); - $this->assertTrue($connection->hasInitializedRateLimiter()); - $this->assertTrue($limiter->inspect($policy)->denied()); $this->server->close($connection); $this->assertFalse($connection->hasInitializedRateLimiter()); - $this->assertTrue($limiter->inspect($policy)->allowed()); + + $connection->resetReceived(); + + // Probe the cleared limiter state directly; production does not receive messages after close. + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'fresh-channel'], + ]) + ); + + $connection->assertReceived([ + 'event' => 'pusher_internal:subscription_succeeded', + 'data' => '{}', + 'channel' => 'fresh-channel', + ]); } public function testTerminatesTheConnectionWhenRateLimitIsExceededAndConfiguredToTerminate(): void @@ -788,6 +840,75 @@ public function testTerminatesTheConnectionWhenRateLimitIsExceededAndConfiguredT $this->assertTrue($connection->wasTerminated); } + public function testTerminatesTheConnectionWhenSendingTheRateLimitErrorFails(): void + { + $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ + 'enabled' => true, + 'max_attempts' => 1, + 'decay_seconds' => 1, + 'terminate_on_limit' => true, + ]); + + $this->server->open($connection = new RateLimitErrorThrowingConnection); + + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel'], + ]) + ); + + $this->assertThrows( + fn () => $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel-2'], + ]) + ), + RuntimeException::class, + 'Rate limit error delivery failed.', + ); + + $this->assertTrue($connection->wasTerminated); + } + + public function testEnabledRateLimitingRequiresDecaySeconds(): void + { + $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ + 'enabled' => true, + 'max_attempts' => 1, + 'terminate_on_limit' => false, + ]); + + $exceptionHandler = m::mock(ExceptionHandler::class); + $exceptionHandler->shouldReceive('report') + ->once() + ->with(m::on(static fn (Throwable $exception): bool => str_contains( + $exception->getMessage(), + 'Undefined array key "decay_seconds"', + ))); + $this->app->instance(ExceptionHandler::class, $exceptionHandler); + + $this->server->open($connection = new FakeConnection); + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel'], + ]) + ); + + $connection->assertReceived([ + 'event' => 'pusher:error', + 'data' => json_encode([ + 'code' => 4200, + 'message' => 'Invalid message format', + ]), + ]); + } + public function testAllowsUnlimitedMessagesWhenNoRateLimitIsConfigured(): void { $this->server->open($connection = new FakeConnection); @@ -902,3 +1023,19 @@ public function testConnectionClosedEventIsDispatched(): void }); } } + +class RateLimitErrorThrowingConnection extends FakeConnection +{ + /** + * Send a message to the connection. + */ + public function send(string $message): void + { + // The active check makes this fixture fail against terminate-before-send ordering. + if (! $this->wasTerminated && $message === json_encode((new RateLimitExceeded)->payload())) { + throw new RuntimeException('Rate limit error delivery failed.'); + } + + parent::send($message); + } +} From 8a49a432d4c63e4b06b2b989c290ff833ce3c918 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:12:09 +0000 Subject: [PATCH 32/41] Fix Redis integration database isolation Normalize every configured Redis connection to the current test database in both sequential and ParaTest runs. Named cache, session, queue, and Reverb connections now share the worker-isolated database instead of leaking through their configured database numbers. Reject non-empty connection URLs because a URL path is merged after raw connection options and would override the worker database. Keep empty URLs equivalent to unset values, skip reserved Redis configuration groups, and retain one FLUSHDB because first-party integration workflows expose one service and all named connections now select the same database. Remove the dead token-to-database trait helper and extend the focused harness coverage for sequential and parallel normalization, reserved entries, empty URLs, URL rejection before flushing, and lightweight opt-in setup. --- .../Testing/Concerns/InteractsWithRedis.php | 40 +++---- .../Concerns/ExternalServiceOptInTest.php | 19 ++++ .../InteractsWithRedisParallelTest.php | 103 ++++++++++++++++++ 3 files changed, 142 insertions(+), 20 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php index 5ca3385c1..faa366d66 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithRedis.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithRedis.php @@ -10,6 +10,7 @@ use Hypervel\Redis\RedisConfig; use Hypervel\Support\Facades\Redis; use Hypervel\Testing\ParallelTesting; +use RuntimeException; use Throwable; /** @@ -60,8 +61,7 @@ protected function setUpInteractsWithRedis(): void ); } - // Apply per-worker DB number for parallel isolation (no-op in sequential mode) - $this->configureParallelRedisDb(); + $this->configureRedisDatabases(); $this->flushRedis(); } @@ -149,14 +149,6 @@ protected function getParallelRedisDb(): int return RedisTestDatabases::primaryDatabase($this->parallelTestingToken()); } - /** - * Get the primary Redis DB number for a parallel testing token. - */ - protected function redisDatabaseForParallelToken(string $token): int - { - return RedisTestDatabases::databaseForToken($token); - } - /** * Get the secondary Redis DB for tests that need to call select(). * @@ -171,21 +163,29 @@ protected function getSecondaryRedisDb(): int } /** - * Configure the Redis DB number for parallel test isolation. - * - * Sets the database.redis.default.database config to the per-worker DB number. + * Configure every Redis connection to use the current test database. */ - private function configureParallelRedisDb(): void + private function configureRedisDatabases(): void { - $token = $this->parallelTestingToken(); + $config = $this->app->make('config'); + $database = $this->getParallelRedisDb(); - if ($token === false) { - return; - } + foreach ($config->array('database.redis') as $name => $connection) { + if (in_array($name, ['client', 'options', 'clusters'], true) || ! is_array($connection)) { + continue; + } - $database = $this->redisDatabaseForParallelToken($token); + $url = $connection['url'] ?? null; - $this->app->make('config')->set('database.redis.default.database', $database); + // A database in the URL is merged after the raw connection options and would override the worker database. + if ($url !== null && $url !== '') { + throw new RuntimeException( + "Redis connection [{$name}] must use REDIS_HOST and REDIS_PORT during integration tests so each test worker can select an isolated database." + ); + } + + $config->set("database.redis.{$name}.database", $database); + } } /** diff --git a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php index b356abc21..8104ff5be 100644 --- a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php +++ b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php @@ -8,6 +8,8 @@ use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient; use Algolia\AlgoliaSearch\Http\HttpClientInterface; use Algolia\AlgoliaSearch\Http\Psr7\Response; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Foundation\Testing\Concerns\InteractsWithAlgolia; use Hypervel\Foundation\Testing\Concerns\InteractsWithMeilisearch; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; @@ -717,6 +719,23 @@ class RedisOptInHarness public int $flushRedisCalls = 0; + protected Container $app; + + public function __construct() + { + $this->app = new Container; + $this->app->instance('config', new Repository([ + 'database' => [ + 'redis' => [ + 'default' => [ + 'url' => null, + 'database' => 0, + ], + ], + ], + ])); + } + /** * Run Redis setup. */ diff --git a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php index 68a697f1d..15343aa6f 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php @@ -20,6 +20,7 @@ class InteractsWithRedisParallelTest extends TestCase * @var list */ private const REDIS_ENVIRONMENT_KEYS = [ + 'REDIS_HOST', 'REDIS_DB', 'REDIS_TEST_DB_MIN', 'REDIS_TEST_DB_MAX', @@ -252,6 +253,90 @@ public function testParallelTestingTokenCanBeResolvedBeforeTheTestCaseAppIsAssig } } + public function testSequentialSetupNormalizesEveryConfiguredConnectionToTheBaseDatabase(): void + { + $this->setRedisEnvironmentValue('REDIS_HOST', '127.0.0.1'); + $this->setRedisEnvironmentValue('REDIS_DB', '7'); + $config = $this->app->make('config'); + $config->set('database.redis.default.database', 1); + $config->set('database.redis.cache.database', 2); + $config->set('database.redis.queue.database', 3); + + $harness = $this->harness(); + $harness->runSetUp(); + + $this->assertSame(7, $config->integer('database.redis.default.database')); + $this->assertSame(7, $config->integer('database.redis.cache.database')); + $this->assertSame(7, $config->integer('database.redis.queue.database')); + $this->assertSame(1, $harness->flushRedisCalls); + } + + public function testParallelSetupNormalizesEveryConfiguredConnectionToTheWorkerDatabase(): void + { + $this->setRedisEnvironmentValue('REDIS_HOST', '127.0.0.1'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '4'); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '8'); + $this->setParallelTestingToken('3'); + $config = $this->app->make('config'); + + $harness = $this->harness(); + $harness->runSetUp(); + + $this->assertSame(6, $config->integer('database.redis.default.database')); + $this->assertSame(6, $config->integer('database.redis.cache.database')); + $this->assertSame(6, $config->integer('database.redis.session.database')); + $this->assertSame(6, $config->integer('database.redis.queue.database')); + $this->assertSame(6, $config->integer('database.redis.reverb.database')); + } + + public function testSetupIgnoresReservedAndNonConnectionConfiguration(): void + { + $this->setRedisEnvironmentValue('REDIS_HOST', '127.0.0.1'); + $config = $this->app->make('config'); + $config->set('database.redis.client', 'phpredis'); + $config->set('database.redis.clusters', ['enabled' => true]); + $config->set('database.redis.fixture', 'value'); + $options = $config->array('database.redis.options'); + + $this->harness()->runSetUp(); + + $this->assertSame('phpredis', $config->string('database.redis.client')); + $this->assertSame(['enabled' => true], $config->array('database.redis.clusters')); + $this->assertSame('value', $config->string('database.redis.fixture')); + $this->assertSame($options, $config->array('database.redis.options')); + } + + public function testSetupTreatsEmptyConnectionUrlsAsUnset(): void + { + $this->setRedisEnvironmentValue('REDIS_HOST', '127.0.0.1'); + $this->setRedisEnvironmentValue('REDIS_DB', '7'); + $config = $this->app->make('config'); + $config->set('database.redis.cache.url', ''); + $config->set('database.redis.cache.database', 2); + $harness = $this->harness(); + + $harness->runSetUp(); + + $this->assertSame(7, $config->integer('database.redis.cache.database')); + $this->assertSame(1, $harness->flushRedisCalls); + } + + public function testSetupRejectsUrlConfiguredConnections(): void + { + $this->setRedisEnvironmentValue('REDIS_HOST', '127.0.0.1'); + $this->app->make('config')->set('database.redis.cache.url', 'redis://127.0.0.1:6379/4'); + $harness = $this->harness(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis connection [cache] must use REDIS_HOST and REDIS_PORT during integration tests'); + + try { + $harness->runSetUp(); + } finally { + $this->assertSame(0, $harness->flushRedisCalls); + } + } + /** * Get an InteractsWithRedis harness. */ @@ -332,6 +417,8 @@ class InteractsWithRedisHarness { use InteractsWithRedis; + public int $flushRedisCalls = 0; + public function __construct( protected ?ApplicationContract $app = null ) { @@ -370,4 +457,20 @@ public function workerDatabases(): array { return $this->redisWorkerDatabases(); } + + /** + * Run Redis setup. + */ + public function runSetUp(): void + { + $this->setUpInteractsWithRedis(); + } + + /** + * Flush the Redis database. + */ + protected function flushRedis(): void + { + ++$this->flushRedisCalls; + } } From 5540c685dfe991288f4a0c2219ff79cfb55fdef2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:12:27 +0000 Subject: [PATCH 33/41] Fix Queue integration driver lifecycle Adapt QueueTestCase to Hypervel's automatically invoked InteractsWithRedis hooks instead of Laravel-only manual setup calls. Gate Redis setup on the live queue.default value and remember successful setup so teardown remains correct even when a test changes the configured driver. Remove the stale driver snapshot and its subclass synchronization assignments, restoring 121 sync and database tests that were silently skipped without REDIS_HOST. Normalize WorkCommandTest's environment setup order now that the driver is read from live configuration. Add an end-to-end Redis queue worker fixture under the service-specific integration directory so CI exercises the Redis lifecycle branch and proves a queued job is dispatched and processed through the real driver. --- tests/Integration/Queue/DebouncedJobTest.php | 1 - .../Queue/DeleteModelWhenMissingTest.php | 1 - ...DeleteNotificationWhenMissingModelTest.php | 1 - tests/Integration/Queue/JobEncryptionTest.php | 1 - tests/Integration/Queue/QueueTestCase.php | 48 +++++++++---------- .../Queue/Redis/RedisQueueDriverTest.php | 45 +++++++++++++++++ tests/Integration/Queue/UniqueJobTest.php | 1 - .../Queue/UniqueUntilProcessingJobTest.php | 1 - tests/Integration/Queue/WorkCommandTest.php | 4 +- 9 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 tests/Integration/Queue/Redis/RedisQueueDriverTest.php diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php index 8e0167625..fe541e55a 100644 --- a/tests/Integration/Queue/DebouncedJobTest.php +++ b/tests/Integration/Queue/DebouncedJobTest.php @@ -36,7 +36,6 @@ protected function defineEnvironment($app): void $app['config']->set('cache.default', 'database'); $app['config']->set('queue.default', 'database'); - $this->driver = 'database'; } public function testDebouncedJobDispatchesAndExecutes(): void diff --git a/tests/Integration/Queue/DeleteModelWhenMissingTest.php b/tests/Integration/Queue/DeleteModelWhenMissingTest.php index bce534a5a..fdc226258 100644 --- a/tests/Integration/Queue/DeleteModelWhenMissingTest.php +++ b/tests/Integration/Queue/DeleteModelWhenMissingTest.php @@ -24,7 +24,6 @@ protected function defineEnvironment($app): void { parent::defineEnvironment($app); $app['config']->set('queue.default', 'database'); - $this->driver = 'database'; } protected function defineDatabaseMigrationsAfterDatabaseRefreshed(): void diff --git a/tests/Integration/Queue/DeleteNotificationWhenMissingModelTest.php b/tests/Integration/Queue/DeleteNotificationWhenMissingModelTest.php index 8b6029997..763a465df 100644 --- a/tests/Integration/Queue/DeleteNotificationWhenMissingModelTest.php +++ b/tests/Integration/Queue/DeleteNotificationWhenMissingModelTest.php @@ -28,7 +28,6 @@ protected function defineEnvironment($app): void { parent::defineEnvironment($app); $app['config']->set('queue.default', 'database'); - $this->driver = 'database'; } protected function defineDatabaseMigrationsAfterDatabaseRefreshed(): void diff --git a/tests/Integration/Queue/JobEncryptionTest.php b/tests/Integration/Queue/JobEncryptionTest.php index f7a76a6a1..47c5998f4 100644 --- a/tests/Integration/Queue/JobEncryptionTest.php +++ b/tests/Integration/Queue/JobEncryptionTest.php @@ -27,7 +27,6 @@ protected function defineEnvironment($app): void $app['config']->set('app.key', Str::random(32)); $app['config']->set('queue.default', 'database'); - $this->driver = 'database'; } #[Override] diff --git a/tests/Integration/Queue/QueueTestCase.php b/tests/Integration/Queue/QueueTestCase.php index cc0a469b3..de345a0a9 100644 --- a/tests/Integration/Queue/QueueTestCase.php +++ b/tests/Integration/Queue/QueueTestCase.php @@ -7,43 +7,39 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Testbench\TestCase; -use Override; abstract class QueueTestCase extends TestCase { use DatabaseMigrations; - use InteractsWithRedis; + use InteractsWithRedis { + // Hypervel auto-runs these hooks for every subclass, so aliases let this base gate them by queue driver. + setUpInteractsWithRedis as setUpRedis; + tearDownInteractsWithRedis as tearDownRedis; + } - /** - * The current database driver. - */ - protected string $driver; + private bool $redisWasSetUp = false; /** - * Define the test environment. - * @param mixed $app + * Set up Redis when testing the Redis queue driver. */ - protected function defineEnvironment($app): void + protected function setUpInteractsWithRedis(): void { - $this->driver = $app['config']->get('queue.default', 'sync'); + if ($this->getQueueDriver() !== 'redis') { + return; + } + + $this->setUpRedis(); + $this->redisWasSetUp = true; } - #[Override] - protected function setUp(): void + /** + * Tear down Redis when it was set up for this test. + */ + protected function tearDownInteractsWithRedis(): void { - $this->afterApplicationCreated(function () { - if ($this->getQueueDriver() === 'redis') { - $this->setUpRedis(); - } - }); - - $this->beforeApplicationDestroyed(function () { - if ($this->getQueueDriver() === 'redis') { - $this->tearDownRedis(); - } - }); - - parent::setUp(); + if ($this->redisWasSetUp) { + $this->tearDownRedis(); + } } /** @@ -89,6 +85,6 @@ protected function markTestSkippedWhenUsingSyncQueueDriver(): void */ protected function getQueueDriver(): string { - return $this->driver; + return $this->app->make('config')->string('queue.default'); } } diff --git a/tests/Integration/Queue/Redis/RedisQueueDriverTest.php b/tests/Integration/Queue/Redis/RedisQueueDriverTest.php new file mode 100644 index 000000000..529c75cd4 --- /dev/null +++ b/tests/Integration/Queue/Redis/RedisQueueDriverTest.php @@ -0,0 +1,45 @@ +make('config')->set('queue.default', 'redis'); + } + + public function testRedisQueueDriverProcessesAJob(): void + { + RedisQueueDriverJob::$handled = false; + + RedisQueueDriverJob::dispatch(); + $this->runQueueWorkerCommand(['--once' => true]); + + $this->assertTrue(RedisQueueDriverJob::$handled); + } +} + +class RedisQueueDriverJob implements ShouldQueue +{ + use Dispatchable; + use Queueable; + + public static bool $handled = false; + + public function handle(): void + { + static::$handled = true; + } +} diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 96d27cab2..fd11847f4 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -34,7 +34,6 @@ protected function defineEnvironment($app): void $app['config']->set('cache.default', 'database'); $app['config']->set('queue.default', 'database'); - $this->driver = 'database'; } public function testUniqueJobsAreNotDispatched() diff --git a/tests/Integration/Queue/UniqueUntilProcessingJobTest.php b/tests/Integration/Queue/UniqueUntilProcessingJobTest.php index 590741911..7f2bbf5d9 100644 --- a/tests/Integration/Queue/UniqueUntilProcessingJobTest.php +++ b/tests/Integration/Queue/UniqueUntilProcessingJobTest.php @@ -23,7 +23,6 @@ protected function defineEnvironment($app): void parent::defineEnvironment($app); $app['config']->set('queue.default', 'database'); $app['config']->set('cache.default', 'database'); - $this->driver = 'database'; } public function testShouldBeUniqueUntilProcessingReleasesLockWhenJobIsReleasedByAMiddleware() diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index b863c14dc..25520ab70 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -29,9 +29,9 @@ class WorkCommandTest extends QueueTestCase protected function defineEnvironment($app): void { - $app['config']->set('queue.default', 'database'); - parent::defineEnvironment($app); + + $app['config']->set('queue.default', 'database'); } protected function setUp(): void From 6a58f5c7899348c0bbc5b246c4f1e2d043108cb7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:38:53 +0000 Subject: [PATCH 34/41] docs: plan first-class sliding window limits Record the approved weighted two-window algorithm, numeric bounds, retry arithmetic, and per-store state transitions.\n\nDocument the Redis one-key Lua design, schema changes, public API, test matrix, benchmark coverage, and deliberate exclusions so the implementation can be reviewed against one complete specification. --- ...-08-07-1439-sliding-window-rate-limiter.md | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/plans/2026-08-07-1439-sliding-window-rate-limiter.md diff --git a/docs/plans/2026-08-07-1439-sliding-window-rate-limiter.md b/docs/plans/2026-08-07-1439-sliding-window-rate-limiter.md new file mode 100644 index 000000000..de5715f56 --- /dev/null +++ b/docs/plans/2026-08-07-1439-sliding-window-rate-limiter.md @@ -0,0 +1,415 @@ +# Sliding-Window Rate Limiter Plan + +## Status and goal + +Implement a first-class weighted sliding-window admission policy in `hypervel/rate-limiter`. It must work through the existing `consume`, `inspect`, `attempt`, and `clear` APIs and every first-party store. The public API stays Laravel-shaped, while each backend keeps its existing optimized atomic path. + +Base commit: `41ec39db5bc1e62eabb64d686179722386a8ac30` on `feature/rate-limiter`. + +The completed package will offer four distinct behaviors: + +- `Limit`: first-hit fixed window; +- `SlidingWindow`: weighted two-window counter; +- `LeakyBucket`: continuously replenishing GCRA limit; +- `Backoff`: failure-driven capped exponential delay. + +This plan is the complete implementation context for sliding windows. It supersedes the earlier package plan's sliding-window exclusion. The existing package architecture and API remain otherwise unchanged. + +## Final public API + +Add `Hypervel\RateLimiter\SlidingWindow` beside `Limit` and `LeakyBucket`: + +```php +readonly class SlidingWindow extends AdmissionPolicy +{ + public function __construct( + public int $maxAttempts = 60, + public int $windowSeconds = 60, + string $key = '', + int $cost = 1, + bool $global = false, + ?Closure $afterCallback = null, + ?Closure $responseCallback = null, + ); + + public static function perSecond(int $maxAttempts, int $windowSeconds = 1): static; + public static function perMinute(int $maxAttempts, int $windowMinutes = 1): static; + public static function perMinutes(int $windowMinutes, int $maxAttempts): static; + public static function perHour(int $maxAttempts, int $windowHours = 1): static; + public static function perDay(int $maxAttempts, int $windowDays = 1): static; +} +``` + +It inherits `by`, `cost`, `globally`, `after`, and `response`. Every modifier remains immutable. Typical use: + +```php +$limit = SlidingWindow::perMinute(100) + ->cost(5) + ->by('uploads:'.$user->id); + +$result = RateLimiter::store('redis')->consume($limit); +``` + +Do not add manager methods, store config, drivers, events, enums, registries, timers, or a generic algorithm hierarchy. `SlidingWindow` is a typed policy handled by the existing manager, `Limiter`, `KeyResolver`, `Store` contract, and result type. + +## Why this algorithm + +Sliding window is a standard general-purpose alternative to fixed windows. It smooths the fixed-window boundary while keeping constant state and bounded work. Hypervel will use the common weighted two-window estimate: + +```text +estimated = current + floor(previous * remaining_in_current_window / window) +allowed = estimated + cost <= max_attempts +``` + +The window begins on the first accepted operation, matching `Limit` rather than introducing calendar alignment. State lives for two window periods because the current counter becomes the weighted previous counter in the next period. + +Deliberate exclusions: + +- An exact sliding log needs per-operation records, variable memory, and `O(log n)` or worse backend work. +- A segmented ring adds configurable segment counts, larger state, and `O(segments)` rotation for little benefit over this approximation. +- Calendar-aligned two-key Redis designs create a different boundary contract and use multiple keys. +- GCRA already provides continuous smoothing and burst control; sliding window fills the separate need for an intuitive rolling-count approximation. + +## Research checked + +The design was checked against these local sources: + +| Source | Local path | Useful finding | +|---|---|---| +| [Symfony RateLimiter](https://github.com/symfony/rate-limiter) | `examples/symfony/rate-limiter` | Uses current and previous counters, first-hit windows, and a two-window lifetime. Its generic storage/lock layer is not suitable for Hypervel's Redis hot path. | +| [Python `limits`](https://github.com/alisaifee/limits) | `/tmp/hypervel-sliding-window-research.wFjjW4/python-limits` | Confirms weighted counters and TTL-driven rotation; its two Redis keys are unnecessary here. | +| [.NET runtime](https://github.com/dotnet/runtime) | `/tmp/hypervel-sliding-window-research.wFjjW4/dotnet-runtime` | Uses a segmented ring, queues, and timers; this is broader and heavier than Hypervel needs. | +| [Upstash Ratelimit](https://github.com/upstash/ratelimit-js) | `/tmp/hypervel-sliding-window-research.wFjjW4/upstash-ratelimit-js` | Shows a compact atomic Redis implementation, but uses calendar windows and two keys. | +| [Redis rate-limiting JS](https://github.com/redis-developer/redis-ratelimiting-js) | `/tmp/hypervel-sliding-window-research.wFjjW4/redis-ratelimiting-js` | Confirms weighted counters; its exact-log option has per-request sorted-set state and is rejected. | +| [Go sliding window](https://github.com/RussellLuo/slidingwindow) | `/tmp/hypervel-sliding-window-research.wFjjW4/go-slidingwindow` | Confirms the widely used current/previous weighted-counter model. | +| [Laravel sliding-window package](https://github.com/beyondcode/laravel-sliding-window-limiter) | `/tmp/hypervel-sliding-window-research.wFjjW4/laravel-sliding-window-limiter` | Uses variable hash segments and non-atomic read/check/write operations, so it is not a correctness or performance model. | + +The selected Redis representation was also compared directly. Three local raw-script runs measured the two-field `PTTL` form at roughly 30.1k, 32.3k, and 30.6k operations per second, versus 28.6k, 29.4k, and 26.2k for a third timestamp plus `TIME`. This is supporting evidence only; the package benchmark remains the release-facing end-to-end measurement. + +## Numeric contract + +All stores must return identical millisecond-quantized decisions. Redis exposes TTL in milliseconds, so the PHP calculator floors its clock once at the start of sliding-window calculation: + +```php +$now -= $now % 1000; +``` + +Existing fixed-window, GCRA, and backoff precision must not change. + +Use: + +```php +private const int WEIGHT_SCALE = 1_000_000; +``` + +With `remaining` in microseconds and `windowSeconds` in seconds: + +```php +$weight = min(self::WEIGHT_SCALE, intdiv($remaining, $policy->windowSeconds)); +$weightedPrevious = intdiv($previous * $weight, self::WEIGHT_SCALE); +$estimated = $current + $weightedPrevious; +``` + +The weight clamp is required when a backend clock moves backwards and the remaining lifetime exceeds one window. Keep the raw remaining lifetime for `retryAfter`, `resetAfter`, and persisted TTL; only the weight is clamped. + +### Limits and validation + +The scaled multiplication must remain within `AdmissionPolicy::MAX_INTEGER` (`2^53 - 1`) on every backend: + +```text +maximum maxAttempts = floor(9_007_199_254_740_991 / 1_000_000) + = 9_007_199_254 + +maximum windowSeconds = floor(9_007_199_254_740_991 / 2_000_000) + = 4_503_599_627 +``` + +`9_007_199_254 * 1_000_000` leaves 740,991 exact-integer units of headroom. Reject larger values during policy construction. Validate `cost <= maxAttempts` in `Limiter` before key resolution or storage. Validate `now + 2 * window` through the existing time-range check. + +Factories must convert their own public unit directly so error messages name seconds, minutes, hours, or days correctly. Do not route one factory through another when that changes the validation message. + +For `SlidingWindow`, validation covers the full two-window lifetime. The constructor and `perSecond` validate `windowSeconds * 2_000_000` under "window seconds". Minute, hour, and day factories validate these exact multipliers under their own unit names, then recover stored seconds by dividing the validated result by `2_000_000`: + +| Factory | Two-window multiplier | Maximum accepted units | +|---|---:|---:| +| `perSecond` | `2_000_000` | `4_503_599_627` | +| `perMinute` / `perMinutes` | `120_000_000` | `75_059_993` | +| `perHour` | `7_200_000_000` | `1_250_999` | +| `perDay` | `172_800_000_000` | `52_124` | + +```php +$windowMicroseconds = static::multiply($windowMinutes, 120_000_000, 'window minutes'); + +return new static($maxAttempts, intdiv($windowMicroseconds, 2_000_000)); +``` + +`perMinutes` may delegate to `perMinute` because both accept minutes. Do not repeat the seconds check in `perSecond`; its constructor already performs the exact validation with the correct unit. + +Apply the same rule to `Limit`. Its minute/hour/day factories currently validate the unit-to-seconds conversion, then let the constructor's seconds-to-microseconds check report an overflow in "decay seconds" even when the caller supplied another unit. Pre-validate each factory's complete unit-to-microseconds conversion under its public unit name, derive the stored seconds from that exact value, and retain constructor validation for direct construction. `perMinutes` may delegate to `perMinute` because both accept minutes. This is policy construction work, not a limiter hot-path change. + +```php +$decayMicroseconds = static::multiply($decayMinutes, 60_000_000, 'decay minutes'); + +return new static($maxAttempts, intdiv($decayMicroseconds, 1_000_000)); +``` + +## Shared state and schema + +The common PHP stores already persist three integers. Rename the generic second field from the backoff-specific `available_at` / `$availableAt` to `secondary_value` / `$secondaryValue` everywhere owned by the rate-limiter package: + +| Policy | `value` | `secondary_value` | `expires_at` | +|---|---|---|---| +| Fixed window | consumed capacity | `0` | window end | +| Leaky bucket | TAT | `0` | TAT | +| Backoff | failure count | blocked-until timestamp | inactivity expiry | +| Sliding window | current counter | previous counter | end of the following window | + +This affects the shared calculator, database store, Swoole store/table, worker-array store, rate-limiter migration stub, Testbench migration, application skeleton migration, and their tests. Redis backoff keeps the semantic hash field `available_at`; it is not generic shared state. + +This is more than a field rename for fixed windows. Their current shared state duplicates expiry in both `available_at` and `expires_at`. Stop writing that duplicate and require `secondary_value === 0` in `validateFixedWindowState()`. `validateLeakyBucketState()` already requires the second value to be zero and only needs the variable rename. Invert the fixed-window database assertion that currently expects the two old fields to match. + +Hypervel 0.4 has no compatibility burden, so update the existing migrations rather than add a column-rename migration or dual-read path. The final schema is: + +```php +$table->char('key', 32)->primary(); +$table->unsignedBigInteger('value')->default(0); +$table->unsignedBigInteger('secondary_value')->default(0); +$table->unsignedBigInteger('expires_at')->index(); +``` + +Valid sliding state is either all-zero empty state, or `current` in `1..maxAttempts`, `previous` in `0..maxAttempts`, and a positive expiry. A package-written live row never has `current = 0`: inspection and denial do not persist logical rotation, while an accepted rotation writes its positive cost. + +## Shared PHP transition + +Extend `CalculatesRateLimits` and both admission dispatches with `SlidingWindow`. The operation works on local copies; stores persist them only after an accepted consume, preserving inspection and denial immutability. + +State interpretation after expired-state reset: + +```php +$window = $policy->windowSeconds * 1_000_000; +$windowEnd = $expiresAt - $window; + +if ($expiresAt === 0) { + // Empty. +} elseif ($now < $windowEnd) { + $remaining = $windowEnd - $now; +} else { + // Logical rotation only; persist it only if this consume is accepted. + $previous = $current; + $current = 0; + $remaining = $expiresAt - $now; +} +``` + +Transitions: + +- Missing inspect: allowed, full remaining capacity, retry/reset `0`, no state. +- First accepted consume: `current = cost`, `previous = 0`, expiry `now + 2W`, reset `2W`. +- Same-window acceptance: increment current, retain previous and expiry. +- Rotated acceptance: write `current = cost`, `previous = old current`, extend expiry by `W`. +- Denial or inspection: never write the logical rotation or extend expiry. +- Expired state: treat as empty; mutation replaces it only on acceptance. + +For an accepted consume, `remaining()` is `maxAttempts - estimated - cost`. For inspection or denial, it is `max(0, maxAttempts - estimated)`. `resetAfter()` is the raw time until all contributing state expires, so it may be as high as two window periods. On a rotated acceptance, calculate it from the post-write expiry after extending that expiry by `W`; the newly accepted current count remains relevant through the following window. `retryAfter()` is zero when allowed and the minimum millisecond-grid wait for the configured cost when denied. + +### Exact retry calculation + +When `current + cost <= limit` but the weighted previous counter causes denial, set: + +```php +$available = $limit - $current - $cost; +$maximumWeight = intdiv((($available + 1) * self::WEIGHT_SCALE) - 1, $previous); +$maximumRemainingMilliseconds = intdiv( + (($maximumWeight + 1) * $policy->windowSeconds) - 1, + 1000, +); +$retry = (intdiv($remaining, 1000) - $maximumRemainingMilliseconds) * 1000; +``` + +This inverts both integer floors without returning a retry that is one millisecond too early. Call it only when the current weighted value is denied. + +When `current + cost > limit`, admission cannot occur before the current boundary. Add the remaining time to that boundary, rotate `current` into `previous`, then apply the same inverse calculation from a full next window with `available = limit - cost`. This result never exceeds the raw reset time. + +The retry branches have these required preconditions: + +- Weighted denial has `weightedPrevious > available >= 0`, so `weightedPrevious >= 1` and `previous >= 1`; the divisor is non-zero. +- Capacity denial has `current > limit - cost >= 0`, so `current >= 1`; after the boundary, the divisor `previous = current` is non-zero. One boundary hop suffices because `limit - cost >= 0`. +- Capacity denial cannot follow a logical rotation because rotation sets `current = 0`. +- The post-boundary capacity-denial state is still denied at full previous weight: entering the branch proves `current > limit - cost`. Its maximum weight is therefore at most `999_999`, and the second inverse always returns at least one millisecond; do not add an unreachable zero-delay guard. + +The inverse was exhaustively checked over both one- and two-second windows, `previous` values 1 through 8, every smaller available value, and every millisecond position. All 42,060 denied states returned the first admissible millisecond. Keep an aggregate deterministic test for this domain rather than thousands of PHPUnit assertions. + +## Redis transition + +Add one dedicated Lua script and `executeSlidingWindow()`. It must use one physical key, one `evalWithShaCache()` call, one pooled connection checkout, and no cache serializer. Store one hash with only: + +```text +current +previous +``` + +Derive position from `PTTL`; do not store a timestamp or call `TIME`: + +```lua +local ttl = redis.call('PTTL', KEYS[1]) + +if ttl > windowMilliseconds then + remainingMilliseconds = ttl - windowMilliseconds +else + previous = current + current = 0 + remainingMilliseconds = ttl + rotated = true +end + +local weight +if remainingMilliseconds >= windowMilliseconds then + weight = WEIGHT_SCALE +else + weight = math.floor(remainingMilliseconds * 1000 / windowSeconds) +end +``` + +The `remainingMilliseconds >= windowMilliseconds` branch clamps before multiplication, so a clock rollback cannot make a large raw TTL overflow. Do not reject a TTL above two periods; that is a valid conservative clock-rollback state. Return the raw TTL-derived reset and retry values. + +State rules: + +- Both hash fields absent means empty. +- A partial hash, noncanonical integer, live `current = 0`, field outside its policy range, or missing expiry is corrupt and returns an `ERR` reply. +- Check `PTTL == -1` first and report the missing expiry as corrupt; only then treat the remaining `PTTL <= 0` values as logically empty. +- Initial acceptance uses `HSET current cost previous 0` and `PEXPIRE 2W`. +- Same-window acceptance uses `HINCRBY current cost`; Redis retains the TTL. +- Rotated acceptance uses `HSET current cost previous oldCurrent` and `PEXPIRE ttl + W`. +- Inspection and denial perform no write. +- Return the standard five-integer tuple: allowed flag, limit, remaining, retry microseconds, reset microseconds. + +Use only established Redis/Valkey commands: `HMGET`, `HSET`, `HINCRBY`, `PTTL`, `PEXPIRE`, `DEL`, and the existing `EVALSHA`/`EVAL` fallback. No Redis 8 command, server-version branch, raw command, second key, or Redis Function is needed. + +## File changes + +### Package source + +- Add `src/rate-limiter/src/SlidingWindow.php`. +- Correct `Limit` factory overflow validation so minute/hour/day callers receive errors in the units they supplied. +- Extend `Limiter` validation and maximum state duration. +- Add the stable `sliding-window` fingerprint with max attempts, window seconds, and global scope in `KeyResolver`; cost and callbacks remain excluded. +- Add sliding calculation and the `secondaryValue` rename in `Concerns/CalculatesRateLimits.php`. +- Update `DatabaseStore`, `SwooleStore`, `WorkerArrayStore`, and `Swoole/TableManager` for the generic second field and sliding state. +- Add the one-key sliding Lua path to `RedisStore` without changing the existing scripts. +- Update `Console/stubs/rate-limits.stub`. +- Correct the active package plan at `docs/plans/2026-08-04-1543-rate-limiter-package.md`: remove sliding window from its non-goals; add it to the typed-policy list, target layout, state mapping, and final checklist; and change the common Swoole/database column to `secondary_value`. Leave the Redis backoff hash's semantic `available_at` unchanged. + +The `Store` and `PrunableStore` contracts, `LimitResult`, manager, service provider, facade, config, and package dependencies do not change. + +### Framework and skeleton state + +- Update `src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php`. +- Update the existing application skeleton migration at `contrib/hypervel/hypervel/database/migrations/0001_01_01_000008_create_rate_limits_table.php` under that repository's rules. +- Update generator, migration inventory, schema, database-row, Swoole-column, and state-shape assertions. Do not change queue tables whose unrelated `available_at` column is correct. + +### Documentation and PR + +- Update `src/boost/docs/rate-limiting.md` as the sole full guide: + - list sliding windows in the introduction and table of contents; + - add a short comparison table for choosing fixed, sliding, leaky, or backoff behavior; + - document `SlidingWindow` factories, weighted approximation, first-hit anchoring, two-period reset, modifiers, and examples; + - include sliding in result, store, Swoole lifetime, custom-store, and validation descriptions. +- Update the short supported-policy sentences in `routing.md` and `queues.md`. +- Update the package README's `Differences From Laravel` policy list without duplicating the guide. +- Draft an updated PR #480 body in a temporary file. If the prior `/tmp/hypervel-rate-limiter-pr.md` no longer exists, fetch the current body with `gh pr view 480 --json body` and recreate the draft. Add `SlidingWindow` to the policy summary, explain that it is a first-hit-anchored weighted two-window approximation with constant state, and include one concise factory/consume example. Present the draft to the user; publishing it with `gh pr edit --body-file` requires explicit approval. +- Use the simple, direct style of the existing Laravel-ported docs. Do not rewrite untouched Laravel prose. + +### Benchmark + +Extend `tests/Benchmarks/RateLimiter/benchmark.php` and its README: + +- add sliding-window allowed-heavy and denied-heavy rows for Redis, Swoole, and the selected database store; +- keep the current one-client and contended-client shapes; +- do not add backend-specific state injection or a broad parameter matrix. + +The benchmark must continue exercising the manager, pool, driver, Lua/result decoding, and cleanup. Rotation behavior belongs in deterministic store tests rather than a timed setup row. The acceptance target is unchanged: Redis steady state is one checkout and one script; PHP stores use constant integer state and bounded work. + +## Tests + +Run every changed test file immediately after editing it. + +### Policy and dispatch + +- Add `tests/RateLimiter/SlidingWindowTest.php` for every factory, public-unit conversion, immutable modifiers/callbacks, positive input validation, the exact max-attempt ceiling, max+1 rejection, two-window duration bounds, copied fields, and overflow messages naming seconds, minutes, hours, and days correctly. +- Extend `LimitTest` with minute/hour/day overflow messages that name the caller's unit. +- Extend `LimiterTest` for cost/capacity validation before key or store access and full `2W` time-range validation; retain its existing unsupported-policy coverage. +- Extend `KeyResolverTest` with a golden sliding identity and checks that parameters/type affect identity while cost/callbacks do not. + +### Exact calculator + +Add a focused `tests/RateLimiter/SlidingWindowCalculatorTest.php` using a tiny test fixture around `CalculatesRateLimits`. Use `ReflectionProperty` only to inspect `LimitResult`'s existing internal microsecond values; do not widen the production API for tests. + +Cover: + +- missing inspection and first consume; +- same-window weighting and logical rotation; +- immediately before, exactly at, and immediately after a boundary; +- multiple elapsed windows; +- accepted and denied weighted costs, including a rotated denial that leaves stored state unchanged; +- inspection and denial immutability; +- same-bucket TTL retention and accepted-rotation expiry extension; +- truthful remaining capacity and reset up to `2W`; +- both retry branches and first-admissible-millisecond behavior; +- backward clock movement with separate vectors proving the accepted decision changes without the weight clamp and that denied retries/resets retain the raw duration; +- empty, valid, and corrupt state shapes; +- the deterministic exhaustive domain described above; +- high-frequency boundary vectors with previous values 999, 1000, 1001, and 2000; +- exact ceiling/product/headroom vectors. + +### Shared stores and integration + +Extend `RateLimiterStoreContract` so worker-array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL, Redis, and Valkey all prove: + +- weighted sliding admission and denial; +- denial/inspection immutability; +- recovery across a boundary; +- expiry, matching clear, and changed-parameter isolation. + +Add store-specific coverage: + +- Worker array: generic state shape, rotation, expiry, and no suspension-dependent behavior. +- Swoole: `secondary_value` column, rotation, shared-table contention, pruning lifetime, and state validation. +- Database: schema/mapping on all four drivers, rotation within the existing transaction/row lock, server-time behavior, concurrent exact-capacity admission, and pruning. +- Redis unit/integration: one key and exact arguments; no `TIME`; two-field hash; initial/same/rotated TTL; missing inspect; denial immutability, including rotated denial; malformed, partial, noncanonical, no-expiry, and zero-current state; separate clock-rollback vectors proving a clamp-dependent accepted decision and raw denied retry/reset durations without TTL rewrites; prefix, serializer/compression, NOSCRIPT fallback, and concurrent weighted admission. + +Reuse the existing contention harnesses where they already test store atomicity. Do not add duplicate process/coroutine frameworks merely for the new policy. + +Add focused routing and queue integration cases proving named limiters accept `SlidingWindow` without a special middleware path and use its returned retry/remaining values. No framework consumer source change is expected because both already operate on `AdmissionPolicy`; investigate before editing those consumers if a test disproves that contract. + +### Documentation and static checks + +- Verify anchors and links in rate-limiting, routing, and queue docs. +- Search all `src/` and `tests/` references to ensure rate-limiter-owned `available_at` is gone except Redis backoff; unrelated queue columns remain. +- Search every policy dispatch/list to ensure `SlidingWindow` is included. +- Run `git diff --check` in both repositories. + +## Implementation order and verification + +1. Add and test `SlidingWindow` plus Limiter/KeyResolver support, and correct `Limit` factory unit validation. +2. Rename common state to `secondary_value`, update both migrations, and run affected schema/store tests. +3. Add and prove the shared calculator transition. +4. Wire worker-array, Swoole, and database through that transition and run each store's unit/integration tests. +5. Add the optimized Redis script and run unit, Redis, Valkey, and concurrency coverage. +6. Update the shared contract suite and benchmark. +7. Update Boost docs, README, the active package plan, a draft PR body, and policy lists. Do not publish the PR body without explicit approval. +8. Run focused package, routing, queue, database, Redis/Valkey, Swoole, generator, Testbench, and skeleton checks. +9. Run `composer fix` from the components worktree. If it fails, correct the cause and run the failed stage plus every remaining stage in the script. +10. Review every changed caller/callee, stale symbol, hot path, schema, test, and doc. Fix all findings before requesting code review. + +## Completion checklist + +- [ ] `SlidingWindow` has the approved Laravel-style factories and inherited modifiers. +- [ ] All stores implement the same millisecond-quantized weighted algorithm and result semantics. +- [ ] Redis uses one key, one cached Lua call, two hash fields, mature commands, and no `TIME`. +- [ ] Common PHP state and migrations use `secondary_value`; Redis backoff alone retains semantic `available_at`. +- [ ] Fixed-window state no longer duplicates expiry, and all period factories report validation errors in the caller's unit. +- [ ] Cost, time, exact-integer, corruption, rollback, retry, rotation, and concurrency edges are covered. +- [ ] No inspection or denial mutates or extends state. +- [ ] Documentation, both active plans, package README, benchmark, draft PR body, routing, and queue policy lists agree. +- [ ] No compatibility layer, strategy enum, generic algorithm framework, extra driver/config, exact log, segmented ring, event, or timer was added. +- [ ] Targeted tests, integration services, skeleton checks, `composer fix`, stale searches, and `git diff --check` pass. From b1302f67d9a1428970b875f16f9da9dfea05111b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:01 +0000 Subject: [PATCH 35/41] feat(rate-limiter): add sliding window policy Introduce an immutable SlidingWindow policy with the same Laravel-style period factories and modifiers as the existing admission policies. Add stable key fingerprints and validate capacity, cost, exact-integer limits, and the complete two-window lifetime before store access.\n\nAlso make Limit factories validate their complete public-unit conversion so overflow errors consistently name the seconds, minutes, hours, or days supplied by the caller. Cover factories, immutability, key identity, validation order, boundaries, and error messages. --- src/rate-limiter/src/KeyResolver.php | 4 + src/rate-limiter/src/Limit.php | 14 ++- src/rate-limiter/src/Limiter.php | 15 ++++ src/rate-limiter/src/SlidingWindow.php | 110 +++++++++++++++++++++++ tests/RateLimiter/KeyResolverTest.php | 20 +++++ tests/RateLimiter/LimitTest.php | 20 +++++ tests/RateLimiter/LimiterTest.php | 28 ++++++ tests/RateLimiter/SlidingWindowTest.php | 112 ++++++++++++++++++++++++ 8 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 src/rate-limiter/src/SlidingWindow.php create mode 100644 tests/RateLimiter/SlidingWindowTest.php diff --git a/src/rate-limiter/src/KeyResolver.php b/src/rate-limiter/src/KeyResolver.php index 966d9414a..e3c6952a2 100644 --- a/src/rate-limiter/src/KeyResolver.php +++ b/src/rate-limiter/src/KeyResolver.php @@ -70,6 +70,10 @@ protected function policyIdentity(AdmissionPolicy|Backoff $policy): string . $this->segment('max-attempts', (string) $policy->maxAttempts) . $this->segment('decay-seconds', (string) $policy->decaySeconds) . $this->segment('global', $policy->global ? '1' : '0'), + $policy instanceof SlidingWindow => $this->segment('policy', 'sliding-window') + . $this->segment('max-attempts', (string) $policy->maxAttempts) + . $this->segment('window-seconds', (string) $policy->windowSeconds) + . $this->segment('global', $policy->global ? '1' : '0'), $policy instanceof LeakyBucket => $this->segment('policy', 'leaky-bucket') . $this->segment('rate', (string) $policy->rate) . $this->segment('period-microseconds', (string) $policy->periodMicroseconds) diff --git a/src/rate-limiter/src/Limit.php b/src/rate-limiter/src/Limit.php index 147d0125a..b07585d9b 100644 --- a/src/rate-limiter/src/Limit.php +++ b/src/rate-limiter/src/Limit.php @@ -40,7 +40,9 @@ public static function perSecond(int $maxAttempts, int $decaySeconds = 1): stati */ public static function perMinute(int $maxAttempts, int $decayMinutes = 1): static { - return new static($maxAttempts, static::multiply($decayMinutes, 60, 'decay minutes')); + $decayMicroseconds = static::multiply($decayMinutes, 60_000_000, 'decay minutes'); + + return new static($maxAttempts, intdiv($decayMicroseconds, 1_000_000)); } /** @@ -48,7 +50,7 @@ public static function perMinute(int $maxAttempts, int $decayMinutes = 1): stati */ public static function perMinutes(int $decayMinutes, int $maxAttempts): static { - return new static($maxAttempts, static::multiply($decayMinutes, 60, 'decay minutes')); + return static::perMinute($maxAttempts, $decayMinutes); } /** @@ -56,7 +58,9 @@ public static function perMinutes(int $decayMinutes, int $maxAttempts): static */ public static function perHour(int $maxAttempts, int $decayHours = 1): static { - return new static($maxAttempts, static::multiply($decayHours, 3600, 'decay hours')); + $decayMicroseconds = static::multiply($decayHours, 3_600_000_000, 'decay hours'); + + return new static($maxAttempts, intdiv($decayMicroseconds, 1_000_000)); } /** @@ -64,7 +68,9 @@ public static function perHour(int $maxAttempts, int $decayHours = 1): static */ public static function perDay(int $maxAttempts, int $decayDays = 1): static { - return new static($maxAttempts, static::multiply($decayDays, 86400, 'decay days')); + $decayMicroseconds = static::multiply($decayDays, 86_400_000_000, 'decay days'); + + return new static($maxAttempts, intdiv($decayMicroseconds, 1_000_000)); } /** diff --git a/src/rate-limiter/src/Limiter.php b/src/rate-limiter/src/Limiter.php index 7af99085f..0a90fd8b6 100644 --- a/src/rate-limiter/src/Limiter.php +++ b/src/rate-limiter/src/Limiter.php @@ -135,6 +135,7 @@ protected function validateAdmission(AdmissionPolicy $policy): void { $duration = match (true) { $policy instanceof Limit => $this->validateFixedWindow($policy), + $policy instanceof SlidingWindow => $this->validateSlidingWindow($policy), $policy instanceof LeakyBucket => $this->validateLeakyBucket($policy), default => throw new InvalidRateLimitException(sprintf( 'Admission policy [%s] is not supported.', @@ -159,6 +160,20 @@ protected function validateFixedWindow(Limit $policy): int return $policy->decaySeconds * 1_000_000; } + /** + * Validate a sliding-window policy and return its maximum state duration. + */ + protected function validateSlidingWindow(SlidingWindow $policy): int + { + if ($policy->cost > $policy->maxAttempts) { + throw new InvalidRateLimitException( + 'The rate limit cost may not exceed the sliding-window capacity.' + ); + } + + return $policy->windowSeconds * 2_000_000; + } + /** * Validate a leaky-bucket policy and return its maximum state duration. */ diff --git a/src/rate-limiter/src/SlidingWindow.php b/src/rate-limiter/src/SlidingWindow.php new file mode 100644 index 000000000..d82a7b068 --- /dev/null +++ b/src/rate-limiter/src/SlidingWindow.php @@ -0,0 +1,110 @@ + self::MAX_ATTEMPTS) { + throw new InvalidRateLimitException(sprintf( + 'The sliding-window capacity may not exceed %d.', + self::MAX_ATTEMPTS, + )); + } + + // Every store converts the two-window lifetime to microseconds, so + // validate that conversion here. + static::multiply($windowSeconds, 2_000_000, 'window seconds'); + + parent::__construct($key, $cost, $global, $afterCallback, $responseCallback); + } + + /** + * Create a new per-second sliding-window limit. + */ + public static function perSecond(int $maxAttempts, int $windowSeconds = 1): static + { + return new static($maxAttempts, $windowSeconds); + } + + /** + * Create a new per-minute sliding-window limit. + */ + public static function perMinute(int $maxAttempts, int $windowMinutes = 1): static + { + $windowMicroseconds = static::multiply($windowMinutes, 120_000_000, 'window minutes'); + + return new static($maxAttempts, intdiv($windowMicroseconds, 2_000_000)); + } + + /** + * Create a new sliding-window limit using minutes as the window. + */ + public static function perMinutes(int $windowMinutes, int $maxAttempts): static + { + return static::perMinute($maxAttempts, $windowMinutes); + } + + /** + * Create a new per-hour sliding-window limit. + */ + public static function perHour(int $maxAttempts, int $windowHours = 1): static + { + $windowMicroseconds = static::multiply($windowHours, 7_200_000_000, 'window hours'); + + return new static($maxAttempts, intdiv($windowMicroseconds, 2_000_000)); + } + + /** + * Create a new per-day sliding-window limit. + */ + public static function perDay(int $maxAttempts, int $windowDays = 1): static + { + $windowMicroseconds = static::multiply($windowDays, 172_800_000_000, 'window days'); + + return new static($maxAttempts, intdiv($windowMicroseconds, 2_000_000)); + } + + /** + * Create a copy with the given shared policy values. + */ + protected function newInstance( + string $key, + int $cost, + bool $global, + ?Closure $afterCallback, + ?Closure $responseCallback, + ): static { + return new static( + maxAttempts: $this->maxAttempts, + windowSeconds: $this->windowSeconds, + key: $key, + cost: $cost, + global: $global, + afterCallback: $afterCallback, + responseCallback: $responseCallback, + ); + } +} diff --git a/tests/RateLimiter/KeyResolverTest.php b/tests/RateLimiter/KeyResolverTest.php index e6f54183c..2c96563e4 100644 --- a/tests/RateLimiter/KeyResolverTest.php +++ b/tests/RateLimiter/KeyResolverTest.php @@ -8,6 +8,7 @@ use Hypervel\RateLimiter\KeyResolver; use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Tests\TestCase; use Stringable; @@ -26,6 +27,10 @@ public function testCanonicalIdentityHasStableGoldenVectors(): void 'd91bec8237651325e9d9bc0c89d9119b', $resolver->resolve(Limit::perMinute(60)->by('user:1'), 'api'), ); + $this->assertSame( + '28f58bbedbb7a4f75f8e7899ded97686', + $resolver->resolve(SlidingWindow::perMinute(60)->by('user:1'), 'api'), + ); $this->assertSame( '72519c9daf2298e61f4ce018cacde4ac', $resolver->resolve(LeakyBucket::perSecond(100)->burst(200)->by('user:1'), 'api'), @@ -70,6 +75,21 @@ public function testRequestCostAndCallbacksDoNotChangeIdentity(): void $this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited'))); } + public function testSlidingWindowIdentityIncludesStablePolicySettings(): void + { + $resolver = new KeyResolver('app', static fn (): ?string => null); + $policy = SlidingWindow::perMinute(60)->by('user:1'); + $key = $resolver->resolve($policy); + + $this->assertNotSame($key, $resolver->resolve(SlidingWindow::perMinute(61)->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve(SlidingWindow::perMinutes(2, 60)->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve(Limit::perMinute(60)->by('user:1'))); + $this->assertNotSame($key, $resolver->resolve($policy->globally())); + $this->assertSame($key, $resolver->resolve($policy->cost(5))); + $this->assertSame($key, $resolver->resolve($policy->after(static fn (): bool => true))); + $this->assertSame($key, $resolver->resolve($policy->response(static fn (): string => 'limited'))); + } + public function testMissingScopeResolverMatchesAResolverReturningNull(): void { $policy = Limit::perMinute(60)->by('user:1'); diff --git a/tests/RateLimiter/LimitTest.php b/tests/RateLimiter/LimitTest.php index 9ee6ac001..73af735f0 100644 --- a/tests/RateLimiter/LimitTest.php +++ b/tests/RateLimiter/LimitTest.php @@ -8,6 +8,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Unlimited; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class LimitTest extends TestCase { @@ -21,6 +22,25 @@ public function testFactoriesCreateFixedWindowPolicies(): void $this->assertInstanceOf(Unlimited::class, Limit::none()); } + #[DataProvider('decayOverflowProvider')] + public function testFactoryOverflowNamesItsPublicDecayUnit(callable $factory, string $unit): void + { + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage("The rate limit decay {$unit} exceeds the maximum supported duration."); + + $factory(); + } + + public static function decayOverflowProvider(): array + { + return [ + 'seconds' => [static fn () => Limit::perSecond(1, 9_007_199_255), 'seconds'], + 'minutes' => [static fn () => Limit::perMinute(1, 150_119_988), 'minutes'], + 'hours' => [static fn () => Limit::perHour(1, 2_502_000), 'hours'], + 'days' => [static fn () => Limit::perDay(1, 104_250), 'days'], + ]; + } + // REMOVED: Laravel's GlobalLimit constructor coverage is replaced by the // immutable AdmissionPolicy::globally() modifier coverage below. diff --git a/tests/RateLimiter/LimiterTest.php b/tests/RateLimiter/LimiterTest.php index 66f1adfeb..2d32404b8 100644 --- a/tests/RateLimiter/LimiterTest.php +++ b/tests/RateLimiter/LimiterTest.php @@ -15,6 +15,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; use Hypervel\RateLimiter\LimitResult; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\RateLimiter\WorkerArrayStore; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; @@ -47,6 +48,7 @@ public function testCrossFieldValidationHappensBeforeKeyOrStoreAccess(): void foreach ([ Limit::perMinute(1)->cost(2), + SlidingWindow::perMinute(1)->cost(2), LeakyBucket::perSecond(1)->cost(2), ] as $policy) { try { @@ -61,6 +63,32 @@ public function testCrossFieldValidationHappensBeforeKeyOrStoreAccess(): void $this->assertSame(0, $store->calls); } + public function testSlidingWindowValidatesItsFullStateLifetimeBeforeKeyOrStoreAccess(): void + { + $store = new LimiterCountingStore; + $scopeCalls = 0; + $limiter = new Limiter($store, new KeyResolver('app', static function () use (&$scopeCalls): ?string { + ++$scopeCalls; + + return 'scope'; + })); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC( + intdiv(AdmissionPolicy::MAX_INTEGER, 1_000_000) - 90, + )); + + try { + $limiter->consume(SlidingWindow::perMinute(1), 'api'); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } finally { + CarbonImmutable::setTestNow(); + } + + $this->assertSame(0, $scopeCalls); + $this->assertSame(0, $store->calls); + } + public function testTimeRangeValidationHappensBeforeKeyOrStoreAccess(): void { $store = new LimiterCountingStore; diff --git a/tests/RateLimiter/SlidingWindowTest.php b/tests/RateLimiter/SlidingWindowTest.php new file mode 100644 index 000000000..e8af69a0f --- /dev/null +++ b/tests/RateLimiter/SlidingWindowTest.php @@ -0,0 +1,112 @@ +assertPolicy(SlidingWindow::perSecond(maxAttempts: 2, windowSeconds: 3), 2, 3); + $this->assertPolicy(SlidingWindow::perMinute(maxAttempts: 4, windowMinutes: 5), 4, 300); + $this->assertPolicy(SlidingWindow::perMinutes(windowMinutes: 6, maxAttempts: 7), 7, 360); + $this->assertPolicy(SlidingWindow::perHour(maxAttempts: 8, windowHours: 2), 8, 7200); + $this->assertPolicy(SlidingWindow::perDay(maxAttempts: 10, windowDays: 2), 10, 172800); + } + + public function testFactoriesAcceptTheirMaximumSupportedWindow(): void + { + $this->assertSame(4_503_599_627, SlidingWindow::perSecond(1, 4_503_599_627)->windowSeconds); + $this->assertSame(4_503_599_580, SlidingWindow::perMinute(1, 75_059_993)->windowSeconds); + $this->assertSame(4_503_596_400, SlidingWindow::perHour(1, 1_250_999)->windowSeconds); + $this->assertSame(4_503_513_600, SlidingWindow::perDay(1, 52_124)->windowSeconds); + } + + #[DataProvider('windowOverflowProvider')] + public function testFactoryOverflowNamesItsPublicWindowUnit(callable $factory, string $unit): void + { + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage("The rate limit window {$unit} exceeds the maximum supported duration."); + + $factory(); + } + + public static function windowOverflowProvider(): array + { + return [ + 'seconds' => [static fn () => SlidingWindow::perSecond(1, 4_503_599_628), 'seconds'], + 'minutes' => [static fn () => SlidingWindow::perMinute(1, 75_059_994), 'minutes'], + 'hours' => [static fn () => SlidingWindow::perHour(1, 1_251_000), 'hours'], + 'days' => [static fn () => SlidingWindow::perDay(1, 52_125), 'days'], + ]; + } + + public function testFluentModifiersReturnImmutableCopies(): void + { + $after = static fn (): bool => true; + $response = static fn (): string => 'limited'; + $original = SlidingWindow::perMinute(100); + $modified = $original + ->by('uploads') + ->cost(5) + ->globally() + ->after($after) + ->response($response); + + $this->assertNotSame($original, $modified); + $this->assertSame('', $original->key); + $this->assertSame(1, $original->cost); + $this->assertFalse($original->global); + $this->assertNull($original->afterCallback); + $this->assertNull($original->responseCallback); + + $this->assertSame('uploads', $modified->key); + $this->assertSame(5, $modified->cost); + $this->assertTrue($modified->global); + $this->assertSame($after, $modified->afterCallback); + $this->assertSame($response, $modified->responseCallback); + $this->assertSame(100, $modified->maxAttempts); + $this->assertSame(60, $modified->windowSeconds); + } + + public function testMaximumSupportedCapacityIsAccepted(): void + { + $this->assertSame(9_007_199_254, SlidingWindow::perMinute(9_007_199_254)->maxAttempts); + } + + public function testCapacityAboveTheExactIntegerCeilingIsRejected(): void + { + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage('The sliding-window capacity may not exceed 9007199254.'); + + SlidingWindow::perMinute(9_007_199_255); + } + + public function testInvalidScalarValuesAreRejected(): void + { + foreach ([ + static fn () => SlidingWindow::perMinute(0), + static fn () => SlidingWindow::perSecond(1, 0), + static fn () => SlidingWindow::perMinute(1)->cost(0), + ] as $callback) { + try { + $callback(); + $this->fail('Expected an invalid rate limit exception.'); + } catch (InvalidRateLimitException) { + $this->addToAssertionCount(1); + } + } + } + + private function assertPolicy(SlidingWindow $limit, int $attempts, int $seconds): void + { + $this->assertSame($attempts, $limit->maxAttempts); + $this->assertSame($seconds, $limit->windowSeconds); + } +} From e6551c4be53b69e04679865e8599a9d276f0174b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:14 +0000 Subject: [PATCH 36/41] feat(rate-limiter): implement sliding windows in PHP stores Add the constant-state weighted two-window transition to the shared calculator, including millisecond quantization, exact retry inversion, logical rotation, rollback-safe weighting, and immutable inspection and denial paths. Wire worker-array, Swoole, and database stores through the same behavior.\n\nGeneralize the shared second state value to secondary_value across memory, Swoole, database, generated migrations, and Testbench. Stop duplicating fixed-window expiry in that field and add exhaustive arithmetic, corruption, expiry, pruning, rotation, contention, schema, and database-driver coverage. --- .../src/Concerns/CalculatesRateLimits.php | 253 ++++++++++-- .../src/Console/stubs/rate-limits.stub | 2 +- src/rate-limiter/src/DatabaseStore.php | 24 +- src/rate-limiter/src/Swoole/TableManager.php | 2 +- src/rate-limiter/src/SwooleStore.php | 30 +- src/rate-limiter/src/WorkerArrayStore.php | 20 +- ...008_testbench_create_rate_limits_table.php | 2 +- .../RateLimiterTableCommandTest.php | 2 +- .../Database/DatabaseStoreTestCase.php | 56 ++- .../Database/Sqlite/DatabaseStoreTest.php | 5 + tests/RateLimiter/DatabaseStoreTest.php | 18 +- .../SlidingWindowCalculatorTest.php | 368 ++++++++++++++++++ .../SwooleStoreConcurrencyTest.php | 15 +- tests/RateLimiter/SwooleStoreTest.php | 54 ++- tests/RateLimiter/SwooleTableManagerTest.php | 4 +- tests/RateLimiter/WorkerArrayStoreTest.php | 35 +- 16 files changed, 789 insertions(+), 101 deletions(-) create mode 100644 tests/RateLimiter/SlidingWindowCalculatorTest.php diff --git a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php index 9710fc1e4..474922662 100644 --- a/src/rate-limiter/src/Concerns/CalculatesRateLimits.php +++ b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php @@ -11,11 +11,14 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\LimitResult; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Support\CarbonImmutable; use UnexpectedValueException; trait CalculatesRateLimits { + private const int WEIGHT_SCALE = 1_000_000; + /** * Calculate an admission decision and update accepted state values. */ @@ -23,7 +26,7 @@ protected function calculateConsume( AdmissionPolicy $policy, int $now, int &$value, - int &$availableAt, + int &$secondaryValue, int &$expiresAt, ): LimitResult { return match (true) { @@ -31,7 +34,15 @@ protected function calculateConsume( $policy, $now, $value, - $availableAt, + $secondaryValue, + $expiresAt, + true, + ), + $policy instanceof SlidingWindow => $this->calculateSlidingWindow( + $policy, + $now, + $value, + $secondaryValue, $expiresAt, true, ), @@ -39,7 +50,7 @@ protected function calculateConsume( $policy, $now, $value, - $availableAt, + $secondaryValue, $expiresAt, true, ), @@ -59,7 +70,7 @@ protected function calculateInspection( AdmissionPolicy|Backoff $policy, int $now, int $value, - int $availableAt, + int $secondaryValue, int $expiresAt, ): LimitResult|BackoffResult { return match (true) { @@ -67,7 +78,15 @@ protected function calculateInspection( $policy, $now, $value, - $availableAt, + $secondaryValue, + $expiresAt, + false, + ), + $policy instanceof SlidingWindow => $this->calculateSlidingWindow( + $policy, + $now, + $value, + $secondaryValue, $expiresAt, false, ), @@ -75,14 +94,14 @@ protected function calculateInspection( $policy, $now, $value, - $availableAt, + $secondaryValue, $expiresAt, false, ), $policy instanceof Backoff => $this->calculateBackoffInspection( $now, $value, - $availableAt, + $secondaryValue, $expiresAt, ), default => throw new InvalidRateLimitException(sprintf( @@ -99,11 +118,11 @@ protected function calculateFailure( Backoff $backoff, int $now, int &$value, - int &$availableAt, + int &$secondaryValue, int &$expiresAt, ): BackoffResult { - $this->resetExpiredState($now, $value, $availableAt, $expiresAt); - $this->validateBackoffState($value, $availableAt, $expiresAt); + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateBackoffState($value, $secondaryValue, $expiresAt); if ($value >= AdmissionPolicy::MAX_INTEGER) { throw new UnexpectedValueException('The stored backoff failure count cannot be incremented safely.'); @@ -115,7 +134,7 @@ protected function calculateFailure( ? 0 : $this->backoffDelay($backoff, $value - $backoff->after); - $availableAt = $delay === 0 + $secondaryValue = $delay === 0 ? 0 : $this->addExact($now, $this->secondsToMicroseconds($delay)); $expiresAt = $this->addExact($now, $this->secondsToMicroseconds($backoff->resetAfter)); @@ -123,7 +142,7 @@ protected function calculateFailure( return new BackoffResult( $delay === 0, $value, - $delay === 0 ? 0 : $availableAt - $now, + $delay === 0 ? 0 : $secondaryValue - $now, ); } @@ -134,12 +153,12 @@ private function calculateFixedWindow( Limit $policy, int $now, int &$value, - int &$availableAt, + int &$secondaryValue, int &$expiresAt, bool $consume, ): LimitResult { - $this->resetExpiredState($now, $value, $availableAt, $expiresAt); - $this->validateFixedWindowState($policy, $value, $availableAt, $expiresAt); + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateFixedWindowState($policy, $value, $secondaryValue, $expiresAt); if ($expiresAt === 0) { if (! $consume) { @@ -147,7 +166,8 @@ private function calculateFixedWindow( } $value = $policy->cost; - $availableAt = $expiresAt = $this->addExact( + $secondaryValue = 0; + $expiresAt = $this->addExact( $now, $this->secondsToMicroseconds($policy->decaySeconds), ); @@ -187,6 +207,101 @@ private function calculateFixedWindow( ); } + /** + * Calculate a sliding-window decision. + */ + private function calculateSlidingWindow( + SlidingWindow $policy, + int $now, + int &$value, + int &$secondaryValue, + int &$expiresAt, + bool $consume, + ): LimitResult { + $now -= $now % 1000; + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateSlidingWindowState($policy, $value, $secondaryValue, $expiresAt); + + $window = $this->secondsToMicroseconds($policy->windowSeconds); + + if ($expiresAt === 0) { + if (! $consume) { + return new LimitResult(true, $policy->maxAttempts, $policy->maxAttempts, 0, 0); + } + + $value = $policy->cost; + $secondaryValue = 0; + $expiresAt = $this->addExact($now, $this->multiplyExact($window, 2)); + + return new LimitResult( + true, + $policy->maxAttempts, + $policy->maxAttempts - $value, + 0, + $expiresAt - $now, + ); + } + + $current = $value; + $previous = $secondaryValue; + $windowEnd = $expiresAt - $window; + $rotated = $now >= $windowEnd; + + if ($rotated) { + $previous = $current; + $current = 0; + $remaining = $expiresAt - $now; + } else { + $remaining = $windowEnd - $now; + } + + $weight = min(self::WEIGHT_SCALE, intdiv($remaining, $policy->windowSeconds)); + $weightedPrevious = intdiv( + $this->multiplyExact($previous, $weight), + self::WEIGHT_SCALE, + ); + $estimated = $current + $weightedPrevious; + $allowed = $estimated <= $policy->maxAttempts - $policy->cost; + $resetAfter = $expiresAt - $now; + + if (! $allowed) { + return new LimitResult( + false, + $policy->maxAttempts, + max(0, $policy->maxAttempts - $estimated), + $this->slidingWindowRetryAfter($policy, $current, $previous, $remaining), + $resetAfter, + ); + } + + if (! $consume) { + return new LimitResult( + true, + $policy->maxAttempts, + $policy->maxAttempts - $estimated, + 0, + $resetAfter, + ); + } + + if ($rotated) { + $value = $policy->cost; + $secondaryValue = $previous; + $expiresAt = $this->addExact($expiresAt, $window); + $resetAfter = $expiresAt - $now; + } else { + $value = $current + $policy->cost; + } + + return new LimitResult( + true, + $policy->maxAttempts, + $policy->maxAttempts - $estimated - $policy->cost, + 0, + $resetAfter, + ); + } + /** * Calculate a leaky-bucket decision. */ @@ -194,12 +309,12 @@ private function calculateLeakyBucket( LeakyBucket $policy, int $now, int &$value, - int &$availableAt, + int &$secondaryValue, int &$expiresAt, bool $consume, ): LimitResult { - $this->resetExpiredState($now, $value, $availableAt, $expiresAt); - $this->validateLeakyBucketState($value, $availableAt, $expiresAt); + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateLeakyBucketState($value, $secondaryValue, $expiresAt); $emission = intdiv($policy->periodMicroseconds, $policy->rate) + ($policy->periodMicroseconds % $policy->rate === 0 ? 0 : 1); @@ -233,7 +348,7 @@ private function calculateLeakyBucket( } $value = $candidateTat; - $availableAt = 0; + $secondaryValue = 0; $expiresAt = $candidateTat; return new LimitResult( @@ -251,13 +366,13 @@ private function calculateLeakyBucket( private function calculateBackoffInspection( int $now, int $value, - int $availableAt, + int $secondaryValue, int $expiresAt, ): BackoffResult { - $this->resetExpiredState($now, $value, $availableAt, $expiresAt); - $this->validateBackoffState($value, $availableAt, $expiresAt); + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateBackoffState($value, $secondaryValue, $expiresAt); - $retryAfter = max($availableAt - $now, 0); + $retryAfter = max($secondaryValue - $now, 0); return new BackoffResult($retryAfter === 0, $value, $retryAfter); } @@ -276,6 +391,55 @@ private function remainingCapacity( return min($burst, max(0, intdiv($fullTat - $effectiveTat, $emission))); } + /** + * Calculate the first millisecond at which a sliding-window cost fits. + */ + private function slidingWindowRetryAfter( + SlidingWindow $policy, + int $current, + int $previous, + int $remaining, + ): int { + $available = $policy->maxAttempts - $current - $policy->cost; + + if ($available >= 0) { + return $this->weightedSlidingWindowRetryAfter( + $previous, + $available, + $remaining, + $policy->windowSeconds, + ); + } + + return $remaining + $this->weightedSlidingWindowRetryAfter( + $current, + $policy->maxAttempts - $policy->cost, + $this->secondsToMicroseconds($policy->windowSeconds), + $policy->windowSeconds, + ); + } + + /** + * Invert the weighted counter's integer floors. + */ + private function weightedSlidingWindowRetryAfter( + int $previous, + int $available, + int $remaining, + int $windowSeconds, + ): int { + $maximumWeight = intdiv( + $this->multiplyExact($available + 1, self::WEIGHT_SCALE) - 1, + $previous, + ); + $maximumRemainingMilliseconds = intdiv( + $this->multiplyExact($maximumWeight + 1, $windowSeconds) - 1, + 1000, + ); + + return (intdiv($remaining, 1000) - $maximumRemainingMilliseconds) * 1000; + } + /** * Calculate a capped exponential delay without overflowing. */ @@ -299,12 +463,12 @@ private function backoffDelay(Backoff $backoff, int $doublings): int private function resetExpiredState( int $now, int &$value, - int &$availableAt, + int &$secondaryValue, int &$expiresAt, ): void { if ($expiresAt !== 0 && $expiresAt <= $now) { $value = 0; - $availableAt = 0; + $secondaryValue = 0; $expiresAt = 0; } } @@ -315,23 +479,42 @@ private function resetExpiredState( private function validateFixedWindowState( Limit $policy, int $value, - int $availableAt, + int $secondaryValue, int $expiresAt, ): void { if ($value < 0 || $value > $policy->maxAttempts - || $availableAt < 0 || $expiresAt < 0 - || $availableAt !== $expiresAt + || $secondaryValue !== 0 || $expiresAt < 0 || ($expiresAt === 0 && $value !== 0)) { throw new UnexpectedValueException('The stored fixed-window rate limiter state is invalid.'); } } + /** + * Validate sliding-window state loaded from a store. + */ + private function validateSlidingWindowState( + SlidingWindow $policy, + int $value, + int $secondaryValue, + int $expiresAt, + ): void { + if ($value === 0 && $secondaryValue === 0 && $expiresAt === 0) { + return; + } + + if ($value < 1 || $value > $policy->maxAttempts + || $secondaryValue < 0 || $secondaryValue > $policy->maxAttempts + || $expiresAt < 1 || $expiresAt > AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException('The stored sliding-window rate limiter state is invalid.'); + } + } + /** * Validate leaky-bucket state loaded from a store. */ - private function validateLeakyBucketState(int $value, int $availableAt, int $expiresAt): void + private function validateLeakyBucketState(int $value, int $secondaryValue, int $expiresAt): void { - if ($value < 0 || $availableAt !== 0 || $expiresAt < 0 || $value !== $expiresAt) { + if ($value < 0 || $secondaryValue !== 0 || $expiresAt < 0 || $value !== $expiresAt) { throw new UnexpectedValueException('The stored leaky-bucket rate limiter state is invalid.'); } } @@ -339,12 +522,12 @@ private function validateLeakyBucketState(int $value, int $availableAt, int $exp /** * Validate backoff state loaded from a store. */ - private function validateBackoffState(int $value, int $availableAt, int $expiresAt): void + private function validateBackoffState(int $value, int $secondaryValue, int $expiresAt): void { - if ($value < 0 || $availableAt < 0 || $expiresAt < 0 - || ($value === 0 && ($availableAt !== 0 || $expiresAt !== 0)) + if ($value < 0 || $secondaryValue < 0 || $expiresAt < 0 + || ($value === 0 && ($secondaryValue !== 0 || $expiresAt !== 0)) || ($value !== 0 && $expiresAt === 0) - || $availableAt > $expiresAt) { + || $secondaryValue > $expiresAt) { throw new UnexpectedValueException('The stored backoff rate limiter state is invalid.'); } } diff --git a/src/rate-limiter/src/Console/stubs/rate-limits.stub b/src/rate-limiter/src/Console/stubs/rate-limits.stub index f2918afb5..e2809cc4e 100644 --- a/src/rate-limiter/src/Console/stubs/rate-limits.stub +++ b/src/rate-limiter/src/Console/stubs/rate-limits.stub @@ -16,7 +16,7 @@ return new class extends Migration Schema::create('{{table}}', function (Blueprint $table) { $table->char('key', 32)->primary(); $table->unsignedBigInteger('value')->default(0); - $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('secondary_value')->default(0); $table->unsignedBigInteger('expires_at')->index(); }); } diff --git a/src/rate-limiter/src/DatabaseStore.php b/src/rate-limiter/src/DatabaseStore.php index dc527640e..eb5481876 100644 --- a/src/rate-limiter/src/DatabaseStore.php +++ b/src/rate-limiter/src/DatabaseStore.php @@ -38,17 +38,17 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult $this->ensureOutsideTransaction($connection); return $connection->transaction(function (ConnectionInterface $connection) use ($key, $policy): LimitResult { - [$value, $availableAt, $expiresAt] = $this->stateForUpdate($connection, $key); + [$value, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); $result = $this->calculateConsume( $policy, $this->currentDatabaseTimeInMicroseconds($connection), $value, - $availableAt, + $secondaryValue, $expiresAt, ); if ($result->allowed()) { - $this->writeState($connection, $key, $value, $availableAt, $expiresAt); + $this->writeState($connection, $key, $value, $secondaryValue, $expiresAt); } return $result; @@ -67,7 +67,7 @@ public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResu ->useWritePdo() ->where('key', $key) ->first(); - [$value, $availableAt, $expiresAt] = $row === null + [$value, $secondaryValue, $expiresAt] = $row === null ? [0, 0, 0] : $this->stateFromRow($row); @@ -75,7 +75,7 @@ public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResu $policy, $this->currentDatabaseTimeInMicroseconds($connection), $value, - $availableAt, + $secondaryValue, $expiresAt, ); } @@ -89,16 +89,16 @@ public function recordFailure(string $key, Backoff $backoff): BackoffResult $this->ensureOutsideTransaction($connection); return $connection->transaction(function (ConnectionInterface $connection) use ($key, $backoff): BackoffResult { - [$value, $availableAt, $expiresAt] = $this->stateForUpdate($connection, $key); + [$value, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); $result = $this->calculateFailure( $backoff, $this->currentDatabaseTimeInMicroseconds($connection), $value, - $availableAt, + $secondaryValue, $expiresAt, ); - $this->writeState($connection, $key, $value, $availableAt, $expiresAt); + $this->writeState($connection, $key, $value, $secondaryValue, $expiresAt); return $result; }, attempts: 3); @@ -171,7 +171,7 @@ protected function insertStateRow(ConnectionInterface $connection, string $key): $connection->table($this->table)->insertOrIgnore([ 'key' => $key, 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]); } @@ -235,7 +235,7 @@ protected function stateFromRow(object $row): array { return [ $this->integerValue($row->value ?? null, 'value'), - $this->integerValue($row->available_at ?? null, 'available_at'), + $this->integerValue($row->secondary_value ?? null, 'secondary_value'), $this->integerValue($row->expires_at ?? null, 'expires_at'), ]; } @@ -247,14 +247,14 @@ protected function writeState( ConnectionInterface $connection, string $key, int $value, - int $availableAt, + int $secondaryValue, int $expiresAt, ): void { $connection->table($this->table) ->where('key', $key) ->update([ 'value' => $value, - 'available_at' => $availableAt, + 'secondary_value' => $secondaryValue, 'expires_at' => $expiresAt, ]); } diff --git a/src/rate-limiter/src/Swoole/TableManager.php b/src/rate-limiter/src/Swoole/TableManager.php index 8803048c5..fbe1d8f8d 100644 --- a/src/rate-limiter/src/Swoole/TableManager.php +++ b/src/rate-limiter/src/Swoole/TableManager.php @@ -84,7 +84,7 @@ protected function resolve(string $name): TableState $table = new Table($rows, $conflictProportion); $table->column('value', Table::TYPE_INT, 8); - $table->column('available_at', Table::TYPE_INT, 8); + $table->column('secondary_value', Table::TYPE_INT, 8); $table->column('expires_at', Table::TYPE_INT, 8); if (! $table->create()) { diff --git a/src/rate-limiter/src/SwooleStore.php b/src/rate-limiter/src/SwooleStore.php index a16df8630..65e710ec7 100644 --- a/src/rate-limiter/src/SwooleStore.php +++ b/src/rate-limiter/src/SwooleStore.php @@ -39,13 +39,13 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult for ($attempt = 0; $attempt < 2; ++$attempt) { /** @var array{LimitResult, bool} $outcome */ $outcome = $this->state->withLock($key, function () use ($key, $policy): array { - [$value, $availableAt, $expiresAt] = $this->storedState($key); + [$value, $secondaryValue, $expiresAt] = $this->storedState($key); $result = $this->calculateConsume( $policy, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); @@ -55,7 +55,7 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult return [ $result, - $this->writeState($key, $value, $availableAt, $expiresAt), + $this->writeState($key, $value, $secondaryValue, $expiresAt), ]; }); @@ -83,13 +83,13 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult { return $this->state->withLock($key, function () use ($key, $policy): LimitResult|BackoffResult { - [$value, $availableAt, $expiresAt] = $this->storedState($key); + [$value, $secondaryValue, $expiresAt] = $this->storedState($key); return $this->calculateInspection( $policy, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); }); @@ -103,19 +103,19 @@ public function recordFailure(string $key, Backoff $backoff): BackoffResult for ($attempt = 0; $attempt < 2; ++$attempt) { /** @var array{BackoffResult, bool} $outcome */ $outcome = $this->state->withLock($key, function () use ($key, $backoff): array { - [$value, $availableAt, $expiresAt] = $this->storedState($key); + [$value, $secondaryValue, $expiresAt] = $this->storedState($key); $result = $this->calculateFailure( $backoff, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); return [ $result, - $this->writeState($key, $value, $availableAt, $expiresAt), + $this->writeState($key, $value, $secondaryValue, $expiresAt), ]; }); @@ -207,28 +207,28 @@ protected function storedState(string $key): array } $value = $row['value'] ?? null; - $availableAt = $row['available_at'] ?? null; + $secondaryValue = $row['secondary_value'] ?? null; $expiresAt = $row['expires_at'] ?? null; - if (! is_int($value) || ! is_int($availableAt) || ! is_int($expiresAt) - || $value < 0 || $availableAt < 0 || $expiresAt < 0 + if (! is_int($value) || ! is_int($secondaryValue) || ! is_int($expiresAt) + || $value < 0 || $secondaryValue < 0 || $expiresAt < 0 || $value > AdmissionPolicy::MAX_INTEGER - || $availableAt > AdmissionPolicy::MAX_INTEGER + || $secondaryValue > AdmissionPolicy::MAX_INTEGER || $expiresAt > AdmissionPolicy::MAX_INTEGER) { throw new UnexpectedValueException('The stored Swoole rate limiter state is invalid.'); } - return [$value, $availableAt, $expiresAt]; + return [$value, $secondaryValue, $expiresAt]; } /** * Write numeric state for a physical limiter key. */ - protected function writeState(string $key, int $value, int $availableAt, int $expiresAt): bool + protected function writeState(string $key, int $value, int $secondaryValue, int $expiresAt): bool { return $this->state->table()->set($key, [ 'value' => $value, - 'available_at' => $availableAt, + 'secondary_value' => $secondaryValue, 'expires_at' => $expiresAt, ]); } diff --git a/src/rate-limiter/src/WorkerArrayStore.php b/src/rate-limiter/src/WorkerArrayStore.php index 8920541a1..540b1f704 100644 --- a/src/rate-limiter/src/WorkerArrayStore.php +++ b/src/rate-limiter/src/WorkerArrayStore.php @@ -20,7 +20,7 @@ class WorkerArrayStore implements Store /** * The numeric rate limiter state held for this worker's lifetime. * - * @var array + * @var array */ protected array $states = []; @@ -29,20 +29,20 @@ class WorkerArrayStore implements Store */ public function consume(string $key, AdmissionPolicy $policy): LimitResult { - [$value, $availableAt, $expiresAt] = $this->state($key); + [$value, $secondaryValue, $expiresAt] = $this->state($key); $result = $this->calculateConsume( $policy, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); if ($result->allowed()) { $this->states[$key] = [ 'value' => $value, - 'available_at' => $availableAt, + 'secondary_value' => $secondaryValue, 'expires_at' => $expiresAt, ]; } @@ -57,13 +57,13 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult */ public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult { - [$value, $availableAt, $expiresAt] = $this->state($key); + [$value, $secondaryValue, $expiresAt] = $this->state($key); return $this->calculateInspection( $policy, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); } @@ -73,19 +73,19 @@ public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResu */ public function recordFailure(string $key, Backoff $backoff): BackoffResult { - [$value, $availableAt, $expiresAt] = $this->state($key); + [$value, $secondaryValue, $expiresAt] = $this->state($key); $result = $this->calculateFailure( $backoff, $this->currentTimeInMicroseconds(), $value, - $availableAt, + $secondaryValue, $expiresAt, ); $this->states[$key] = [ 'value' => $value, - 'available_at' => $availableAt, + 'secondary_value' => $secondaryValue, 'expires_at' => $expiresAt, ]; @@ -117,6 +117,6 @@ protected function state(string $key): array return $state === null ? [0, 0, 0] - : [$state['value'], $state['available_at'], $state['expires_at']]; + : [$state['value'], $state['secondary_value'], $state['expires_at']]; } } diff --git a/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php b/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php index 75642986e..e3b51f29e 100644 --- a/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php +++ b/src/testbench/hypervel/migrations/0001_01_01_000008_testbench_create_rate_limits_table.php @@ -15,7 +15,7 @@ public function up(): void Schema::create('rate_limits', function (Blueprint $table) { $table->char('key', 32)->primary(); $table->unsignedBigInteger('value')->default(0); - $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('secondary_value')->default(0); $table->unsignedBigInteger('expires_at')->index(); }); } diff --git a/tests/Integration/Generators/RateLimiterTableCommandTest.php b/tests/Integration/Generators/RateLimiterTableCommandTest.php index 65a6951ea..689bd1fae 100644 --- a/tests/Integration/Generators/RateLimiterTableCommandTest.php +++ b/tests/Integration/Generators/RateLimiterTableCommandTest.php @@ -22,7 +22,7 @@ public function testCreateMakesTheConfiguredRateLimiterMigration(): void "Schema::create('rate_limits', function (Blueprint \$table) {", "\$table->char('key', 32)->primary();", "\$table->unsignedBigInteger('value')->default(0);", - "\$table->unsignedBigInteger('available_at')->default(0);", + "\$table->unsignedBigInteger('secondary_value')->default(0);", "\$table->unsignedBigInteger('expires_at')->index();", "Schema::dropIfExists('rate_limits');", ], 'create_rate_limits_table.php'); diff --git a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php index a535a1a11..fbc352a6c 100644 --- a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php +++ b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php @@ -15,6 +15,7 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Integration\Database\DatabaseTestCase; @@ -59,7 +60,8 @@ public function testFixedWindowOperationsUseNumericDatabaseState(): void $this->assertTrue($denied->denied()); $this->assertSame(0, $denied->remaining()); $this->assertSame(2, (int) $row->value); - $this->assertSame((int) $row->available_at, (int) $row->expires_at); + $this->assertSame(0, (int) $row->secondary_value); + $this->assertGreaterThan(0, (int) $row->expires_at); } public function testInspectingMissingStateDoesNotCreateARow(): void @@ -75,6 +77,29 @@ public function testInspectingMissingStateDoesNotCreateARow(): void $this->assertFalse(DB::table('rate_limits')->where('key', $key)->exists()); } + public function testSlidingWindowRotatesWithinTheDatabaseTransaction(): void + { + $store = $this->store(); + $key = str_repeat('s', 32); + $policy = SlidingWindow::perSecond(10)->cost(4); + + $this->assertTrue($store->consume($key, $policy)->allowed()); + $initial = DB::table('rate_limits')->where('key', $key)->first(); + $this->assertSame(4, (int) $initial->value); + $this->assertSame(0, (int) $initial->secondary_value); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $store->inspect($key, $policy)->resetAfter() <= 1, + 1, + ); + + $this->assertTrue($store->consume($key, $policy->cost(3))->allowed()); + $rotated = DB::table('rate_limits')->where('key', $key)->first(); + $this->assertSame(3, (int) $rotated->value); + $this->assertSame(4, (int) $rotated->secondary_value); + $this->assertGreaterThan((int) $initial->expires_at, (int) $rotated->expires_at); + } + public function testLeakyBucketAndBackoffUseTheSharedCalculator(): void { $store = $this->store(); @@ -120,7 +145,7 @@ public function testConfiguredTableUsesTheConnectionPrefix(): void Schema::create('custom_rate_limits', function (Blueprint $table): void { $table->char('key', 32)->primary(); $table->unsignedBigInteger('value')->default(0); - $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('secondary_value')->default(0); $table->unsignedBigInteger('expires_at')->index(); }); @@ -147,7 +172,7 @@ public function testPrunesExpiredStateInBoundedBatches(): void DB::table('rate_limits')->insert([ 'key' => str_pad("expired{$index}", 32, 'x'), 'value' => 1, - 'available_at' => 1, + 'secondary_value' => 0, 'expires_at' => 1, ]); } @@ -155,7 +180,7 @@ public function testPrunesExpiredStateInBoundedBatches(): void DB::table('rate_limits')->insert([ 'key' => str_repeat('l', 32), 'value' => 1, - 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'secondary_value' => 0, 'expires_at' => AdmissionPolicy::MAX_INTEGER, ]); @@ -169,7 +194,7 @@ public function testPruningDoesNotDeleteStateRenewedAfterSelection(): void DB::table('rate_limits')->insert([ 'key' => $key, 'value' => 1, - 'available_at' => 1, + 'secondary_value' => 0, 'expires_at' => 1, ]); $renewed = false; @@ -184,7 +209,6 @@ public function testPruningDoesNotDeleteStateRenewedAfterSelection(): void $renewed = true; DB::table('rate_limits')->where('key', $key)->update([ - 'available_at' => AdmissionPolicy::MAX_INTEGER, 'expires_at' => AdmissionPolicy::MAX_INTEGER, ]); }); @@ -203,7 +227,7 @@ public function testCorruptStateFailsClosed(): void DB::table('rate_limits')->insert([ 'key' => $key, 'value' => 1, - 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'secondary_value' => AdmissionPolicy::MAX_INTEGER, 'expires_at' => AdmissionPolicy::MAX_INTEGER - 1, ]); @@ -229,6 +253,24 @@ public function testConcurrentFirstUseAdmitsExactlyTheConfiguredCapacity(): void $this->assertSame(5, (int) DB::table('rate_limits')->where('key', $key)->value('value')); } + public function testConcurrentSlidingWindowFirstUseAdmitsExactlyTheConfiguredCapacity(): void + { + $store = $this->store(); + $key = str_repeat('j', 32); + $policy = SlidingWindow::perMinute(5); + $operations = []; + + for ($index = 0; $index < 10; ++$index) { + $operations[] = static fn (): bool => $store->consume($key, $policy)->allowed(); + } + + $results = parallel($operations); + + $this->assertSame(5, count(array_filter($results))); + $this->assertSame(5, (int) DB::table('rate_limits')->where('key', $key)->value('value')); + $this->assertSame(0, (int) DB::table('rate_limits')->where('key', $key)->value('secondary_value')); + } + public function testConcurrentExistingStateDoesNotLoseUpdates(): void { $store = $this->store(); diff --git a/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php b/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php index 8693d027e..e81c08a08 100644 --- a/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php +++ b/tests/Integration/RateLimiter/Database/Sqlite/DatabaseStoreTest.php @@ -51,6 +51,11 @@ public function testConcurrentExistingStateDoesNotLoseUpdates(): void $this->markTestSkipped('Requires the Swoole AIO scheduler fix from PR #6140.'); } + public function testConcurrentSlidingWindowFirstUseAdmitsExactlyTheConfiguredCapacity(): void + { + $this->markTestSkipped('Requires the Swoole AIO scheduler fix from PR #6140.'); + } + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); diff --git a/tests/RateLimiter/DatabaseStoreTest.php b/tests/RateLimiter/DatabaseStoreTest.php index c404b2529..ab9be8b7a 100644 --- a/tests/RateLimiter/DatabaseStoreTest.php +++ b/tests/RateLimiter/DatabaseStoreTest.php @@ -78,7 +78,7 @@ public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithout return (object) [ 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]; }); @@ -99,7 +99,7 @@ public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithout ->once() ->with([ 'value' => 1, - 'available_at' => 61_000_000, + 'secondary_value' => 0, 'expires_at' => 61_000_000, ]) ->andReturnUsing(static function () use (&$operations): int { @@ -155,7 +155,7 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock ->with([ 'key' => 'physical-key', 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]) ->andReturnUsing(static function () use (&$operations): int { @@ -170,7 +170,7 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock return (object) [ 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]; }); @@ -191,7 +191,7 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock ->once() ->with([ 'value' => 1, - 'available_at' => 61_000_000, + 'secondary_value' => 0, 'expires_at' => 61_000_000, ]) ->andReturnUsing(static function () use (&$operations): int { @@ -242,7 +242,7 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void ->with([ 'key' => 'physical-key', 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]) ->andReturnUsing(static function () use (&$operations): int { @@ -257,7 +257,7 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void return (object) [ 'value' => 0, - 'available_at' => 0, + 'secondary_value' => 0, 'expires_at' => 0, ]; }); @@ -268,8 +268,8 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void $operations[] = 'update'; return $state['value'] === 1 - && $state['available_at'] > 60_000_000 - && $state['expires_at'] === $state['available_at']; + && $state['secondary_value'] === 0 + && $state['expires_at'] > 60_000_000; })) ->andReturn(1); diff --git a/tests/RateLimiter/SlidingWindowCalculatorTest.php b/tests/RateLimiter/SlidingWindowCalculatorTest.php new file mode 100644 index 000000000..9dad8c027 --- /dev/null +++ b/tests/RateLimiter/SlidingWindowCalculatorTest.php @@ -0,0 +1,368 @@ +cost(2); + $state = [0, 0, 0]; + + [$inspection, $inspectedState] = $calculator->inspect($policy, 10_000_999, $state); + + $this->assertTrue($inspection->allowed()); + $this->assertSame(10, $inspection->remaining()); + $this->assertSame(0, $this->retryMicroseconds($inspection)); + $this->assertSame(0, $this->resetMicroseconds($inspection)); + $this->assertSame($state, $inspectedState); + + [$result, $consumedState] = $calculator->consume($policy, 10_000_999, $state); + + $this->assertTrue($result->allowed()); + $this->assertSame(8, $result->remaining()); + $this->assertSame(4_000_000, $this->resetMicroseconds($result)); + $this->assertSame([2, 0, 14_000_000], $consumedState); + } + + public function testSameWindowConsumptionRetainsExpiry(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(10); + $state = [2, 4, 11_500_000]; + + [$inspection, $inspectedState] = $calculator->inspect($policy, 10_000_000, $state); + [$result, $consumedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($inspection->allowed()); + $this->assertSame(6, $inspection->remaining()); + $this->assertSame($state, $inspectedState); + $this->assertTrue($result->allowed()); + $this->assertSame(5, $result->remaining()); + $this->assertSame([3, 4, 11_500_000], $consumedState); + $this->assertSame(1_500_000, $this->resetMicroseconds($result)); + } + + public function testAcceptedLogicalRotationExtendsExpiry(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(10)->cost(2); + $state = [4, 2, 12_000_000]; + + [$inspection, $inspectedState] = $calculator->inspect($policy, 11_000_000, $state); + [$result, $consumedState] = $calculator->consume($policy, 11_000_000, $state); + + $this->assertTrue($inspection->allowed()); + $this->assertSame(6, $inspection->remaining()); + $this->assertSame($state, $inspectedState); + $this->assertTrue($result->allowed()); + $this->assertSame(4, $result->remaining()); + $this->assertSame([2, 4, 13_000_000], $consumedState); + $this->assertSame(2_000_000, $this->resetMicroseconds($result)); + } + + public function testRotatedDenialKeepsStoredStateUnchanged(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(10)->cost(7); + $state = [8, 2, 10_500_000]; + + [$result, $returnedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($result->denied()); + $this->assertSame(6, $result->remaining()); + $this->assertSame(1000, $this->retryMicroseconds($result)); + $this->assertSame(500_000, $this->resetMicroseconds($result)); + $this->assertSame($state, $returnedState); + } + + #[DataProvider('boundaryProvider')] + public function testBoundaryPositionsUseMillisecondQuantization(int $now, int $remaining): void + { + $calculator = new SlidingWindowCalculator; + $state = [4, 2, 12_000_000]; + + [$result, $returnedState] = $calculator->inspect( + SlidingWindow::perSecond(10), + $now, + $state, + ); + + $this->assertTrue($result->allowed()); + $this->assertSame($remaining, $result->remaining()); + $this->assertSame($state, $returnedState); + } + + public static function boundaryProvider(): array + { + return [ + 'immediately before' => [10_999_000, 6], + 'exactly at' => [11_000_000, 6], + 'immediately after' => [11_001_000, 7], + ]; + } + + public function testExpiredStateIsLogicallyEmptyWithoutMutatingInspection(): void + { + $calculator = new SlidingWindowCalculator; + $state = [4, 2, 12_000_000]; + + [$result, $returnedState] = $calculator->inspect( + SlidingWindow::perSecond(10), + 14_000_000, + $state, + ); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + $this->assertSame(0, $this->resetMicroseconds($result)); + $this->assertSame($state, $returnedState); + } + + public function testWeightedDenialReportsTheFirstAdmissibleMillisecond(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(8)->cost(2); + $state = [2, 8, 11_625_000]; + + [$result, $returnedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($result->denied()); + $this->assertSame(1, $result->remaining()); + $this->assertSame(1000, $this->retryMicroseconds($result)); + $this->assertSame(1_625_000, $this->resetMicroseconds($result)); + $this->assertSame($state, $returnedState); + } + + public function testCapacityDenialWaitsForTheBoundaryAndPreviousWeight(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(10)->cost(3); + $state = [8, 0, 11_500_000]; + + [$result, $returnedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($result->denied()); + $this->assertSame(2, $result->remaining()); + $this->assertSame(501_000, $this->retryMicroseconds($result)); + $this->assertSame(1_500_000, $this->resetMicroseconds($result)); + $this->assertSame($state, $returnedState); + } + + public function testBackwardClockMovementKeepsRawDurations(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(6); + $state = [2, 4, 13_000_000]; + + [$result, $returnedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($result->denied()); + $this->assertSame(0, $result->remaining()); + $this->assertSame(1_001_000, $this->retryMicroseconds($result)); + $this->assertSame(3_000_000, $this->resetMicroseconds($result)); + $this->assertSame($state, $returnedState); + } + + public function testBackwardClockMovementClampsWeightForAdmissionDecision(): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond(6); + $state = [1, 4, 13_000_000]; + + [$result, $returnedState] = $calculator->consume($policy, 10_000_000, $state); + + $this->assertTrue($result->allowed()); + $this->assertSame(0, $result->remaining()); + $this->assertSame(0, $this->retryMicroseconds($result)); + $this->assertSame(3_000_000, $this->resetMicroseconds($result)); + $this->assertSame([2, 4, 13_000_000], $returnedState); + } + + #[DataProvider('corruptStateProvider')] + public function testCorruptStateFailsClosed(array $state): void + { + $this->expectException(UnexpectedValueException::class); + + (new SlidingWindowCalculator)->inspect( + SlidingWindow::perSecond(10), + 10_000_000, + $state, + ); + } + + public static function corruptStateProvider(): array + { + return [ + 'zero current with expiry' => [[0, 0, 12_000_000]], + 'zero current with previous' => [[0, 1, 12_000_000]], + 'missing expiry' => [[1, 0, 0]], + 'negative current' => [[-1, 0, 12_000_000]], + 'negative previous' => [[1, -1, 12_000_000]], + 'current above capacity' => [[11, 0, 12_000_000]], + 'previous above capacity' => [[1, 11, 12_000_000]], + 'expiry above exact range' => [[1, 0, AdmissionPolicy::MAX_INTEGER + 1]], + ]; + } + + #[DataProvider('highFrequencyWeightProvider')] + public function testHighFrequencyWeightsDoNotRoundUp(int $previous, int $weightedPrevious): void + { + $calculator = new SlidingWindowCalculator; + $policy = SlidingWindow::perSecond($previous + 2); + + [$result] = $calculator->inspect( + $policy, + 10_000_000, + [1, $previous, 11_999_000], + ); + + $this->assertSame($policy->maxAttempts - 1 - $weightedPrevious, $result->remaining()); + } + + public static function highFrequencyWeightProvider(): array + { + return [ + '999' => [999, 998], + '1000' => [1000, 999], + '1001' => [1001, 999], + '2000' => [2000, 1998], + ]; + } + + public function testExactIntegerCeilingsRemainSafe(): void + { + $calculator = new SlidingWindowCalculator; + $capacity = 9_007_199_254; + $policy = SlidingWindow::perSecond($capacity, 4_503_599_627); + + [$first, $firstState] = $calculator->consume($policy, 0, [0, 0, 0]); + [$weighted] = $calculator->inspect( + $policy, + 0, + [1, $capacity, 9_007_199_254_000_000], + ); + + $this->assertTrue($first->allowed()); + $this->assertSame([1, 0, 9_007_199_254_000_000], $firstState); + $this->assertSame(740_991, AdmissionPolicy::MAX_INTEGER - $firstState[2]); + $this->assertTrue($weighted->denied()); + $this->assertSame(0, $weighted->remaining()); + $this->assertGreaterThan(0, $this->retryMicroseconds($weighted)); + } + + public function testRetryCalculationIsExactAcrossTheExhaustiveSmallDomain(): void + { + $calculator = new SlidingWindowCalculator; + $now = 10_000_000; + $checked = 0; + + foreach ([1, 2] as $windowSeconds) { + $windowMilliseconds = $windowSeconds * 1000; + $windowMicroseconds = $windowMilliseconds * 1000; + + for ($previous = 1; $previous <= 8; ++$previous) { + for ($available = 0; $available < $previous; ++$available) { + $policy = SlidingWindow::perSecond($previous + 1, $windowSeconds); + $current = $previous - $available; + + for ($remainingMilliseconds = 1; $remainingMilliseconds <= $windowMilliseconds; ++$remainingMilliseconds) { + $weightedPrevious = intdiv( + $previous * $remainingMilliseconds, + $windowMilliseconds, + ); + + if ($weightedPrevious <= $available) { + continue; + } + + $state = [ + $current, + $previous, + $now + $windowMicroseconds + ($remainingMilliseconds * 1000), + ]; + [$result, $returnedState] = $calculator->consume($policy, $now, $state); + $retryMilliseconds = intdiv($this->retryMicroseconds($result), 1000); + $weightAtRetry = intdiv( + $previous * ($remainingMilliseconds - $retryMilliseconds), + $windowMilliseconds, + ); + $weightBeforeRetry = intdiv( + $previous * ($remainingMilliseconds - $retryMilliseconds + 1), + $windowMilliseconds, + ); + + if (! $result->denied() + || $returnedState !== $state + || $retryMilliseconds < 1 + || $weightAtRetry > $available + || $weightBeforeRetry <= $available) { + $this->fail(sprintf( + 'Retry mismatch for window=%d, previous=%d, available=%d, remaining=%d.', + $windowSeconds, + $previous, + $available, + $remainingMilliseconds, + )); + } + + ++$checked; + } + } + } + } + + $this->assertSame(42_060, $checked); + } + + private function retryMicroseconds(LimitResult $result): int + { + return (new ReflectionProperty($result, 'retryAfterMicroseconds'))->getValue($result); + } + + private function resetMicroseconds(LimitResult $result): int + { + return (new ReflectionProperty($result, 'resetAfterMicroseconds'))->getValue($result); + } +} + +class SlidingWindowCalculator +{ + use CalculatesRateLimits; + + /** + * @param array{int, int, int} $state + * @return array{LimitResult, array{int, int, int}} + */ + public function consume(SlidingWindow $policy, int $now, array $state): array + { + [$value, $secondaryValue, $expiresAt] = $state; + $result = $this->calculateConsume($policy, $now, $value, $secondaryValue, $expiresAt); + + return [$result, [$value, $secondaryValue, $expiresAt]]; + } + + /** + * @param array{int, int, int} $state + * @return array{LimitResult, array{int, int, int}} + */ + public function inspect(SlidingWindow $policy, int $now, array $state): array + { + [$value, $secondaryValue, $expiresAt] = $state; + $result = $this->calculateInspection($policy, $now, $value, $secondaryValue, $expiresAt); + + return [$result, [$value, $secondaryValue, $expiresAt]]; + } +} diff --git a/tests/RateLimiter/SwooleStoreConcurrencyTest.php b/tests/RateLimiter/SwooleStoreConcurrencyTest.php index a6b33f4b6..737fa1476 100644 --- a/tests/RateLimiter/SwooleStoreConcurrencyTest.php +++ b/tests/RateLimiter/SwooleStoreConcurrencyTest.php @@ -5,11 +5,14 @@ namespace Hypervel\Tests\RateLimiter; use Hypervel\Config\Repository; +use Hypervel\RateLimiter\AdmissionPolicy; use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\RateLimiter\Swoole\TableManager; use Hypervel\RateLimiter\Swoole\TableState; use Hypervel\RateLimiter\SwooleStore; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Log\NullLogger; use RuntimeException; use Swoole\Atomic; @@ -24,10 +27,10 @@ class SwooleStoreConcurrencyTest extends TestCase protected bool $runTestsInCoroutine = false; - public function testForkedWorkersAdmitExactlyTheConfiguredCapacity(): void + #[DataProvider('admissionPolicyProvider')] + public function testForkedWorkersAdmitExactlyTheConfiguredCapacity(AdmissionPolicy $policy): void { $state = $this->state(); - $policy = Limit::perMinute(50); $processCount = 8; $attemptsPerProcess = 25; $ready = new Atomic(0); @@ -127,6 +130,14 @@ public function testForkedWorkersAdmitExactlyTheConfiguredCapacity(): void } } + public static function admissionPolicyProvider(): array + { + return [ + 'fixed window' => [Limit::perMinute(50)], + 'sliding window' => [SlidingWindow::perMinute(50)], + ]; + } + /** * Wait until every child reaches a synchronization point. */ diff --git a/tests/RateLimiter/SwooleStoreTest.php b/tests/RateLimiter/SwooleStoreTest.php index 1429e1e5e..88adf2c2f 100644 --- a/tests/RateLimiter/SwooleStoreTest.php +++ b/tests/RateLimiter/SwooleStoreTest.php @@ -11,6 +11,7 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\RateLimiter\Swoole\TableManager; use Hypervel\RateLimiter\Swoole\TableState; use Hypervel\RateLimiter\SwooleStore; @@ -82,6 +83,37 @@ public function testLeakyBucketAndBackoffUseTheSharedCalculator(): void $this->assertTrue($store->clear('backoff')); } + public function testSlidingWindowRotatesStateAndKeepsItForTwoWindows(): void + { + $now = CarbonImmutable::parse('2026-08-04 00:00:00.000000'); + CarbonImmutable::setTestNow($now); + [$store, $state] = $this->store(); + $policy = SlidingWindow::perSecond(10, 2)->cost(4); + $expiresAt = (int) $now->getPreciseTimestamp(6) + 4_000_000; + + $this->assertTrue($store->consume('sliding', $policy)->allowed()); + $this->assertSame([ + 'value' => 4, + 'secondary_value' => 0, + 'expires_at' => $expiresAt, + ], $state->table()->get('sliding')); + + CarbonImmutable::setTestNow($now->addSeconds(2)); + + $this->assertTrue($store->consume('sliding', $policy->cost(3))->allowed()); + $this->assertSame([ + 'value' => 3, + 'secondary_value' => 4, + 'expires_at' => $expiresAt + 2_000_000, + ], $state->table()->get('sliding')); + + CarbonImmutable::setTestNow($now->addSeconds(5)); + $this->assertSame(0, $store->pruneExpiredRows()); + + CarbonImmutable::setTestNow($now->addSeconds(6)); + $this->assertSame(1, $store->pruneExpiredRows()); + } + public function testSwitchingToTestTimeKeepsTheEpochClockScale(): void { [$store] = $this->store(); @@ -143,7 +175,7 @@ public function testFullTablePrunesExpiredRowsAndRetriesOnce(): void $capacity = $this->fillUntilAllocationFails($state, $now + 60_000_000); $this->assertTrue($table->set($capacity['conflict_key'], [ 'value' => 1, - 'available_at' => $now - 1, + 'secondary_value' => 0, 'expires_at' => $now - 1, ])); @@ -174,7 +206,7 @@ public function testCorruptStateFailsClosed(): void $expiresAt = (int) CarbonImmutable::now()->getPreciseTimestamp(6) + 60_000_000; $this->assertTrue($state->table()->set('corrupt', [ 'value' => 1, - 'available_at' => $expiresAt + 1, + 'secondary_value' => $expiresAt + 1, 'expires_at' => $expiresAt, ])); @@ -183,6 +215,22 @@ public function testCorruptStateFailsClosed(): void $store->consume('corrupt', Limit::perMinute(10)); } + public function testCorruptSlidingWindowStateFailsClosed(): void + { + CarbonImmutable::setTestNow('2026-08-04 00:00:00'); + [$store, $state] = $this->store(); + $expiresAt = (int) CarbonImmutable::now()->getPreciseTimestamp(6) + 120_000_000; + $this->assertTrue($state->table()->set('corrupt-sliding', [ + 'value' => 0, + 'secondary_value' => 1, + 'expires_at' => $expiresAt, + ])); + + $this->expectException(UnexpectedValueException::class); + + $store->consume('corrupt-sliding', SlidingWindow::perMinute(10)); + } + /** * @return array{SwooleStore, TableState} */ @@ -226,7 +274,7 @@ private function fillUntilAllocationFails(TableState $state, int $expiresAt): ar $key = "capacity:{$index}"; $stored = @$table->set($key, [ 'value' => 1, - 'available_at' => $expiresAt, + 'secondary_value' => 0, 'expires_at' => $expiresAt, ]); diff --git a/tests/RateLimiter/SwooleTableManagerTest.php b/tests/RateLimiter/SwooleTableManagerTest.php index c6c54a372..f22ace369 100644 --- a/tests/RateLimiter/SwooleTableManagerTest.php +++ b/tests/RateLimiter/SwooleTableManagerTest.php @@ -26,12 +26,12 @@ public function testCreatesAndCachesAnEightByteIntegerTable(): void $this->assertSame($state, $manager->get('swoole')); $this->assertTrue($table->set('maximum', [ 'value' => AdmissionPolicy::MAX_INTEGER, - 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'secondary_value' => AdmissionPolicy::MAX_INTEGER, 'expires_at' => AdmissionPolicy::MAX_INTEGER, ])); $this->assertSame([ 'value' => AdmissionPolicy::MAX_INTEGER, - 'available_at' => AdmissionPolicy::MAX_INTEGER, + 'secondary_value' => AdmissionPolicy::MAX_INTEGER, 'expires_at' => AdmissionPolicy::MAX_INTEGER, ], $table->get('maximum')); } diff --git a/tests/RateLimiter/WorkerArrayStoreTest.php b/tests/RateLimiter/WorkerArrayStoreTest.php index 0e25feca2..fb28d33bf 100644 --- a/tests/RateLimiter/WorkerArrayStoreTest.php +++ b/tests/RateLimiter/WorkerArrayStoreTest.php @@ -9,6 +9,7 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\RateLimiter\WorkerArrayStore; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\RateLimiter\Fixtures\RateLimiterStoreContract; @@ -75,6 +76,28 @@ public function testLeakyBucketRecoversContinuouslyWithoutMutatingDenials(): voi $this->assertSame(0, $accepted->remaining()); } + public function testSlidingWindowUsesGenericStateAndExtendsExpiryOnRotation(): void + { + $now = CarbonImmutable::parse('2026-08-04 12:00:00.000000'); + CarbonImmutable::setTestNow($now); + $store = new CorruptibleWorkerArrayStore; + $policy = SlidingWindow::perSecond(10, 2)->cost(4); + $expiresAt = (int) $now->getPreciseTimestamp(6) + 4_000_000; + + $this->assertTrue($store->consume('sliding', $policy)->allowed()); + $this->assertSame([4, 0, $expiresAt], $store->stateFor('sliding')); + + CarbonImmutable::setTestNow($now->addSeconds(2)); + + $this->assertTrue($store->consume('sliding', $policy->cost(3))->allowed()); + $this->assertSame([3, 4, $expiresAt + 2_000_000], $store->stateFor('sliding')); + + CarbonImmutable::setTestNow($now->addSeconds(6)); + + $this->assertSame(10, $store->inspect('sliding', $policy)->remaining()); + $this->assertSame([3, 4, $expiresAt + 2_000_000], $store->stateFor('sliding')); + } + public function testExponentialBackoffUsesThresholdDoublingCapAndInactivityReset(): void { $now = CarbonImmutable::parse('2026-08-04 12:00:00.000000'); @@ -173,12 +196,20 @@ protected function advanceRateLimiterStoreContractClock(int $seconds): bool class CorruptibleWorkerArrayStore extends WorkerArrayStore { - public function putState(string $key, int $value, int $availableAt, int $expiresAt): void + public function putState(string $key, int $value, int $secondaryValue, int $expiresAt): void { $this->states[$key] = [ 'value' => $value, - 'available_at' => $availableAt, + 'secondary_value' => $secondaryValue, 'expires_at' => $expiresAt, ]; } + + /** + * @return array{int, int, int} + */ + public function stateFor(string $key): array + { + return $this->state($key); + } } From 06bf4ace35438a4abda9f9915f11c86f71e506bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:23 +0000 Subject: [PATCH 37/41] feat(rate-limiter): optimize sliding windows for Redis Implement sliding-window admission as one atomic cached Lua execution over a single two-field hash. Derive window position from PTTL, preserve raw reset timing, use HINCRBY for steady-state increments, and avoid Redis TIME, serializer paths, version branching, and additional keys or round trips.\n\nExercise initial, steady, rotated, denied, rollback, malformed-state, expiry, prefix, serializer, compression, NOSCRIPT fallback, and weighted contention behavior against the Redis integration store. --- src/rate-limiter/src/RedisStore.php | 138 +++++++++++++- .../RateLimiter/Redis/RedisStoreTest.php | 176 ++++++++++++++++++ tests/RateLimiter/RedisStoreTest.php | 19 ++ 3 files changed, 329 insertions(+), 4 deletions(-) diff --git a/src/rate-limiter/src/RedisStore.php b/src/rate-limiter/src/RedisStore.php index 497e82bc5..31470c58c 100644 --- a/src/rate-limiter/src/RedisStore.php +++ b/src/rate-limiter/src/RedisStore.php @@ -43,7 +43,7 @@ class RedisStore implements Store end local current = tonumber(raw) -if not current or current < 0 or current > limit or current % 1 ~= 0 then +if current > limit then return redis.error_reply('ERR corrupt rate limiter counter') end @@ -67,6 +67,120 @@ class RedisStore implements Store local incremented = redis.call('INCRBY', KEYS[1], ARGV[2]) return {1, limit, limit - incremented, 0, ttlMicroseconds} +LUA; + + private const string SLIDING_WINDOW_SCRIPT = <<<'LUA' +local WEIGHT_SCALE = 1000000 +local mode = ARGV[1] +local cost = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local windowSeconds = tonumber(ARGV[4]) +local windowMilliseconds = windowSeconds * 1000 +local fullLifetimeMilliseconds = windowMilliseconds * 2 + +local function empty_result() + if mode == 'inspect' then + return {1, limit, limit, 0, 0} + end + + redis.call('HSET', KEYS[1], 'current', cost, 'previous', 0) + redis.call('PEXPIRE', KEYS[1], fullLifetimeMilliseconds) + return {1, limit, limit - cost, 0, fullLifetimeMilliseconds * 1000} +end + +local state = redis.call('HMGET', KEYS[1], 'current', 'previous') + +if not state[1] and not state[2] then + return empty_result() +end + +if not state[1] or not state[2] then + return redis.error_reply('ERR corrupt rate limiter sliding-window state') +end + +for _, raw in ipairs(state) do + if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then + return redis.error_reply('ERR corrupt rate limiter sliding-window state') + end +end + +local current = tonumber(state[1]) +local previous = tonumber(state[2]) + +if current > limit or previous > limit then + return redis.error_reply('ERR corrupt rate limiter sliding-window state') +end + +local ttl = redis.call('PTTL', KEYS[1]) +if ttl == -1 then + return redis.error_reply('ERR corrupt rate limiter sliding-window state has no expiry') +end +if ttl <= 0 then + return empty_result() +end +if current == 0 then + return redis.error_reply('ERR corrupt rate limiter sliding-window state') +end + +local remainingMilliseconds +local rotated = false + +if ttl > windowMilliseconds then + remainingMilliseconds = ttl - windowMilliseconds +else + previous = current + current = 0 + remainingMilliseconds = ttl + rotated = true +end + +local weight +if remainingMilliseconds >= windowMilliseconds then + weight = WEIGHT_SCALE +else + weight = math.floor(remainingMilliseconds * 1000 / windowSeconds) +end + +local weightedPrevious = math.floor(previous * weight / WEIGHT_SCALE) +local estimated = current + weightedPrevious +local resetMicroseconds = ttl * 1000 + +if estimated > limit - cost then + local available = limit - current - cost + local retryMicroseconds + + if available >= 0 then + local maximumWeight = math.floor(((available + 1) * WEIGHT_SCALE - 1) / previous) + local maximumRemainingMilliseconds = math.floor( + ((maximumWeight + 1) * windowSeconds - 1) / 1000 + ) + retryMicroseconds = (remainingMilliseconds - maximumRemainingMilliseconds) * 1000 + else + local nextAvailable = limit - cost + local maximumWeight = math.floor(((nextAvailable + 1) * WEIGHT_SCALE - 1) / current) + local maximumRemainingMilliseconds = math.floor( + ((maximumWeight + 1) * windowSeconds - 1) / 1000 + ) + retryMicroseconds = remainingMilliseconds * 1000 + + (windowMilliseconds - maximumRemainingMilliseconds) * 1000 + end + + return {0, limit, math.max(0, limit - estimated), retryMicroseconds, resetMicroseconds} +end + +if mode == 'inspect' then + return {1, limit, limit - estimated, 0, resetMicroseconds} +end + +if rotated then + local nextTtl = ttl + windowMilliseconds + redis.call('HSET', KEYS[1], 'current', cost, 'previous', previous) + redis.call('PEXPIRE', KEYS[1], nextTtl) + return {1, limit, limit - estimated - cost, 0, nextTtl * 1000} +end + +redis.call('HINCRBY', KEYS[1], 'current', cost) +return {1, limit, limit - estimated - cost, 0, resetMicroseconds} LUA; private const string LEAKY_BUCKET_SCRIPT = <<<'LUA' @@ -95,7 +209,7 @@ class RedisStore implements Store end storedTat = tonumber(raw) - if not storedTat or storedTat < 0 or storedTat > MAX_INTEGER or storedTat % 1 ~= 0 then + if storedTat > MAX_INTEGER then return redis.error_reply('ERR corrupt rate limiter TAT') end @@ -170,8 +284,7 @@ class RedisStore implements Store failures = tonumber(state[1]) availableAt = tonumber(state[2]) - if not failures or failures < 0 or failures > MAX_INTEGER or failures % 1 ~= 0 - or not availableAt or availableAt < 0 or availableAt > MAX_INTEGER or availableAt % 1 ~= 0 then + if failures > MAX_INTEGER or availableAt > MAX_INTEGER then return redis.error_reply('ERR corrupt rate limiter backoff state') end @@ -242,6 +355,7 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult { return match (true) { $policy instanceof Limit => $this->executeFixedWindow($key, $policy, 'consume'), + $policy instanceof SlidingWindow => $this->executeSlidingWindow($key, $policy, 'consume'), $policy instanceof LeakyBucket => $this->executeLeakyBucket($key, $policy, 'consume'), default => throw new InvalidRateLimitException(sprintf( 'Admission policy [%s] is not supported.', @@ -259,6 +373,7 @@ public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResu { return match (true) { $policy instanceof Limit => $this->executeFixedWindow($key, $policy, 'inspect'), + $policy instanceof SlidingWindow => $this->executeSlidingWindow($key, $policy, 'inspect'), $policy instanceof LeakyBucket => $this->executeLeakyBucket($key, $policy, 'inspect'), $policy instanceof Backoff => $this->executeBackoff($key, $policy, 'inspect'), default => throw new InvalidRateLimitException(sprintf( @@ -302,6 +417,21 @@ protected function executeFixedWindow(string $key, Limit $policy, string $mode): return $this->limitResult($result, $policy->maxAttempts); } + /** + * Execute a sliding-window operation. + */ + protected function executeSlidingWindow(string $key, SlidingWindow $policy, string $mode): LimitResult + { + $result = $this->execute(self::SLIDING_WINDOW_SCRIPT, $key, [ + $mode, + (string) $policy->cost, + (string) $policy->maxAttempts, + (string) $policy->windowSeconds, + ]); + + return $this->limitResult($result, $policy->maxAttempts); + } + /** * Execute a leaky-bucket operation. */ diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php index 9ef1d7334..eec307b18 100644 --- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php +++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php @@ -13,10 +13,12 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Redis\Exceptions\LuaScriptException; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Hypervel\Tests\RateLimiter\Fixtures\RateLimiterStoreContract; +use PHPUnit\Framework\Attributes\DataProvider; use Redis as PhpRedis; use function Hypervel\Coroutine\parallel; @@ -68,6 +70,84 @@ public function testInspectingMissingStateDoesNotCreateAKey(): void $this->assertSame(0, $this->redisClient()->exists($this->physicalKey($policy))); } + public function testSlidingWindowUsesOneHashAndExtendsTtlOnlyOnAcceptedRotation(): void + { + $limiter = $this->limiter(); + $policy = SlidingWindow::perSecond(10, 2)->cost(4)->by('sliding'); + + $this->assertSame(6, $limiter->consume($policy)->remaining()); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + $initialTtl = $redis->pttl($physicalKey); + + $this->assertSame([ + 'current' => '4', + 'previous' => '0', + ], $redis->hGetAll($physicalKey)); + + $this->assertSame(5, $limiter->consume($policy->cost(1))->remaining()); + $sameWindowTtl = $redis->pttl($physicalKey); + $stateBeforeDenial = $redis->hGetAll($physicalKey); + + $this->assertLessThanOrEqual($initialTtl, $sameWindowTtl); + $this->assertGreaterThan($initialTtl - 500, $sameWindowTtl); + $this->assertTrue($limiter->consume($policy->cost(6))->denied()); + $this->assertSame($stateBeforeDenial, $redis->hGetAll($physicalKey)); + $this->assertLessThanOrEqual($sameWindowTtl, $redis->pttl($physicalKey)); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($policy)->resetAfter() <= 2, + 2, + ); + + $this->assertTrue($limiter->consume($policy->cost(1))->allowed()); + $this->assertSame([ + 'current' => '1', + 'previous' => '5', + ], $redis->hGetAll($physicalKey)); + $this->assertGreaterThan(3000, $redis->pttl($physicalKey)); + } + + public function testRotatedSlidingWindowDenialKeepsStoredStateUnchanged(): void + { + $policy = SlidingWindow::perSecond(10, 2) + ->cost(7) + ->by('sliding-rotated-denial'); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + $redis->hMSet($physicalKey, [ + 'current' => '8', + 'previous' => '2', + ]); + $redis->pExpire($physicalKey, 1900); + + $result = $this->limiter()->consume($policy); + + $this->assertTrue($result->denied()); + $this->assertSame(1, $result->retryAfter()); + $this->assertSame(2, $result->resetAfter()); + $this->assertSame([ + 'current' => '8', + 'previous' => '2', + ], $redis->hGetAll($physicalKey)); + + $ttl = $redis->pttl($physicalKey); + + $this->assertGreaterThan(1000, $ttl); + $this->assertLessThanOrEqual(1900, $ttl); + } + + public function testInspectingMissingSlidingWindowStateDoesNotCreateAKey(): void + { + $policy = SlidingWindow::perMinute(10)->by('inspect-sliding'); + $result = $this->limiter()->inspect($policy); + + $this->assertTrue($result->allowed()); + $this->assertSame(10, $result->remaining()); + $this->assertSame(0, $result->resetAfter()); + $this->assertSame(0, $this->redisClient()->exists($this->physicalKey($policy))); + } + public function testLeakyBucketUsesRedisTimeAndRecoversCapacity(): void { CarbonImmutable::setTestNow('2000-01-01 00:00:00'); @@ -144,6 +224,95 @@ public function testPresentBackoffStateWithZeroFailuresFailsClosed(): void $this->limiter()->inspect($backoff); } + #[DataProvider('corruptSlidingWindowStateProvider')] + public function testCorruptSlidingWindowStateFailsClosed( + string $suffix, + array $state, + bool $expires, + ): void { + $policy = SlidingWindow::perMinute(10)->by("corrupt-sliding-{$suffix}"); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + + $redis->hMSet($physicalKey, $state); + + if ($expires) { + $redis->pExpire($physicalKey, 60_000); + } + + $this->expectException(LuaScriptException::class); + + $this->limiter()->inspect($policy); + } + + public static function corruptSlidingWindowStateProvider(): array + { + return [ + 'partial' => ['partial', ['current' => '1'], true], + 'noncanonical' => ['noncanonical', ['current' => '01', 'previous' => '0'], true], + 'zero current' => ['zero-current', ['current' => '0', 'previous' => '0'], true], + 'current above capacity' => ['current-capacity', ['current' => '11', 'previous' => '0'], true], + 'previous above capacity' => ['previous-capacity', ['current' => '1', 'previous' => '11'], true], + 'missing expiry' => ['missing-expiry', ['current' => '1', 'previous' => '0'], false], + ]; + } + + public function testSlidingWindowKeepsRawRollbackDurationsWithoutRewritingTtl(): void + { + $policy = SlidingWindow::perSecond(6)->by('sliding-rollback'); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + $redis->hMSet($physicalKey, [ + 'current' => '2', + 'previous' => '4', + ]); + $redis->pExpire($physicalKey, 3500); + + $result = $this->limiter()->consume($policy); + + $this->assertTrue($result->denied()); + $this->assertSame(0, $result->remaining()); + $this->assertSame(2, $result->retryAfter()); + $this->assertSame(4, $result->resetAfter()); + $this->assertSame([ + 'current' => '2', + 'previous' => '4', + ], $redis->hGetAll($physicalKey)); + + $ttl = $redis->pttl($physicalKey); + + $this->assertGreaterThan(3000, $ttl); + $this->assertLessThanOrEqual(3500, $ttl); + } + + public function testSlidingWindowClampsRollbackWeightForAdmissionDecision(): void + { + $policy = SlidingWindow::perSecond(6)->by('sliding-clamp'); + $physicalKey = $this->physicalKey($policy); + $redis = $this->redisClient(); + $redis->hMSet($physicalKey, [ + 'current' => '1', + 'previous' => '4', + ]); + $redis->pExpire($physicalKey, 3000); + + $result = $this->limiter()->consume($policy); + + $this->assertTrue($result->allowed()); + $this->assertSame(0, $result->remaining()); + $this->assertSame(0, $result->retryAfter()); + $this->assertSame(3, $result->resetAfter()); + $this->assertSame([ + 'current' => '2', + 'previous' => '4', + ], $redis->hGetAll($physicalKey)); + + $ttl = $redis->pttl($physicalKey); + + $this->assertGreaterThan(2250, $ttl); + $this->assertLessThanOrEqual(3000, $ttl); + } + public function testMaximumExactIntegerSurvivesSetAndIncrementArguments(): void { $policy = Limit::perSecond(AdmissionPolicy::MAX_INTEGER)->by('maximum'); @@ -188,6 +357,7 @@ public function testConcurrentWeightedClientsNeverAdmitBeyondCapacity(): void $limiter = $this->limiter(); $policies = [ Limit::perMinute(20)->cost(3)->by('concurrent-weighted-fixed'), + SlidingWindow::perMinute(20)->cost(3)->by('concurrent-weighted-sliding'), LeakyBucket::perMinute(1)->burst(20)->cost(3)->by('concurrent-weighted-leaky'), ]; @@ -219,6 +389,7 @@ public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): vo $limiter = $this->app->make(RateLimiter::class)->store('encoded'); $fixed = Limit::perMinute(2)->by('encoded-fixed'); + $sliding = SlidingWindow::perMinute(2)->by('encoded-sliding'); $leaky = LeakyBucket::perMinute(1)->burst(1)->by('encoded-leaky'); $backoff = Backoff::exponential( after: 1, @@ -228,6 +399,7 @@ public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): vo )->by('encoded-backoff'); $this->assertSame(1, $limiter->consume($fixed)->remaining()); + $this->assertSame(1, $limiter->consume($sliding)->remaining()); $this->assertTrue($limiter->consume($leaky)->allowed()); $this->assertTrue($limiter->consume($leaky)->denied()); $this->assertSame(1, $limiter->recordFailure($backoff)->failures()); @@ -236,6 +408,10 @@ public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): vo try { $this->assertSame('1', $redis->get('rate-limiter-encoded:' . $this->physicalKey($fixed))); + $this->assertSame([ + 'current' => '1', + 'previous' => '0', + ], $redis->hGetAll('rate-limiter-encoded:' . $this->physicalKey($sliding))); $this->assertMatchesRegularExpression( '/^[1-9][0-9]*$/D', (string) $redis->get('rate-limiter-encoded:' . $this->physicalKey($leaky)), diff --git a/tests/RateLimiter/RedisStoreTest.php b/tests/RateLimiter/RedisStoreTest.php index 74fd013e1..c53b5c3d4 100644 --- a/tests/RateLimiter/RedisStoreTest.php +++ b/tests/RateLimiter/RedisStoreTest.php @@ -9,6 +9,7 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\RedisStore; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; @@ -47,6 +48,24 @@ public function testLeakyBucketUsesOneKeyAndMicrosecondArguments(): void $this->assertStringContainsString("redis.call('TIME')", $captured['script']); } + public function testSlidingWindowUsesOneKeyAndTtlDerivedTwoFieldState(): void + { + $captured = []; + $store = $this->store([1, 10, 7, 0, 120_000_000], $captured); + $policy = SlidingWindow::perMinute(10)->cost(3); + + $result = $store->consume('physical-key', $policy); + + $this->assertTrue($result->allowed()); + $this->assertSame(7, $result->remaining()); + $this->assertSame(['physical-key'], $captured['keys']); + $this->assertSame(['consume', '3', '10', '60'], $captured['arguments']); + $this->assertStringContainsString("redis.call('HMGET', KEYS[1], 'current', 'previous')", $captured['script']); + $this->assertStringContainsString("redis.call('HINCRBY', KEYS[1], 'current', cost)", $captured['script']); + $this->assertStringContainsString("redis.call('PTTL', KEYS[1])", $captured['script']); + $this->assertStringNotContainsString("redis.call('TIME')", $captured['script']); + } + public function testBackoffReturnsItsFailureCountAndDelay(): void { $captured = []; From 09a163fd4b50297b6776e7654d9adedf97f4b60a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:30 +0000 Subject: [PATCH 38/41] test(rate-limiter): cover sliding windows across consumers Extend the common store contract with weighted admission, denial and inspection immutability, boundary recovery, expiry, clearing, and parameter isolation so every first-party backend proves the same public behavior.\n\nAdd routing and queue integration coverage showing named sliding-window limits flow through the existing middleware paths and expose their remaining capacity and retry timing without consumer-specific source changes. --- .../Integration/Http/ThrottleRequestsTest.php | 26 +++++++ tests/Integration/Queue/RateLimitedTest.php | 11 +++ .../Fixtures/RateLimiterStoreContract.php | 70 +++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/tests/Integration/Http/ThrottleRequestsTest.php b/tests/Integration/Http/ThrottleRequestsTest.php index cad17ec0d..52145fc27 100644 --- a/tests/Integration/Http/ThrottleRequestsTest.php +++ b/tests/Integration/Http/ThrottleRequestsTest.php @@ -14,6 +14,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\LimitResult; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\RateLimiter\WorkerArrayStore; use Hypervel\Routing\Exceptions\MissingRateLimiterException; use Hypervel\Routing\Middleware\ThrottleRequests; @@ -208,6 +209,31 @@ public function testLeakyBucketHeadersDescribeBurstCapacity(): void ->assertHeader('X-RateLimit-Remaining', 0); } + public function testSlidingWindowHeadersUseItsCapacityAndRetryDelay(): void + { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); + $manager = $this->app->make(RateLimiter::class); + $manager->for('api', fn () => SlidingWindow::perSecond(2, 2)->by('api')); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('api')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 1); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 0); + + $this->get('/') + ->assertTooManyRequests() + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 0) + ->assertHeader('Retry-After', 3) + ->assertHeader('X-RateLimit-Reset', CarbonImmutable::now()->addSeconds(3)->getTimestamp()); + } + // REMOVED: Laravel's shouldHashKeys(false) coverage does not apply because // canonical rate-limiter identities are always hashed. diff --git a/tests/Integration/Queue/RateLimitedTest.php b/tests/Integration/Queue/RateLimitedTest.php index e639c78db..a5dc59af8 100644 --- a/tests/Integration/Queue/RateLimitedTest.php +++ b/tests/Integration/Queue/RateLimitedTest.php @@ -12,6 +12,7 @@ use Hypervel\Queue\Middleware\RateLimited; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -69,6 +70,16 @@ public function testRateLimitedJobsAreNotExecutedOnLimitReached(): void $this->assertJobWasReleased(RateLimitedTestJob::class); } + public function testSlidingWindowRetryDelayIsUsedWhenReleasingAJob(): void + { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); + $rateLimiter = $this->app->make(RateLimiter::class); + $rateLimiter->for('test', fn () => SlidingWindow::perSecond(1, 2)); + + $this->assertJobRanSuccessfully(RateLimitedTestJob::class); + $this->assertJobWasReleasedAfter(RateLimitedTestJob::class, 6); + } + public function testRateLimitedJobsCanBeSkippedOnLimitReached(): void { $rateLimiter = $this->app->make(RateLimiter::class); diff --git a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php index 3c22738ec..bdf690d08 100644 --- a/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php +++ b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php @@ -9,6 +9,7 @@ use Hypervel\RateLimiter\LeakyBucket; use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; +use Hypervel\RateLimiter\SlidingWindow; trait RateLimiterStoreContract { @@ -72,6 +73,75 @@ public function testStoreContractFixedWindowExpiresWithoutBeingExtendedByDenials $this->assertSame(1, $expired->remaining()); } + public function testStoreContractSlidingWindowDecisionsAreAtomicAndComplete(): void + { + $limiter = $this->rateLimiterStoreContract(); + $key = $this->rateLimiterStoreContractKey('sliding'); + $policy = SlidingWindow::perSecond(10, 2)->cost(4)->by($key); + + $missing = $limiter->inspect($policy); + $this->assertTrue($missing->allowed()); + $this->assertSame(10, $missing->limit()); + $this->assertSame(10, $missing->remaining()); + $this->assertSame(0, $missing->retryAfter()); + $this->assertSame(0, $missing->resetAfter()); + + $accepted = $limiter->consume($policy); + $this->assertTrue($accepted->allowed()); + $this->assertSame(6, $accepted->remaining()); + $this->assertGreaterThan(2, $accepted->resetAfter()); + $this->assertLessThanOrEqual(4, $accepted->resetAfter()); + + $this->assertSame(5, $limiter->consume($policy->cost(1))->remaining()); + + $denied = $limiter->consume($policy->cost(6)); + $this->assertTrue($denied->denied()); + $this->assertSame(5, $denied->remaining()); + $this->assertGreaterThan(0, $denied->retryAfter()); + + $inspection = $limiter->inspect($policy); + $this->assertTrue($inspection->allowed()); + $this->assertSame(5, $inspection->remaining()); + + $this->assertSame(11, $limiter->inspect(SlidingWindow::perSecond(11, 2)->by($key))->remaining()); + $this->assertFalse($limiter->clear(SlidingWindow::perSecond(11, 2)->by($key))); + $this->assertTrue($limiter->clear($policy)); + $this->assertSame(10, $limiter->inspect($policy)->remaining()); + } + + public function testStoreContractSlidingWindowRecoversAcrossABoundaryAndExpires(): void + { + $limiter = $this->rateLimiterStoreContract(); + $policy = SlidingWindow::perSecond(10, 2) + ->cost(6) + ->by($this->rateLimiterStoreContractKey('sliding-recovery')); + + $this->assertTrue($limiter->consume($policy)->allowed()); + $this->assertTrue($limiter->consume($policy->cost(5))->denied()); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($policy->cost(5))->allowed(), + 3, + ); + + $recovered = $limiter->consume($policy->cost(5)); + $this->assertTrue($recovered->allowed()); + $this->assertGreaterThan(0, $recovered->resetAfter()); + + $expiring = SlidingWindow::perSecond(1) + ->by($this->rateLimiterStoreContractKey('sliding-expiry')); + $this->assertTrue($limiter->consume($expiring)->allowed()); + + $this->waitForRateLimiterStoreContract( + static fn (): bool => $limiter->inspect($expiring)->resetAfter() === 0, + 2, + ); + + $expired = $limiter->inspect($expiring); + $this->assertTrue($expired->allowed()); + $this->assertSame(1, $expired->remaining()); + } + public function testStoreContractLeakyBucketRecoversWithoutMutatingDenials(): void { $limiter = $this->rateLimiterStoreContract(); From 24b003d82cff32864da5655e7e8f905230593ce3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:38 +0000 Subject: [PATCH 39/41] perf(rate-limiter): benchmark sliding window paths Add allowed-heavy and denied-heavy sliding-window cases to the existing Redis, Swoole, and database benchmark matrix. Keep the benchmark on the real manager and store paths so it measures connection handling, Lua execution or shared calculation, result decoding, and cleanup rather than an isolated arithmetic helper. --- tests/Benchmarks/RateLimiter/README.md | 2 +- tests/Benchmarks/RateLimiter/benchmark.php | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/Benchmarks/RateLimiter/README.md b/tests/Benchmarks/RateLimiter/README.md index e8c1be6fa..db7b029a9 100644 --- a/tests/Benchmarks/RateLimiter/README.md +++ b/tests/Benchmarks/RateLimiter/README.md @@ -23,7 +23,7 @@ php tests/Benchmarks/RateLimiter/benchmark.php \ Each output row records operations per second and p50, p95, and p99 operation latency. The heading records the PHP and Swoole versions, workload size, warmup, concurrency, and generated rate limiter prefix. Each store also prints its non-secret connection, driver, and sizing inputs. -The harness measures fixed-window and leaky-bucket rate limits on both allowed-heavy and denied-heavy paths. It runs each path with one client and with the requested number of clients contending for the same rate limit. Redis and pooled database operations can overlap while awaiting I/O. Swoole operations do not suspend inside one worker, so its concurrent row measures the normal single-worker coroutine workload rather than cross-process lock contention; the forked-worker test suite covers cross-process correctness. +The harness measures fixed-window, sliding-window, and leaky-bucket rate limits on both allowed-heavy and denied-heavy paths. It runs each path with one client and with the requested number of clients contending for the same rate limit. Redis and pooled database operations can overlap while awaiting I/O. Swoole operations do not suspend inside one worker, so its concurrent row measures the normal single-worker coroutine workload rather than cross-process lock contention; the forked-worker test suite covers cross-process correctness. Use a configured MySQL, MariaDB, or PostgreSQL connection when comparing production database behavior. SQLite results are explicitly labeled and should not be treated as representative of a networked database. diff --git a/tests/Benchmarks/RateLimiter/benchmark.php b/tests/Benchmarks/RateLimiter/benchmark.php index 554180f7b..5c0aae4fc 100755 --- a/tests/Benchmarks/RateLimiter/benchmark.php +++ b/tests/Benchmarks/RateLimiter/benchmark.php @@ -9,6 +9,7 @@ use Hypervel\RateLimiter\Limit; use Hypervel\RateLimiter\Limiter; use Hypervel\RateLimiter\RateLimiter; +use Hypervel\RateLimiter\SlidingWindow; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as TestbenchApplication; @@ -50,7 +51,7 @@ public function execute(): void printf("\nStore: %s\n", $storeName); printf("Backend: %s\n", $this->describeBackend($storeName, $limiter)); printf( - "%-13s %-8s %8s %14s %12s %12s %12s\n", + "%-14s %-8s %8s %14s %12s %12s %12s\n", 'policy', 'path', 'clients', @@ -60,7 +61,7 @@ public function execute(): void 'p99 us', ); - foreach (['fixed-window', 'leaky-bucket'] as $policyName) { + foreach (['fixed-window', 'sliding-window', 'leaky-bucket'] as $policyName) { foreach (['allowed', 'denied'] as $path) { foreach (array_values(array_unique([1, $this->concurrency])) as $clients) { $this->benchmarkScenario($limiter, $storeName, $policyName, $path, $clients); @@ -102,7 +103,7 @@ private function benchmarkScenario( sort($samples, SORT_NUMERIC); printf( - "%-13s %-8s %8d %14.0f %12.2f %12.2f %12.2f\n", + "%-14s %-8s %8d %14.0f %12.2f %12.2f %12.2f\n", $policyName, $path, $clients, @@ -124,6 +125,7 @@ private function makePolicy(string $policyName, bool $expectedAllowed, string $k if (! $expectedAllowed) { return match ($policyName) { 'fixed-window' => Limit::perDay(1)->by($key), + 'sliding-window' => SlidingWindow::perDay(1)->by($key), 'leaky-bucket' => LeakyBucket::perDay(1)->burst(1)->by($key), default => throw new LogicException("Unknown benchmark policy [{$policyName}]."), }; @@ -133,6 +135,7 @@ private function makePolicy(string $policyName, bool $expectedAllowed, string $k return match ($policyName) { 'fixed-window' => Limit::perMinute($capacity)->by($key), + 'sliding-window' => SlidingWindow::perMinute($capacity)->by($key), 'leaky-bucket' => LeakyBucket::perSecond(min($capacity, 1_000_000)) ->burst($capacity) ->by($key), From d81f5bb44827574b0614be573731c0c64db6283a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:49 +0000 Subject: [PATCH 40/41] docs(rate-limiter): document sliding window limits Explain when to choose fixed windows, sliding windows, leaky buckets, or backoff in the first-party rate-limiting guide. Document SlidingWindow factories, immutable modifiers, first-hit anchoring, weighted approximation, result timing, store support, and practical consumption examples in Laravel-style prose.\n\nUpdate the concise routing, queue, and package-difference references so every public policy list agrees without duplicating the main guide. --- src/boost/docs/queues.md | 2 +- src/boost/docs/rate-limiting.md | 58 +++++++++++++++++++++++++++++---- src/boost/docs/routing.md | 2 +- src/rate-limiter/README.md | 2 +- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/boost/docs/queues.md b/src/boost/docs/queues.md index ab853084c..736e464d1 100644 --- a/src/boost/docs/queues.md +++ b/src/boost/docs/queues.md @@ -698,7 +698,7 @@ return Limit::perMinute(50)->by($job->user->id); Named queue rate limiters use the same [key scope resolver](/docs/{{version}}/routing#scoping-named-rate-limits) as named route rate limiters. -Queue rate limiters may use fixed-window or leaky-bucket rate limits, and each operation may have a weighted cost. If a named limiter returns several rate limits, Hypervel consumes them in the listed order. When a later rate limit denies the job, capacity already consumed by earlier rate limits is not restored. +Queue rate limiters may use fixed-window, sliding-window, or leaky-bucket rate limits, and each operation may have a weighted cost. If a named limiter returns several rate limits, Hypervel consumes them in the listed order. When a later rate limit denies the job, capacity already consumed by earlier rate limits is not restored. Once you have defined your rate limit, you may attach the rate limiter to your job using the `Hypervel\Queue\Middleware\RateLimited` middleware. Each time the job exceeds the rate limit, this middleware will release the job back to the queue with an appropriate delay based on the rate limit duration: diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index 52e661c7b..20e7c0896 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -6,7 +6,9 @@ - [Database Store](#database-store) - [Swoole Store](#swoole-store) - [Defining Rate Limits](#defining-rate-limits) + - [Choosing a Rate Limit](#choosing-a-rate-limit) - [Fixed Windows](#fixed-windows) + - [Sliding Windows](#sliding-windows) - [Leaky Buckets](#leaky-buckets) - [Weighted Operations](#weighted-operations) - [Unlimited](#unlimited) @@ -29,6 +31,7 @@ Hypervel includes a powerful rate limiter that you may use to limit HTTP routes, The rate limiter supports: - fixed-window limits; +- sliding-window limits; - continuously replenishing leaky buckets; - weighted operations; - capped exponential failure backoff; @@ -92,7 +95,7 @@ Hypervel includes four rate limiter stores: | `swoole` | Workers belonging to one Swoole server instance | Very high-throughput local limiting | | `worker-array` | One worker process | Automated tests only | -The Redis store evaluates each fixed-window, leaky-bucket, and backoff decision atomically in a single cached Lua script, using one pooled connection checkout per operation. The database store uses transactions and row locks. It is a portable shared option when Redis is not available, but does not offer the same throughput. +The Redis store evaluates each fixed-window, sliding-window, leaky-bucket, and backoff decision atomically in a single cached Lua script, using one pooled connection checkout per operation. The database store uses transactions and row locks. It is a portable shared option when Redis is not available, but does not offer the same throughput. The Swoole store keeps native integer state in shared memory. It is shared by workers forked from the same server master, but not by independent Hypervel server instances or different machines. @@ -144,7 +147,7 @@ php artisan rate-limiter:prune database --chunk=2000 The Swoole store allocates its table before server workers are forked. Changes to its table settings therefore require a server restart. -Set `rows` higher than the greatest number of rate limit keys that may be active at once. A key remains active for its fixed window, leaky-bucket refill time, or backoff inactivity time. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. +Set `rows` higher than the greatest number of rate limit keys that may be active at once. A key remains active for its fixed window, up to two sliding-window periods, its leaky-bucket refill time, or its backoff inactivity time. For example, if up to 40,000 client IP addresses may have active one-minute limits at once, configure substantially more than 40,000 rows. Swoole rounds `rows` up to a power of two with a minimum of 64 and allocates an additional collision area based on `conflict_proportion`. Hash collisions may exhaust that collision area before the table's total row count reaches its configured size. Hypervel logs a warning when table or collision pressure enters the configured `memory_limit_buffer`, and throws `Hypervel\RateLimiter\Exceptions\SwooleTableFullException` if a live entry cannot be allocated. It never evicts active limiter state because doing so could admit excess traffic. @@ -167,6 +170,18 @@ Enums, strings, integers, stringable objects, and `null` are accepted as keys. B Invalid rate limit settings throw `Hypervel\RateLimiter\Exceptions\InvalidRateLimitException` before the store is changed. + +### Choosing a Rate Limit + +Hypervel provides several rate limits for different kinds of work: + +| Rate Limit | When to Use It | +|---|---| +| Fixed window | You want a simple limit that resets all capacity at once. | +| Sliding window | You want to smooth the traffic spike that may occur at a fixed-window boundary. | +| Leaky bucket | You want capacity to replenish continuously or need precise burst control. | +| Exponential backoff | You want repeated failures to create progressively longer delays. | + ### Fixed Windows @@ -190,6 +205,37 @@ $limit = Limit::perMinute(120, decayMinutes: 2); A denied operation does not consume capacity or extend the active window. + +### Sliding Windows + +The `SlidingWindow` class provides a rolling approximation that smooths traffic across window boundaries: + +```php +use Hypervel\RateLimiter\SlidingWindow; + +$perSecond = SlidingWindow::perSecond(10); +$perMinute = SlidingWindow::perMinute(60); +$perFiveMinutes = SlidingWindow::perMinutes(5, 300); +$perHour = SlidingWindow::perHour(1000); +$perDay = SlidingWindow::perDay(10_000); +``` + +Sliding windows support the same `by`, `cost`, `globally`, `after`, and `response` modifiers as fixed windows. + +Like a fixed window, the first accepted operation starts the timer. Hypervel keeps the current and previous window counts, then gradually reduces how much the previous count contributes as the current window passes. This avoids the sharp reset at a fixed-window boundary while keeping the amount of stored state constant. + +Sliding windows are an approximation rather than an exact record of every operation during the preceding period. Use a leaky bucket when you need capacity to replenish continuously. + +Each factory accepts a window multiplier. For example, the following rate limit allows approximately 120 operations during a rolling two-minute period: + +```php +$limit = SlidingWindow::perMinute(120, windowMinutes: 2); +``` + +Sliding-window state may contribute for up to two window periods. As a result, `resetAfter()` may return up to twice the configured window. Denied operations and inspections do not change the counts or extend their expiration. + +The capacity and window must be positive and small enough for every configured store to represent them exactly. Invalid values are rejected when the rate limit is created. + ### Leaky Buckets @@ -228,7 +274,7 @@ $limit = Limit::perMinute(100) ->by('uploads:'.$user->id); ``` -The cost may not exceed the fixed-window capacity or leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. +The cost may not exceed the fixed-window or sliding-window capacity, or the leaky-bucket burst capacity. A denied weighted operation leaves the current capacity unchanged. ### Unlimited @@ -269,10 +315,10 @@ if ($result->denied()) { A `LimitResult` provides: - `allowed()` and `denied()`; -- `limit()`, the fixed-window capacity or leaky-bucket burst capacity; +- `limit()`, the configured capacity; - `remaining()`, the whole capacity immediately available after the decision; - `retryAfter()`, the minimum whole seconds until the same cost may be accepted; and -- `resetAfter()`, the whole seconds until the fixed window expires or the leaky bucket becomes full. +- `resetAfter()`, the whole seconds until all current state stops contributing to the rate limit. Durations are rounded up, ensuring a caller is never instructed to retry before capacity is actually available. @@ -433,7 +479,7 @@ interface Store } ``` -A custom store receives validated `Limit` and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores. +A custom store receives validated `Limit`, `SlidingWindow`, and `LeakyBucket` objects through the `AdmissionPolicy` type, while backoff operations receive a `Backoff` instance. The `$key` has already been hashed to a fixed length. The `consume` method must check and consume capacity atomically, while `inspect` must not change state. The `recordFailure` method updates backoff state, and `clear` removes state for a key. Custom stores should return the same decisions and timing values as Hypervel's built-in stores. If your custom store retains expired state, it may also implement `Hypervel\RateLimiter\Contracts\PrunableStore` so it can be targeted by the `rate-limiter:prune` command. diff --git a/src/boost/docs/routing.md b/src/boost/docs/routing.md index f4c562549..7f658c18e 100644 --- a/src/boost/docs/routing.md +++ b/src/boost/docs/routing.md @@ -947,7 +947,7 @@ RateLimiter::for('uploads', function (Request $request) { }); ``` -Named route limiters may use fixed-window or leaky-bucket rate limits, including weighted costs. To learn more about defining rate limits, please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#defining-rate-limits). +Named route limiters may use fixed-window, sliding-window, or leaky-bucket rate limits, including weighted costs. To learn more about defining rate limits, please consult the [rate limiting documentation](/docs/{{version}}/rate-limiting#defining-rate-limits). The optional third argument to `RateLimiter::for` selects a configured store for the named limiter. When omitted, Hypervel uses the default rate limiter store: diff --git a/src/rate-limiter/README.md b/src/rate-limiter/README.md index 9a9e4674c..a3f1c3ff4 100644 --- a/src/rate-limiter/README.md +++ b/src/rate-limiter/README.md @@ -6,7 +6,7 @@ Documentation: https://hypervel.org/docs/rate-limiting ## Differences From Laravel - Hypervel provides rate limiting through the dedicated `hypervel/rate-limiter` package and `Hypervel\RateLimiter` namespace instead of Laravel's Cache component. -- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class. +- Hypervel uses immutable, typed rate limits. `Limit` defines a fixed window, `SlidingWindow` defines a weighted sliding window, `LeakyBucket` defines a GCRA-backed leaky bucket, `Unlimited` bypasses storage, and `Backoff` defines failure-driven exponential delays. Use `globally()` instead of Laravel's `GlobalLimit` class. - The `consume`, `inspect`, `attempt`, `recordFailure`, and `clear` methods replace Laravel's split primitive counter API. Dedicated Redis, Swoole, database, and worker-array stores perform their state changes atomically. - Rate limit keys are always hashed and include the rate limit type, its stable algorithm settings, and its global scope. Cost and callbacks do not affect identity. Changing identity settings starts new state, and `clear()` must receive the same settings that created the state. - When several rate limits are consumed in order, earlier successful charges remain if a later rate limit denies the operation. Weighted denials report the actual unused capacity. The `attempt()` method consumes before invoking its callback and retains the charge if the callback throws. From d18140b7ab0af3bdb39f7e02f6005a6f2ba97dbf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:39:55 +0000 Subject: [PATCH 41/41] docs: align rate limiter package plan with sliding windows Update the original package design record to include SlidingWindow in the final typed-policy inventory, source layout, shared state model, documentation surface, and completion checklist. Point detailed arithmetic and backend behavior to the focused sliding-window plan while retaining Redis backoff's intentionally semantic available_at field. --- .../2026-08-04-1543-rate-limiter-package.md | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/plans/2026-08-04-1543-rate-limiter-package.md b/docs/plans/2026-08-04-1543-rate-limiter-package.md index 06a0f95ff..f35b96d9b 100644 --- a/docs/plans/2026-08-04-1543-rate-limiter-package.md +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -4,6 +4,8 @@ This is the implementation plan for replacing Hypervel's cache-bound rate limiter with a dedicated first-party `hypervel/rate-limiter` component. It is a final-codebase plan, not a compatibility or phased-migration plan. The implementation must remove the old rate-limiter implementation and every obsolete alternate path in the same change. There must be one canonical API and no aliases, shims, deprecated wrappers, stale tests, stale documentation, or TODO entries describing code that no longer exists. +The companion [sliding-window plan](2026-08-07-1439-sliding-window-rate-limiter.md) adds the `SlidingWindow` admission policy and defines its exact algorithm, store changes, schema, tests, benchmark, and documentation. Read that focused plan for the complete sliding-window implementation context. + The plan deliberately does not preserve source compatibility. Hypervel 0.4 is a work in progress, and the desired end state is the code that would have been written if this package had existed from the start. ## Desired outcome @@ -29,7 +31,7 @@ Create `src/rate-limiter` as a split first-party package with these properties: - Do not build a compatibility layer under `Hypervel\Cache`. - Do not introduce a generic cache-backed driver. - Do not add a file driver. A correct file implementation would require a dedicated locked state format, and it would add a low-throughput production surface without an unmet use case because the database driver is the portable shared fallback. -- Do not implement token bucket, sliding-log, sliding-window-counter, linear backoff, Fibonacci backoff, reservations, blocking waits, or distributed multi-limit transactions in this change. The type and store boundaries must permit future additions, but speculative algorithms must not produce unused code. +- Do not implement token bucket, sliding log, segmented sliding windows, linear backoff, Fibonacci backoff, reservations, blocking waits, or distributed multi-limit transactions in this change. The type and store boundaries must permit future additions, but speculative algorithms must not produce unused code. - Do not introduce a process-global service/version registry as part of this package. The related framework capability work is separately recorded in `docs/todo.md`. - Do not use Redis Functions as an alternate deployment mode. Functions require server-side library lifecycle/permissions and create an operational branch without improving the one-round-trip steady-state contract over cached Lua scripts. @@ -60,6 +62,7 @@ Do not add a `Hypervel\Cache\RateLimiter` alias or wrapper. Two class locations The manager resolves named stores from `rate-limiter.stores`. Each store has a `driver` string, following Laravel's manager/config conventions. The algorithm is selected by the policy object's concrete type: - `AdmissionPolicy` is the clearly named admission-policy base; Laravel's familiar concrete `Limit` remains the fixed-window policy. +- `SlidingWindow` is the weighted two-window counter policy. - `LeakyBucket` is the smoothed admission policy, implemented with GCRA. - `Unlimited` bypasses storage. - `Backoff::exponential(...)` returns a concrete exponential failure policy. @@ -68,7 +71,7 @@ Adding an admission algorithm later means adding a typed policy and implementing ### 4. Backoff is not an admission algorithm -Exponential backoff is based on failures and success/reset events; fixed window and leaky bucket decide whether a unit of work may be admitted. They therefore use different operations: +Exponential backoff is based on failures and success/reset events; fixed windows, sliding windows, and leaky buckets decide whether a unit of work may be admitted. They therefore use different operations: - admission: `consume`, `inspect`, `clear`; - failure penalty: `inspect`, `recordFailure`, `clear`. @@ -220,7 +223,7 @@ Modifiers shared by admission policies: - `after(callable $callback): static`; - `response(callable $callback): static`. -Retain Laravel's convenient readable-property shape, but make the properties `public readonly`: `key`, `cost`, `global`, `afterCallback`, and `responseCallback` on `AdmissionPolicy`; `maxAttempts` and `decaySeconds` on `Limit`; and `rate`, `periodMicroseconds`, and `burst` on `LeakyBucket`. `AdmissionPolicy` declares a protected copy hook receiving every shared field; each concrete policy implements it by invoking its own constructor with those fields plus its typed algorithm fields. Shared modifiers call that hook. Concrete modifiers use the same constructor path. Do not use reflection, post-clone readonly writes, or a generic options array. +Retain Laravel's convenient readable-property shape, but make the properties `public readonly`: `key`, `cost`, `global`, `afterCallback`, and `responseCallback` on `AdmissionPolicy`; `maxAttempts` and `decaySeconds` on `Limit`; `maxAttempts` and `windowSeconds` on `SlidingWindow`; and `rate`, `periodMicroseconds`, and `burst` on `LeakyBucket`. `AdmissionPolicy` declares a protected copy hook receiving every shared field; each concrete policy implements it by invoking its own constructor with those fields plus its typed algorithm fields. Shared modifiers call that hook. Concrete modifiers use the same constructor path. Do not use reflection, post-clone readonly writes, or a generic options array. Modifiers validate their own scalar/range input immediately. Cross-field constraints are validated on `Limiter::consume()`/`inspect()` before key resolution or storage access, so fluent order is irrelevant: both `LeakyBucket::perSecond(100)->cost(150)->burst(200)` and the reverse order are valid, while a final `cost > burst` fails before mutation. Internal stores may read the typed readonly properties directly without getter-call overhead. @@ -259,8 +262,8 @@ final readonly class LimitResult implements Decision All public durations are integer seconds rounded up from the driver's finer internal precision: - `retryAfter()` is `0` when the requested cost was accepted and otherwise is the minimum wait until that cost can be accepted; -- `resetAfter()` is the remaining fixed-window duration or time until a leaky bucket is full; -- `limit()` is fixed-window capacity or leaky-bucket burst capacity; +- `resetAfter()` is the time until all current state stops contributing to the rate limit; +- `limit()` is the configured capacity; - `remaining()` is immediately consumable whole-token capacity after the decision. For `inspect()`, no consumption occurs, so `remaining()` is the capacity available in the observed state; `allowed()` answers whether this policy's configured cost could be consumed now. For an accepted `consume()`, remaining is measured after the cost is committed. For a denied consume, it is the unchanged current capacity. This distinction must be identical across stores and explicit in result tests. @@ -287,7 +290,7 @@ class Limiter } ``` -`AdmissionPolicy` is the abstract base implemented by `Limit`, `LeakyBucket`, and `Unlimited`; the distinct name avoids conflating the `RateLimiter` manager, per-store `Limiter`, and Laravel-compatible `Limit`. `Backoff` is a separate concrete failure policy. `consume()` is the normal one-call atomic operation. `inspect()` never mutates state. The conditional PHPDoc return is part of both public and store contracts so PHPStan narrows admission inspection to `LimitResult` and backoff inspection to `BackoffResult` without caller assertions. `attempt()` atomically consumes before invoking the callback and returns `false` on denial; if a callback returns `null`, it returns `true`, preserving Laravel's convenient semantics. If the callback throws, the accepted token remains consumed. Code that should charge only on failure or on a response predicate must use `inspect()` followed by the appropriate explicit operation. +`AdmissionPolicy` is the abstract base implemented by `Limit`, `SlidingWindow`, `LeakyBucket`, and `Unlimited`; the distinct name avoids conflating the `RateLimiter` manager, per-store `Limiter`, and Laravel-compatible `Limit`. `Backoff` is a separate concrete failure policy. `consume()` is the normal one-call atomic operation. `inspect()` never mutates state. The conditional PHPDoc return is part of both public and store contracts so PHPStan narrows admission inspection to `LimitResult` and backoff inspection to `BackoffResult` without caller assertions. `attempt()` atomically consumes before invoking the callback and returns `false` on denial; if a callback returns `null`, it returns `true`, preserving Laravel's convenient semantics. If the callback throws, the accepted token remains consumed. Code that should charge only on failure or on a response predicate must use `inspect()` followed by the appropriate explicit operation. The optional `limiterName` is only identity context for a policy obtained from `RateLimiter::for()`. Routing and queue middleware must pass it; direct calls omit it. It is deliberately not a mutable hidden field on a policy and not the selected store name. This closes the collision between two named limiters that return otherwise identical policies while keeping direct policy use terse. @@ -379,6 +382,7 @@ src/rate-limiter/ ├── RateLimiter.php ├── RateLimiterServiceProvider.php ├── RedisStore.php + ├── SlidingWindow.php ├── Swoole/ │ ├── TableManager.php │ └── TableState.php @@ -590,7 +594,7 @@ Do not alter `RedisConnection::callEvalsha()` or `evalWithShaCache()` behavior f ### Swoole store - Own a dedicated `Swoole\Table`; do not reuse `SwooleStore` or `SwooleTableManager` from cache. -- Columns are `value`, `available_at`, and `expires_at`, all `Table::TYPE_INT` with an explicit 8-byte width. +- Columns are `value`, `secondary_value`, and `expires_at`, all `Table::TYPE_INT` with an explicit 8-byte width. - Use a fixed 32-character hashed key. - Resolve the package-local `Swoole\TableManager` as an unbound concrete, using Hypervel's auto-singleton behavior rather than an explicit container binding. `Listeners\InitializeSwooleTables` asks it to resolve every configured Swoole limiter store during `BeforeServerStart`; later `createSwooleDriver()` retrieves that same named `TableState`. This is the necessary registry between pre-fork allocation and lazy store resolution, not a second rate-limiter/store cache. - Create every configured Swoole limiter table and its striped `Swoole\Atomic` locks before server fork, so all workers share them. After creating the configured tables, `InitializeSwooleTables` seals the manager. Before sealing, console/tests may explicitly initialize named tables; after sealing, `get()` returns only a pre-created state and an unknown name throws instead of allocating worker-private state. The sealed flag is set before fork and inherited by workers. Structural options (`rows`, columns, conflict proportion, store names) are restart-only. @@ -614,16 +618,17 @@ Migration generated by `make:rate-limiter-table` (`rate-limiter:table` alias): Schema::create('rate_limits', function (Blueprint $table) { $table->char('key', 32)->primary(); $table->unsignedBigInteger('value')->default(0); - $table->unsignedBigInteger('available_at')->default(0); + $table->unsignedBigInteger('secondary_value')->default(0); $table->unsignedBigInteger('expires_at')->index(); }); ``` State mapping: -- fixed window: `value = consumed`, `available_at = reset_at`, `expires_at = reset_at`; -- leaky bucket: `value = TAT`, `available_at = 0`, `expires_at = full_refill_at`; -- exponential backoff: `value = failures`, `available_at = blocked_until`, `expires_at = inactivity expiry`. +- fixed window: `value = consumed`, `secondary_value = 0`, `expires_at = reset_at`; +- sliding window: `value = current count`, `secondary_value = previous count`, `expires_at = end of the following window`; +- leaky bucket: `value = TAT`, `secondary_value = 0`, `expires_at = full_refill_at`; +- exponential backoff: `value = failures`, `secondary_value = blocked_until`, `expires_at = inactivity expiry`. The strategy and parameters are already in the hashed physical key, so a strategy column and JSON payload are unnecessary. This representation is compact, queryable, portable across Hypervel's MySQL, MariaDB, PostgreSQL, and SQLite connections, and avoids serialization. @@ -838,7 +843,7 @@ Adapt the ported Queue integration base to Hypervel's auto-invoked trait lifecyc Update every applicable Boost document, not just the main rate-limiting page: - `routing.md`: named policies, leaky bucket, weighted cost, response-based semantics, stores, headers, and removal of `throttleWithRedis`; -- update the existing `src/boost/docs/rate-limiting.md` in place as the single canonical rate-limiting document: cover the package architecture, direct consume/inspect/attempt/clear APIs, typed policies and results, fixed-window/leaky-bucket/backoff behavior, driver selection and guarantees, configuration, database migration/pruning, custom drivers, distribution boundaries, performance guidance, and failure behavior. Do not add a competing `rate-limiter.md` page; +- update the existing `src/boost/docs/rate-limiting.md` in place as the single canonical rate-limiting document: cover the package architecture, direct consume/inspect/attempt/clear APIs, typed policies and results, fixed-window/sliding-window/leaky-bucket/backoff behavior, driver selection and guarantees, configuration, database migration/pruning, custom drivers, distribution boundaries, performance guidance, and failure behavior. Do not add a competing `rate-limiter.md` page; - `queues.md`: store selection and removal of Redis-specific middleware classes; - `fortify.md`, `errors.md`, `starter-kits.md`: imports and new typed calls; - `facades.md`: canonical accessor/class; @@ -872,7 +877,7 @@ Create `tests/RateLimiter` and use the repository-required base test/coroutine c ### Policy/value tests -- Every fixed-window and leaky-bucket factory converts periods correctly; leaky factories default burst to the sustained token count and `burst(1)` opts into strict smoothing. +- Every fixed-window, sliding-window, and leaky-bucket factory converts periods correctly; leaky factories default burst to the sustained token count and `burst(1)` opts into strict smoothing. - Invalid zero/negative capacity, rate, duration, burst, cost, and backoff settings throw named exceptions. - Numeric boundary tests cover the shared Lua-exact/signed-64 limits and every overflow-prone multiplication/addition before a store mutation. - Fluent methods return new copies and do not mutate the original policy. @@ -1001,7 +1006,7 @@ The standalone harness must call Testbench's `Bootstrapper::bootstrap()` and cre Measure at minimum: -- fixed-window and leaky-bucket consume through Redis, Swoole, and one explicitly labeled configured database backend; +- fixed-window, sliding-window, and leaky-bucket consume through Redis, Swoole, and one explicitly labeled configured database backend; - representative single-client and contended concurrency on allowed-heavy and denied-heavy paths, with the exact workload recorded in the output rather than a mandatory combinatorial matrix; - a one-time old cache-backed fixed-limiter baseline versus the new drivers before old code is removed; retain the recorded comparison, not a compatibility adapter or old implementation in the final harness; - p50/p95/p99 latency and operations/second. Measure pool wait, backend CPU, memory, or extra server versions ad hoc only when the core results expose a concrete question. @@ -1041,7 +1046,7 @@ No step should add a temporary alias or dual API. If intermediate local compilat ## Final verification checklist - [ ] `Hypervel\RateLimiter` is the sole namespace; its facade and unconditional default provider resolve the new manager with no Cache shim or dual API. -- [ ] Fixed, GCRA/leaky-bucket, unlimited, and exponential-backoff policies are typed; no strategy/driver enum, descriptor bag, or speculative algorithm exists. +- [ ] Fixed-window, sliding-window, GCRA/leaky-bucket, unlimited, and exponential-backoff policies are typed; no strategy/driver enum, descriptor bag, or speculative algorithm exists. - [ ] Redis/Swoole/database/worker-array pass the shared semantic suite, and Redis/Swoole/database pass their applicable real-contention concurrency suites; failures never fail open and no driver routes through generic cache serialization. - [ ] Redis admission is one cached Lua call on the existing Redis 8 and Valkey 9 services; Swoole uses shared numeric state without live eviction and documents/logs capacity pressure; database uses only `rate_limits`. - [ ] Foundation, the application skeleton, and Testbench carry the same stores/migration, with database as the application default and worker-array as the deliberate Testbench default; named stores merge without duplicate package config.