diff --git a/.env.example b/.env.example index 61d17a4dd..30725a089 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,9 @@ # REDIS_DB=1 # REDIS_TEST_DB_MIN=1 # REDIS_TEST_DB_MAX=14 -# Database 15 is excluded from the worker range. Reverb state and secondary -# Redis tests share it safely because those tests use unique keys and never flush it. +# Database 15 is excluded from the worker range for Reverb integration servers +# and tests that explicitly request the secondary database. InteractsWithRedis +# remaps every named application connection, including reverb, to its worker database. # REDIS_REVERB_DB=15 # REDIS_TEST_SECONDARY_DB=15 diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 5ff3996e0..5827e2078 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -64,9 +64,12 @@ jobs: # Cap ParaTest's auto-detected workers: database 1 is reserved via # REDIS_TEST_SECONDARY_DB, leaving 15 worker databases in the 0-15 range. run: | - vendor/bin/paratest --max-processes=15 tests/Integration/Auth + vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Horizon + vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis valkey_9: @@ -125,7 +128,10 @@ jobs: # Cap ParaTest's auto-detected workers: database 1 is reserved via # REDIS_TEST_SECONDARY_DB, leaving 15 worker databases in the 0-15 range. run: | - vendor/bin/paratest --max-processes=15 tests/Integration/Auth + vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Horizon + vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis diff --git a/AGENTS.md b/AGENTS.md index 9d015281e..44338341c 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 @@ -119,6 +120,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. @@ -200,8 +202,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. @@ -376,7 +376,7 @@ Test supported public behavior, meaningful branches, verified regressions, and r ### Directory layout -All tests live in `tests/{PackageName}/` (PascalCase). Tests that require external services go in `tests/Integration/{PackageName}/` — see Integration tests below. +All tests live in `tests/{PackageName}/` (PascalCase). Tests that require external services go in `tests/Integration/{PackageName}/` — see Integration tests below. When only some integration tests for a package require one service, group them in `tests/Integration/{PackageName}/{ServiceName}/`. When every integration test for the package requires that service, keep them directly in the package directory. Package-specific tests that require one database driver go in `tests/Integration/{PackageName}/Database/{Postgres|MySql|MariaDb|Sqlite}/`. The database workflows discover these directories by convention. @@ -644,6 +644,8 @@ If a test fails with a type error, the source code type may be wrong — not the Tests that require external services (databases, Redis, HTTP servers, search engines) that can't run in every environment go in `tests/Integration/{PackageName}/`. The exception is tests that call freely-available external APIs (e.g., the Guzzle tests hitting the public Pokemon API) — those can stay in regular `tests/` since they need no local service configuration. +Service workflows enumerate their test directories explicitly. Adding a service-specific directory requires adding it to the matching workflow; using the service trait provides isolation and skip behavior but does not make CI discover the test. + #### External service test traits Integration tests that use an external service must use that service's test trait. @@ -677,8 +679,8 @@ Each integration group has its own workflow file in `.github/workflows/`: | Workflow | Runs | Directory | |----------|------|-----------| | `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` | +| `databases.yml` | MySQL, MariaDB, PostgreSQL, SQLite | `tests/Integration/Database`, `tests/Integration/*/Database/*` | +| `redis.yml` | Redis, Valkey | `tests/Integration/Auth/Redis`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/Queue/Redis`, `tests/Integration/RateLimiter/Redis`, `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/composer.json b/composer.json index 142acd357..a1f1d0fc6 100644 --- a/composer.json +++ b/composer.json @@ -67,6 +67,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/", @@ -261,6 +262,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", @@ -349,6 +351,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/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..f35b96d9b --- /dev/null +++ b/docs/plans/2026-08-04-1543-rate-limiter-package.md @@ -0,0 +1,1057 @@ +# 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 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 + +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 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. +- 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, 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. + +## 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: + +- `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. + +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 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`. + +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. +- 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. + +`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 constrain implementation + +- 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. + +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 `AdmissionPolicy` clearly names the admission abstraction and Laravel's `Limit` remains the fixed-window policy applications use. + +```php +namespace Hypervel\RateLimiter; + +/** @mixin \Hypervel\RateLimiter\Limiter */ +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, + 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. +} +``` + +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. 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. + +```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|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 `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. + +`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()); +}, store: 'redis'); +``` + +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. + +### 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 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. + +The common `Decision` contract contains `allowed()`, `denied()`, and `retryAfter()` so middleware can handle admission and backoff results without a loose array. + +### Limiter operations + +```php +class Limiter +{ + public function getStore(): Contracts\Store; + + public function consume(AdmissionPolicy $policy, UnitEnum|string|null $limiterName = null): LimitResult; + + /** @return ($policy is Backoff ? BackoffResult : LimitResult) */ + public function inspect(AdmissionPolicy|Backoff $policy, UnitEnum|string|null $limiterName = null): LimitResult|BackoffResult; + + 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(AdmissionPolicy|Backoff $policy, UnitEnum|string|null $limiterName = null): bool; +} +``` + +`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. + +### 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(...)` 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. + +`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 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 + +Target layout (names may move only if implementation reveals a concrete repository convention conflict): + +```text +src/rate-limiter/ +├── README.md +├── composer.json +└── src/ + ├── AdmissionPolicy.php + ├── Backoff.php + ├── BackoffResult.php + ├── Console/ + │ ├── PruneCommand.php + │ ├── RateLimiterTableCommand.php + │ └── stubs/rate-limits.stub + ├── Concerns/ + │ └── CalculatesRateLimits.php + ├── Contracts/ + │ ├── Decision.php + │ ├── PrunableStore.php + │ └── Store.php + ├── DatabaseStore.php + ├── Exceptions/ + │ ├── InvalidRateLimitException.php + │ └── SwooleTableFullException.php + ├── KeyResolver.php + ├── LeakyBucket.php + ├── Limit.php + ├── Limiter.php + ├── LimitResult.php + ├── Listeners/ + │ ├── InitializeSwooleTables.php + │ └── RegisterPruneTimer.php + ├── RateLimiter.php + ├── RateLimiterServiceProvider.php + ├── RedisStore.php + ├── SlidingWindow.php + ├── Swoole/ + │ ├── TableManager.php + │ └── TableState.php + ├── SwooleStore.php + ├── Unlimited.php + └── WorkerArrayStore.php +``` + +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. `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 + +The contract receives an already-resolved fixed-length physical key and validated policy: + +```php +interface Store +{ + public function consume(string $key, AdmissionPolicy $policy): LimitResult; + + /** @return ($policy is Backoff ? BackoffResult : 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; +} +``` + +```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 + +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 [ + '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, // seconds + ], + + 'worker-array' => [ + 'driver' => 'worker-array', + ], + ], + + 'prefix' => env('RATE_LIMITER_PREFIX', app_id() . '_rate_limiter'), +]; +``` + +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. + +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 + +### 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. + +`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 + +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`, 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 + +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. 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: + +```lua +local cost = tonumber(ARGV[1]) +local limit = tonumber(ARGV[2]) +local durationMilliseconds = tonumber(ARGV[3]) +local durationMicroseconds = durationMilliseconds * 1000 + +local function start_window() + redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) + return {1, limit, limit - cost, 0, durationMicroseconds} +end + +local raw = redis.call('GET', KEYS[1]) + +if not raw then + return start_window() +end + +if raw ~= '0' and not string.match(raw, '^[1-9]%d*$') then + 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('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 start_window() +end + +local nextValue = current + cost + +if nextValue > limit then + return {0, limit, limit - current, ttl * 1000, ttl * 1000} +end + +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 (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()` 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 + +- Own a dedicated `Swoole\Table`; do not reuse `SwooleStore` or `SwooleTableManager` from cache. +- 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. +- 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 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 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. 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 + +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('secondary_value')->default(0); + $table->unsignedBigInteger('expires_at')->index(); +}); +``` + +State mapping: + +- 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. + +Mutating operation: + +```php +return $connection->transaction(function ($connection) use ($key, $policy) { + $row = $this->stateForUpdate($connection, $key); + + $now = $this->currentTimeInMicroseconds($connection); + + // Compute and persist one validated state transition, then return its result. +}, attempts: 3); +``` + +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. + +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. + +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. + +### Worker-array store + +- 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. +- 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()`, 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 + +### 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. +- 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. +- 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 its `RoutingServiceProvider` binding and binding-lifetime tests. The unified `ThrottleRequests` keeps decisions in method-local state and remains safely auto-singletoned without an explicit binding. +- 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 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. + +### 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. 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. +- 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. + +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 `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. + +### 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. +- 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`. + +## 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-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 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`, 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; +- 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. + +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 `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: + +- `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; +- `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`; +- `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; +- 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. + +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. Correct `DurationLimiter`'s fresh/expired Lua result order so PHP receives the expiry timestamp and remaining capacity in their documented positions, and clamp returned remaining capacity to zero. 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. + +Group Redis-backed tests under `tests/Integration/{Package}/Redis` when the package also has service-independent integration tests. Move the existing Auth, Cache, HTTP, Queue, and RateLimiter Redis tests into those directories so the Redis workflow neither skips them nor reruns unrelated package suites. Run `tests/Integration/Auth/Redis`, `tests/Integration/Cache/Redis`, `tests/Integration/Horizon`, `tests/Integration/Http/Redis`, `tests/Integration/Queue/Redis`, `tests/Integration/RateLimiter/Redis`, and `tests/Integration/Redis` explicitly in both Redis 8 and Valkey 9 jobs with ParaTest capped at 15 workers. The separate Reverb workflow must continue running `tests/Integration/Reverb`, which also uses the shared Redis test trait and requires its own test servers. The database workflow already discovers `tests/Integration/*/Database/{Driver}` and needs no rate-limiter-specific path edit. Keep the root `AGENTS.md` workflow table and service-directory guidance aligned with these paths. + +Correct the shared `InteractsWithRedis` test isolation before relying on those parallel groups. In both sequential and parallel runs, normalize the `database` key of every configured Redis connection array to the current test database, skipping the reserved `client`, `options`, and `clusters` groups and unrelated non-array values. Reject non-empty connection URLs with a clear message directing test environments to the trait's documented `REDIS_HOST`/`REDIS_PORT` setup, because URL database paths would otherwise override the normalized key. The first-party integration workflows expose one Redis/Valkey service, so one `FLUSHDB` through the default connection then clears every configured connection without opening every pool during each test setup and teardown. + +Adapt the ported Queue integration base to Hypervel's auto-invoked trait lifecycle. Alias the trait's setup and teardown methods, gate setup on the live `queue.default` value, remember successful Redis setup for matching teardown even if a test later changes the driver, and remove the stale driver snapshot plus subclass synchronization assignments. Add one end-to-end Redis queue-worker fixture under `tests/Integration/Queue/Redis`; the existing Redis workflow discovers it without a new matrix. This keeps sync/database Queue tests independent of Redis, exercises the Redis branch in CI, and prevents the test base from calling Laravel-only `setUpRedis()`/`tearDownRedis()` methods. + +## 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/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; +- `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. + +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 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. + +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, 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. +- 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. +- 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 worker-array, Swoole, and database stores without descriptor arrays or floating-point state. + +### Shared store contract suite + +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, 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 + +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. + +### Redis-specific tests + +- Steady path calls `evalSha` and a node's first NOSCRIPT response falls back to `eval` through existing `evalWithShaCache()`. +- 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. +- 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; 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. 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. 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. +- 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. +- 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 + +- 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. +- 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. +- 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; 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. +- Foundation's default `Limit::none()` throttle path satisfies the widened `Lottery|AdmissionPolicy|null` return type. +- 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. +- 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 + +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 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, 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. + +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; 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. + +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/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 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 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. +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. +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 sole namespace; its facade and unconditional default provider resolve the new manager with no Cache shim or dual API. +- [ ] 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. +- [ ] 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. +- [ ] Static analysis, all affected suites, end-to-end benchmarks, stale-symbol searches, and `git diff --check` pass. 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. diff --git a/docs/todo.md b/docs/todo.md index 89a8468a6..613dd57fb 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. @@ -46,6 +47,7 @@ ## 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. ## Collections 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/facades.md b/src/boost/docs/facades.md index c8e66a14f..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) @@ -240,7 +239,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/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/middleware.md b/src/boost/docs/middleware.md index 02fdd998d..687633b03 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/queues.md b/src/boost/docs/queues.md index 3cd8b21b9..736e464d1 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, 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: ```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 `RateLimited` middleware supports every configured rate limiter store. ### Preventing Job Overlaps @@ -941,7 +934,9 @@ 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: +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; @@ -957,7 +952,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,26 +1027,15 @@ 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')]; -``` - ### Releasing Jobs diff --git a/src/boost/docs/rate-limiting.md b/src/boost/docs/rate-limiting.md index fc640ad77..20e7c0896 100644 --- a/src/boost/docs/rate-limiting.md +++ b/src/boost/docs/rate-limiting.md @@ -1,160 +1,525 @@ # 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 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) +- [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; +- sliding-window limits; +- continuously replenishing leaky buckets; +- weighted operations; +- capped exponential failure backoff; +- Redis, Swoole, database, and worker-local array stores; and +- custom rate limiter stores. + +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 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 rate limit state separate when multiple applications use the same backend. Hypervel includes this value when generating its hashed 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 | Automated tests only | + +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. -'limiter' => 'redis', // [tl! add] +> [!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 + +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 + +php artisan migrate ``` - -## Basic Usage +The `rate-limiter:table` command is also available as an alias. + +> [!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 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. + +> [!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. -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. +> [!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. -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: +You may schedule the 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. + +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. + +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 Rate Limits + +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 rate limit 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; + +$limit = Limit::perMinute(60)->by('user:'.$user->id); +``` + +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. + + +### 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 + +The `Limit` class defines a fixed window whose timer begins with its first accepted operation: + +```php +use Hypervel\RateLimiter\Limit; + +$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 rate limit allows 120 operations during a two-minute window: + +```php +$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); ``` - -### Manually Incrementing Attempts +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 -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 `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; + +$limit = LeakyBucket::perSecond(100) + ->burst(200) + ->by('api-token:'.$token->id); +``` + +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 keep only one operation immediately available at a time, use `burst(1)`: + +```php +$limit = LeakyBucket::perSecond(100)->burst(1); +``` + +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 + +By default, an operation consumes one unit of capacity. Use `cost` when some operations should consume more: + +```php +$limit = Limit::perMinute(100) + ->cost(5) + ->by('uploads:'.$user->id); +``` + +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 + +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 rate limits 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::tooManyAttempts('send-message:'.$user->id, $perMinute = 5)) { - return 'Too many attempts!'; +$result = RateLimiter::consume( + Limit::perMinute(5)->by('send-message:'.$user->id), +); + +if ($result->denied()) { + return 'Try again in '.$result->retryAfter().' seconds.'; } +``` -RateLimiter::increment('send-message:'.$user->id); +A `LimitResult` provides: -// Send message... -``` +- `allowed()` and `denied()`; +- `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 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. + + +### Inspecting State -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 `inspect` method returns a decision without consuming capacity or creating state: ```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); +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); - // Send message... +$result = RateLimiter::inspect($limit); + +if ($result->allowed()) { + // The requested capacity is currently available... } ``` -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: +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 rate limit is denied; otherwise, it returns the callback result. A `null` callback result is converted to `true`: ```php -RateLimiter::increment('send-message:'.$user->id, amount: 5); +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(); +}); + +if ($executed === false) { + return 'Too many messages sent!'; +} ``` -If you would like to decrement the value for a given rate limiter key, you may use the `decrement` method: +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 + +The `clear` method removes the state for a rate limit: ```php -RateLimiter::decrement('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); ``` - -### Retrieving Attempts +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. -You may use the `attempts` method to retrieve the number of attempts for a given rate limiter key: +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 + +Use `store` to perform an operation against a configured store other than the default: ```php -$attempts = RateLimiter::attempts('send-message:'.$user->id); +use Hypervel\RateLimiter\Limit; +use Hypervel\Support\Facades\RateLimiter; + +$limit = Limit::perMinute(5)->by('send-message:'.$user->id); + +$result = RateLimiter::store('redis')->consume($limit); ``` - -#### Determining Limiter Availability +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. -When a key has no more attempts left, the `availableIn` method returns the number of seconds remaining until more attempts will be available: + +## Exponential Backoff + +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; -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 +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 rate limits, and stacked rate limits. + + +## Custom Stores + +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 { - $message->markAsRead(); + public function consume(string $key, AdmissionPolicy $policy): LimitResult; - RateLimiter::clear('send-message:'.$message->user_id); + public function inspect( + string $key, + AdmissionPolicy|Backoff $policy, + ): LimitResult|BackoffResult; - return $message; + public function recordFailure(string $key, Backoff $backoff): BackoffResult; + + public function clear(string $key): bool; } ``` -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: +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. + +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 +use Hypervel\Contracts\Foundation\Application; +use Hypervel\RateLimiter\Contracts\Store; +use Hypervel\RateLimiter\RateLimiter; + +public function boot(RateLimiter $rateLimiter): void +{ + $rateLimiter->extend('custom', function (Application $app, array $config): Store { + return new CustomRateLimiterStore(/* ... */); + }); +} +``` + +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 replaces any `name` entry in the store configuration. + + +## Failure Behavior + +If the configured store fails, Hypervel throws an exception. It never silently allows the operation or switches to another store. + +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/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/boost/docs/routing.md b/src/boost/docs/routing.md index 465905574..7f658c18e 100644 --- a/src/boost/docs/routing.md +++ b/src/boost/docs/routing.md @@ -25,7 +25,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) @@ -889,8 +892,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; /** @@ -904,11 +907,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; /** @@ -922,6 +925,8 @@ public function boot(): void } ``` +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: ```php @@ -942,6 +947,16 @@ RateLimiter::for('uploads', function (Request $request) { }); ``` +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: + +```php +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); +}, store: 'redis'); +``` + #### Segmenting Rate Limits @@ -981,13 +996,11 @@ 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(); }); ``` @@ -1005,17 +1018,21 @@ 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: +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) { 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), ]; }); ``` +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 @@ -1024,8 +1041,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; @@ -1034,11 +1051,15 @@ 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 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 @@ -1056,20 +1077,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 () { // ... -}) +}); ``` +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. + ## Form Method Spoofing 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) { 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. diff --git a/src/cache/composer.json b/src/cache/composer.json index 4f1a91383..ede45f838 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", + "laravel/serializable-closure": "^2.0.10", + "psr/simple-cache": "^3.0", "symfony/console": "^8.1" }, "provide": { 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 @@ - - */ - 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/src/cache/src/SwooleTimer.php b/src/cache/src/SwooleTimer.php deleted file mode 100644 index 027202a08..000000000 --- a/src/cache/src/SwooleTimer.php +++ /dev/null @@ -1,27 +0,0 @@ - + */ + 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/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/src/fortify/composer.json b/src/fortify/composer.json index b10eea5bd..be7f70515 100644 --- a/src/fortify/composer.json +++ b/src/fortify/composer.json @@ -18,7 +18,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", @@ -32,6 +31,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/src/foundation/README.md b/src/foundation/README.md index 7177f1f9e..a237adc20 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -10,6 +10,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. + The application locale setters do not change the `app.locale` or `app.fallback_locale` configuration values. `App::setLocale()` applies only to the current request, while `App::setFallbackLocale()` is intended for application boot and changes the fallback shared by the worker. Laravel's deprecated `Middleware::validateCsrfTokens()` alias is intentionally not ported. Configure request-forgery protection with `preventRequestForgery()`. 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/config/cache.php b/src/foundation/config/cache.php index 141a1deca..a5a36f317 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -18,18 +18,6 @@ 'default' => 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/src/foundation/config/rate-limiter.php b/src/foundation/config/rate-limiter.php new file mode 100644 index 000000000..a1c8adf30 --- /dev/null +++ b/src/foundation/config/rate-limiter.php @@ -0,0 +1,71 @@ + 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 + ], + + // 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', + ], + ], + + /* + |-------------------------------------------------------------------------- + | 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/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/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/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/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/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/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/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..a3f1c3ff4 --- /dev/null +++ b/src/rate-limiter/README.md @@ -0,0 +1,13 @@ +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 `Hypervel\RateLimiter` namespace instead of Laravel's Cache component. +- 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. +- 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/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..474922662 --- /dev/null +++ b/src/rate-limiter/src/Concerns/CalculatesRateLimits.php @@ -0,0 +1,577 @@ + $this->calculateFixedWindow( + $policy, + $now, + $value, + $secondaryValue, + $expiresAt, + true, + ), + $policy instanceof SlidingWindow => $this->calculateSlidingWindow( + $policy, + $now, + $value, + $secondaryValue, + $expiresAt, + true, + ), + $policy instanceof LeakyBucket => $this->calculateLeakyBucket( + $policy, + $now, + $value, + $secondaryValue, + $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 $secondaryValue, + int $expiresAt, + ): LimitResult|BackoffResult { + return match (true) { + $policy instanceof Limit => $this->calculateFixedWindow( + $policy, + $now, + $value, + $secondaryValue, + $expiresAt, + false, + ), + $policy instanceof SlidingWindow => $this->calculateSlidingWindow( + $policy, + $now, + $value, + $secondaryValue, + $expiresAt, + false, + ), + $policy instanceof LeakyBucket => $this->calculateLeakyBucket( + $policy, + $now, + $value, + $secondaryValue, + $expiresAt, + false, + ), + $policy instanceof Backoff => $this->calculateBackoffInspection( + $now, + $value, + $secondaryValue, + $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 &$secondaryValue, + int &$expiresAt, + ): BackoffResult { + $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.'); + } + + ++$value; + + $delay = $value < $backoff->after + ? 0 + : $this->backoffDelay($backoff, $value - $backoff->after); + + $secondaryValue = $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 : $secondaryValue - $now, + ); + } + + /** + * Calculate a fixed-window decision. + */ + private function calculateFixedWindow( + Limit $policy, + int $now, + int &$value, + int &$secondaryValue, + int &$expiresAt, + bool $consume, + ): LimitResult { + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateFixedWindowState($policy, $value, $secondaryValue, $expiresAt); + + 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->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 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. + */ + private function calculateLeakyBucket( + LeakyBucket $policy, + int $now, + int &$value, + int &$secondaryValue, + int &$expiresAt, + bool $consume, + ): LimitResult { + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateLeakyBucketState($value, $secondaryValue, $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; + $secondaryValue = 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 $secondaryValue, + int $expiresAt, + ): BackoffResult { + $this->resetExpiredState($now, $value, $secondaryValue, $expiresAt); + $this->validateBackoffState($value, $secondaryValue, $expiresAt); + + $retryAfter = max($secondaryValue - $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 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. + */ + 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 &$secondaryValue, + int &$expiresAt, + ): void { + if ($expiresAt !== 0 && $expiresAt <= $now) { + $value = 0; + $secondaryValue = 0; + $expiresAt = 0; + } + } + + /** + * Validate fixed-window state loaded from a store. + */ + private function validateFixedWindowState( + Limit $policy, + int $value, + int $secondaryValue, + int $expiresAt, + ): void { + if ($value < 0 || $value > $policy->maxAttempts + || $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 $secondaryValue, int $expiresAt): void + { + if ($value < 0 || $secondaryValue !== 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 $secondaryValue, int $expiresAt): void + { + if ($value < 0 || $secondaryValue < 0 || $expiresAt < 0 + || ($value === 0 && ($secondaryValue !== 0 || $expiresAt !== 0)) + || ($value !== 0 && $expiresAt === 0) + || $secondaryValue > $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..e2809cc4e --- /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('secondary_value')->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, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); + $result = $this->calculateConsume( + $policy, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $secondaryValue, + $expiresAt, + ); + + if ($result->allowed()) { + $this->writeState($connection, $key, $value, $secondaryValue, $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, $secondaryValue, $expiresAt] = $row === null + ? [0, 0, 0] + : $this->stateFromRow($row); + + return $this->calculateInspection( + $policy, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $secondaryValue, + $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, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); + $result = $this->calculateFailure( + $backoff, + $this->currentDatabaseTimeInMicroseconds($connection), + $value, + $secondaryValue, + $expiresAt, + ); + + $this->writeState($connection, $key, $value, $secondaryValue, $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, + 'secondary_value' => 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->secondary_value ?? null, 'secondary_value'), + $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 $secondaryValue, + int $expiresAt, + ): void { + $connection->table($this->table) + ->where('key', $key) + ->update([ + 'value' => $value, + 'secondary_value' => $secondaryValue, + '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?->__invoke($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 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) + . $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..c4a91eef0 --- /dev/null +++ b/src/rate-limiter/src/LeakyBucket.php @@ -0,0 +1,143 @@ + $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 $periodSeconds = 1): static + { + return new static( + $rate, + static::multiply($periodSeconds, 1_000_000, 'period seconds'), + $rate, + ); + } + + /** + * Create a new per-minute leaky-bucket limit. + */ + public static function perMinute(int $rate, int $periodMinutes = 1): static + { + 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 $periodMinutes, int $rate): static + { + return static::perMinute($rate, $periodMinutes); + } + + /** + * Create a new per-hour leaky-bucket limit. + */ + public static function perHour(int $rate, int $periodHours = 1): static + { + 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 $periodDays = 1): static + { + return new static( + $rate, + static::multiply($periodDays, 86_400_000_000, 'period days'), + $rate, + ); + } + + /** + * 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..b07585d9b --- /dev/null +++ b/src/rate-limiter/src/Limit.php @@ -0,0 +1,104 @@ +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..0a90fd8b6 --- /dev/null +++ b/src/rate-limiter/src/Limiter.php @@ -0,0 +1,238 @@ +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 SlidingWindow => $this->validateSlidingWindow($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 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. + */ + 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..31470c58c --- /dev/null +++ b/src/rate-limiter/src/RedisStore.php @@ -0,0 +1,533 @@ + limit 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 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' +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 storedTat > MAX_INTEGER 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 failures > MAX_INTEGER or availableAt > MAX_INTEGER 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 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.', + $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 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( + '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 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. + */ + 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/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/src/rate-limiter/src/Swoole/TableManager.php b/src/rate-limiter/src/Swoole/TableManager.php new file mode 100644 index 000000000..fbe1d8f8d --- /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('secondary_value', 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..65e710ec7 --- /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, $secondaryValue, $expiresAt] = $this->storedState($key); + + $result = $this->calculateConsume( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $expiresAt, + ); + + if ($result->denied()) { + return [$result, true]; + } + + return [ + $result, + $this->writeState($key, $value, $secondaryValue, $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, $secondaryValue, $expiresAt] = $this->storedState($key); + + return $this->calculateInspection( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $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, $secondaryValue, $expiresAt] = $this->storedState($key); + + $result = $this->calculateFailure( + $backoff, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $expiresAt, + ); + + return [ + $result, + $this->writeState($key, $value, $secondaryValue, $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; + $secondaryValue = $row['secondary_value'] ?? null; + $expiresAt = $row['expires_at'] ?? null; + + if (! is_int($value) || ! is_int($secondaryValue) || ! is_int($expiresAt) + || $value < 0 || $secondaryValue < 0 || $expiresAt < 0 + || $value > AdmissionPolicy::MAX_INTEGER + || $secondaryValue > AdmissionPolicy::MAX_INTEGER + || $expiresAt > AdmissionPolicy::MAX_INTEGER) { + throw new UnexpectedValueException('The stored Swoole rate limiter state is invalid.'); + } + + return [$value, $secondaryValue, $expiresAt]; + } + + /** + * Write numeric state for a physical limiter key. + */ + protected function writeState(string $key, int $value, int $secondaryValue, int $expiresAt): bool + { + return $this->state->table()->set($key, [ + 'value' => $value, + 'secondary_value' => $secondaryValue, + '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, $secondaryValue, $expiresAt] = $this->state($key); + + $result = $this->calculateConsume( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $expiresAt, + ); + + if ($result->allowed()) { + $this->states[$key] = [ + 'value' => $value, + 'secondary_value' => $secondaryValue, + '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, $secondaryValue, $expiresAt] = $this->state($key); + + return $this->calculateInspection( + $policy, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $expiresAt, + ); + } + + /** + * Record a failure against a backoff policy. + */ + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + [$value, $secondaryValue, $expiresAt] = $this->state($key); + + $result = $this->calculateFailure( + $backoff, + $this->currentTimeInMicroseconds(), + $value, + $secondaryValue, + $expiresAt, + ); + + $this->states[$key] = [ + 'value' => $value, + 'secondary_value' => $secondaryValue, + '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['secondary_value'], $state['expires_at']]; + } +} 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 a65ae95bf..c267fac7b 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 3591303f6..940df3cc5 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -1606,15 +1606,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/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..4d3e214dc 100644 --- a/src/reverb/src/Protocols/Pusher/Server.php +++ b/src/reverb/src/Protocols/Pusher/Server.php @@ -4,7 +4,8 @@ namespace Hypervel\Reverb\Protocols\Pusher; -use Hypervel\Cache\RateLimiter; +use Hypervel\RateLimiter\Limit; +use Hypervel\RateLimiter\Limiter; use Hypervel\Reverb\Contracts\Connection; use Hypervel\Reverb\Events\ConnectionClosed; use Hypervel\Reverb\Events\ConnectionEstablished; @@ -24,17 +25,13 @@ class Server { - /** - * Cached rate limiter instance. - */ - protected ?RateLimiter $rateLimiter = null; - /** * Create a new server instance. */ public function __construct( protected ChannelManager $channels, protected EventHandler $handler, + protected Limiter $messageRateLimiter, ) { } @@ -140,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(); + } + } } } @@ -191,8 +200,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,24 +278,24 @@ 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->rateLimiter->tooManyAttempts($key, $config['max_attempts'])) { - if ($config['terminate_on_limit'] ?? false) { - $connection->terminate(); - } - + if ($this->messageRateLimiter->consume($this->messageLimit($connection))->denied()) { 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((int) $config['max_attempts'], (int) $config['decay_seconds']) + ->by('reverb:message:' . $connection->id()); + } + /** * Verify the origin of the connection. */ 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/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/src/routing/composer.json b/src/routing/composer.json index 576ae9d22..22dc5a3e7 100644 --- a/src/routing/composer.json +++ b/src/routing/composer.json @@ -33,7 +33,6 @@ "ext-filter": "*", "ext-hash": "*", "hypervel/auth": "^0.4", - "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/console": "^0.4", @@ -46,7 +45,7 @@ "hypervel/macroable": "^0.4", "hypervel/pipeline": "^0.4", "hypervel/prompts": "^0.4", - "hypervel/redis": "^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 9303076ec..000000000 --- a/src/routing/src/Middleware/ThrottleRequestsWithRedis.php +++ /dev/null @@ -1,137 +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) { - $tooManyAttempts = $limit->afterCallback - ? $this->tooManyAttempts($limit->key, $limit->maxAttempts, $limit->decaySeconds) - : ! $this->acquire($limit->key, $limit->maxAttempts, $limit->decaySeconds); - - if ($tooManyAttempts) { - throw $this->buildException($request, $limit->key, $limit->maxAttempts, $limit->responseCallback); - } - } - - $response = $next($request); - - foreach ($limits as $limit) { - if ($limit->afterCallback && ($limit->afterCallback)($response)) { - $this->acquire($limit->key, $limit->maxAttempts, $limit->decaySeconds); - } - - $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->tooManyAttempts(), function () use ($key, $limiter) { - [$this->decaysAt[$key], $this->remaining[$key]] = [ - $limiter->decaysAt, $limiter->remaining, - ]; - }); - } - - /** - * Attempt to acquire the given key. - */ - protected function acquire(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/routing/src/RoutingServiceProvider.php b/src/routing/src/RoutingServiceProvider.php index 5b564ed4d..a3a0a074b 100644 --- a/src/routing/src/RoutingServiceProvider.php +++ b/src/routing/src/RoutingServiceProvider.php @@ -11,7 +11,6 @@ use Hypervel\Routing\Console\MiddlewareMakeCommand; use Hypervel\Routing\Contracts\CallableDispatcher as CallableDispatcherContract; use Hypervel\Routing\Contracts\ControllerDispatcher as ControllerDispatcherContract; -use Hypervel\Routing\Middleware\ThrottleRequestsWithRedis; use Hypervel\Support\ServiceProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -26,7 +25,6 @@ class RoutingServiceProvider extends ServiceProvider public function register(): void { $this->registerRouter(); - $this->registerRedisThrottle(); $this->registerUrlGenerator(); $this->registerRedirector(); $this->registerPsrRequest(); @@ -52,14 +50,6 @@ protected function registerRouter(): void }); } - /** - * Register the Redis request throttler. - */ - protected function registerRedisThrottle(): void - { - $this->app->bind(ThrottleRequestsWithRedis::class); - } - /** * Register the URL generator service. */ 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/src/support/src/Facades/RateLimiter.php b/src/support/src/Facades/RateLimiter.php index 196c3e4f3..f7ce72884 100644 --- a/src/support/src/Facades/RateLimiter.php +++ b/src/support/src/Facades/RateLimiter.php @@ -5,24 +5,28 @@ namespace Hypervel\Support\Facades; /** - * @method static \Hypervel\Cache\RateLimiter for(\UnitEnum|string $name, \Closure $callback) - * @method static void resolveKeyScopeUsing(\Closure|null $resolver) + * @method static \Hypervel\RateLimiter\Limiter store(\UnitEnum|string|null $name = null) + * @method static \Hypervel\RateLimiter\RateLimiter for(\UnitEnum|string $name, \Closure $callback, \UnitEnum|string|null $store = null) * @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 void resolveKeyScopeUsing(\Closure|null $resolver) + * @method static string getDefaultInstance() + * @method static void setDefaultInstance(string $name) + * @method static array getInstanceConfig(string $name) + * @method static mixed instance(string|null $name = null) + * @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) + * @method static \Hypervel\RateLimiter\RateLimiter setApplication(\Hypervel\Contracts\Foundation\Application $app) + * @method static \Hypervel\RateLimiter\Contracts\Store getStore() + * @method static \Hypervel\RateLimiter\LimitResult consume(\Hypervel\RateLimiter\AdmissionPolicy $policy, \UnitEnum|string|null $limiterName = null) + * @method static ($policy is \Hypervel\RateLimiter\Backoff ? \Hypervel\RateLimiter\BackoffResult : \Hypervel\RateLimiter\LimitResult) 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) * - * @see \Hypervel\Cache\RateLimiter + * @see \Hypervel\RateLimiter\RateLimiter + * @see \Hypervel\RateLimiter\Limiter */ class RateLimiter extends Facade { @@ -31,6 +35,6 @@ class RateLimiter extends Facade */ protected static function getFacadeAccessor(): string { - return \Hypervel\Cache\RateLimiter::class; + return \Hypervel\RateLimiter\RateLimiter::class; } } 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/src/testbench/hypervel/config/rate-limiter.php b/src/testbench/hypervel/config/rate-limiter.php new file mode 100644 index 000000000..372d4fdb4 --- /dev/null +++ b/src/testbench/hypervel/config/rate-limiter.php @@ -0,0 +1,71 @@ + 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 + ], + + // 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', + ], + ], + + /* + |-------------------------------------------------------------------------- + | 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..e3b51f29e --- /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('secondary_value')->default(0); + $table->unsignedBigInteger('expires_at')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('rate_limits'); + } +}; diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 6316863eb..201f59a1a 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/Benchmarks/RateLimiter/README.md b/tests/Benchmarks/RateLimiter/README.md new file mode 100644 index 000000000..db7b029a9 --- /dev/null +++ b/tests/Benchmarks/RateLimiter/README.md @@ -0,0 +1,43 @@ +# Rate Limiter Benchmark + +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: + +```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, 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. + +## 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..5c0aae4fc --- /dev/null +++ b/tests/Benchmarks/RateLimiter/benchmark.php @@ -0,0 +1,489 @@ +#!/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( + "%-14s %-8s %8s %14s %12s %12s %12s\n", + 'policy', + 'path', + 'clients', + 'operations/s', + 'p50 us', + 'p95 us', + 'p99 us', + ); + + 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); + } + } + } + } + } + + /** + * 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( + "%-14s %-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), + 'sliding-window' => SlidingWindow::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), + 'sliding-window' => SlidingWindow::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()); 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/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; - } -} 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/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; + } +} 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); + } +} 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)); + } +} 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); 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/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 1a26e6c62..ae69f3462 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -773,6 +773,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/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)); } 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; + } } 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/Auth/EloquentUserProviderCacheTagsTest.php b/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php similarity index 99% rename from tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php rename to tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php index 0f1b5d6af..403f6a6ed 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php +++ b/tests/Integration/Auth/Redis/EloquentUserProviderCacheTagsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Auth\EloquentUserProviderCacheTagsTest; +namespace Hypervel\Tests\Integration\Auth\Redis\EloquentUserProviderCacheTagsTest; use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Foundation\Application as ApplicationContract; diff --git a/tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php b/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php similarity index 99% rename from tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php rename to tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php index e09cfefce..66346e811 100644 --- a/tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php +++ b/tests/Integration/Auth/Redis/EloquentUserProviderRedisCacheTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Auth; +namespace Hypervel\Tests\Integration\Auth\Redis; use Hypervel\Auth\EloquentUserProvider; use Hypervel\Contracts\Foundation\Application as ApplicationContract; diff --git a/tests/Integration/Cache/MemoizedStoreTest.php b/tests/Integration/Cache/Redis/MemoizedStoreTest.php similarity index 99% rename from tests/Integration/Cache/MemoizedStoreTest.php rename to tests/Integration/Cache/Redis/MemoizedStoreTest.php index 3a5e4322f..bf1c9205e 100644 --- a/tests/Integration/Cache/MemoizedStoreTest.php +++ b/tests/Integration/Cache/Redis/MemoizedStoreTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use BadMethodCallException; use Hypervel\Cache\Events\CacheEvent; diff --git a/tests/Integration/Cache/PhpRedisCacheFunnelTest.php b/tests/Integration/Cache/Redis/PhpRedisCacheFunnelTest.php similarity index 99% rename from tests/Integration/Cache/PhpRedisCacheFunnelTest.php rename to tests/Integration/Cache/Redis/PhpRedisCacheFunnelTest.php index 7bce0bf3e..605208c01 100644 --- a/tests/Integration/Cache/PhpRedisCacheFunnelTest.php +++ b/tests/Integration/Cache/Redis/PhpRedisCacheFunnelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use Hypervel\Contracts\Limiters\RefreshableLease; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; diff --git a/tests/Integration/Cache/PhpRedisCacheLockTest.php b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php similarity index 99% rename from tests/Integration/Cache/PhpRedisCacheLockTest.php rename to tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php index 5832bb79d..978aeafb9 100644 --- a/tests/Integration/Cache/PhpRedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; diff --git a/tests/Integration/Cache/RedisCacheFunnelTest.php b/tests/Integration/Cache/Redis/RedisCacheFunnelTest.php similarity index 97% rename from tests/Integration/Cache/RedisCacheFunnelTest.php rename to tests/Integration/Cache/Redis/RedisCacheFunnelTest.php index a957ce304..ede687e2e 100644 --- a/tests/Integration/Cache/RedisCacheFunnelTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheFunnelTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Limiters\RefreshableLease; use Hypervel\Coroutine\Parallel; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; +use Hypervel\Tests\Integration\Cache\CacheFunnelTestCase; class RedisCacheFunnelTest extends CacheFunnelTestCase { 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. */ diff --git a/tests/Integration/Cache/RedisCacheLockTest.php b/tests/Integration/Cache/Redis/RedisCacheLockTest.php similarity index 99% rename from tests/Integration/Cache/RedisCacheLockTest.php rename to tests/Integration/Cache/Redis/RedisCacheLockTest.php index 117e3ddb6..5936e7f3d 100644 --- a/tests/Integration/Cache/RedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheLockTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use Exception; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; diff --git a/tests/Integration/Cache/RedisStoreTest.php b/tests/Integration/Cache/Redis/RedisStoreTest.php similarity index 99% rename from tests/Integration/Cache/RedisStoreTest.php rename to tests/Integration/Cache/Redis/RedisStoreTest.php index 8161cae5b..a21d23a0e 100644 --- a/tests/Integration/Cache/RedisStoreTest.php +++ b/tests/Integration/Cache/Redis/RedisStoreTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Cache; +namespace Hypervel\Tests\Integration\Cache\Redis; use BadMethodCallException; use DateTime; 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; + } +} diff --git a/tests/Integration/Generators/RateLimiterTableCommandTest.php b/tests/Integration/Generators/RateLimiterTableCommandTest.php new file mode 100644 index 000000000..689bd1fae --- /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('secondary_value')->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/Http/Redis/ThrottleRequestsRedisStoreTest.php b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php new file mode 100644 index 000000000..933483b41 --- /dev/null +++ b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php @@ -0,0 +1,62 @@ +app->make(RateLimiter::class); + $manager->for('redis', fn () => Limit::perMinute(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); + $denied = $this->get('/') + ->assertTooManyRequests() + ->assertHeader('X-RateLimit-Limit', 2) + ->assertHeader('X-RateLimit-Remaining', 0) + ->assertHeader('Retry-After'); + + $this->assertGreaterThan(0, (int) $denied->headers->get('Retry-After')); + } + + 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..52145fc27 --- /dev/null +++ b/tests/Integration/Http/ThrottleRequestsTest.php @@ -0,0 +1,491 @@ + '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); + } + + 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. + + 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 testResponseBasedLimitUsesItsConfiguredResponse(): 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) + ->response(fn (): Response => new Response('ah ah ah', 429))); + + Route::get('/', fn (): Response => new Response('missing', 404)) + ->middleware(ThrottleRequests::using('not-found')); + + $this->get('/')->assertNotFound(); + $this->get('/')->assertTooManyRequests()->assertContent('ah ah ah'); + } + + public function testOrdinaryLimitUsesOneAtomicConsumeWithoutInspection(): void + { + $store = $this->countingStoreFor('uploads', Limit::perMinute(2)->by('uploads')); + + Route::get('/', fn (): string => 'yes')->middleware(ThrottleRequests::using('uploads')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 1); + + $this->assertSame(1, $store->consumeCalls); + $this->assertSame(0, $store->inspectCalls); + } + + public function testExcludedResponseUsesOneInspectionWithoutConsumption(): void + { + $store = $this->countingStoreFor( + 'not-found', + Limit::perMinute(2) + ->by('not-found') + ->after(fn (Response $response): bool => $response->getStatusCode() === 404), + ); + + Route::get('/', fn (): Response => new Response('ok')) + ->middleware(ThrottleRequests::using('not-found')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Remaining', 2); + + $this->assertSame(0, $store->consumeCalls); + $this->assertSame(1, $store->inspectCalls); + } + + public function testQualifyingResponseUsesOneInspectionAndOneAtomicConsume(): void + { + $store = $this->countingStoreFor( + 'not-found', + Limit::perMinute(2) + ->by('not-found') + ->after(fn (Response $response): bool => $response->getStatusCode() === 404), + ); + + Route::get('/', fn (): Response => new Response('missing', 404)) + ->middleware(ThrottleRequests::using('not-found')); + + $this->get('/') + ->assertNotFound() + ->assertHeader('X-RateLimit-Remaining', 1); + + $this->assertSame(1, $store->consumeCalls); + $this->assertSame(1, $store->inspectCalls); + } + + 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); + } + + private function countingStoreFor(string $name, AdmissionPolicy $policy): ThrottleRequestsCountingStore + { + config([ + 'rate-limiter.stores.counting' => [ + 'driver' => 'counting', + ], + ]); + + $store = new ThrottleRequestsCountingStore(new WorkerArrayStore); + $manager = $this->app->make(RateLimiter::class); + $manager->extend('counting', static fn (): Store => $store); + $manager->for($name, static fn (): AdmissionPolicy => $policy, store: 'counting'); + + return $store; + } +} + +class ThrottleRequestsCountingStore implements Store +{ + public int $consumeCalls = 0; + + public int $inspectCalls = 0; + + public function __construct(protected Store $store) + { + } + + public function consume(string $key, AdmissionPolicy $policy): LimitResult + { + ++$this->consumeCalls; + + return $this->store->consume($key, $policy); + } + + public function inspect(string $key, AdmissionPolicy|Backoff $policy): LimitResult|BackoffResult + { + ++$this->inspectCalls; + + return $this->store->inspect($key, $policy); + } + + public function recordFailure(string $key, Backoff $backoff): BackoffResult + { + return $this->store->recordFailure($key, $backoff); + } + + public function clear(string $key): bool + { + return $this->store->clear($key); + } +} + +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 e57bd4071..000000000 --- a/tests/Integration/Http/ThrottleRequestsWithRedisTest.php +++ /dev/null @@ -1,89 +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])); - } - } - - public function testItCanThrottleBasedOnResponse(): void - { - RateLimiter::for('throttle-not-found', function (Request $request) { - return Limit::perMinute(1)->after(fn ($response) => $response->status() === 404); - }); - - Route::get('/', fn () => match (request('status')) { - '404' => abort(404), - default => 'ok', - })->middleware(ThrottleRequestsWithRedis::using('throttle-not-found')); - - $this->get('?status=200')->assertOk()->assertHeader('X-RateLimit-Remaining', 1); - $this->get('?status=200')->assertOk()->assertHeader('X-RateLimit-Remaining', 1); - $this->get('?status=200')->assertOk()->assertHeader('X-RateLimit-Remaining', 1); - - $this->get('?status=404')->assertNotFound()->assertHeader('X-RateLimit-Remaining', 0); - - $this->get('?status=200')->assertTooManyRequests(); - $this->get('?status=404')->assertTooManyRequests(); - } - - public function testItReturnsConfiguredResponseWhenUsingAfterLimit(): void - { - RateLimiter::for('throttle-not-found', function (Request $request) { - return Limit::perMinute(1) - ->after(fn ($response) => $response->status() === 404) - ->response(fn () => response('ah ah ah', status: 429)); - }); - - Route::get('/', fn () => abort(404)) - ->middleware(ThrottleRequestsWithRedis::using('throttle-not-found')); - - $this->get('/')->assertNotFound(); - $this->get('/')->assertTooManyRequests()->assertContent('ah ah ah'); - } -} 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/RateLimitedTest.php b/tests/Integration/Queue/RateLimitedTest.php index 4cc273b3b..a5dc59af8 100644 --- a/tests/Integration/Queue/RateLimitedTest.php +++ b/tests/Integration/Queue/RateLimitedTest.php @@ -6,16 +6,13 @@ 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\RateLimiter\SlidingWindow; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -58,17 +55,11 @@ 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); + // REMOVED: the cache-specific multi-call fixture is replaced by the atomic + // package-store decision exercised by testRateLimitedJobsAreNotExecutedOnLimitReached(). - $rateLimiter = new RateLimiter($cache); - $this->app->instance(RateLimiter::class, $rateLimiter); + public function testRateLimitedJobsAreNotExecutedOnLimitReached(): void + { $rateLimiter = $this->app->make(RateLimiter::class); $rateLimiter->for('test', function ($job) { @@ -76,37 +67,17 @@ public function testRateLimitedJobsAreNotExecutedOnLimitReached2(): void }); $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); + $this->assertJobWasReleased(RateLimitedTestJob::class); } - public function testRateLimitedJobsAreNotExecutedOnLimitReached(): void + public function testSlidingWindowRetryDelayIsUsedWhenReleasingAJob(): void { + CarbonImmutable::setTestNow('2000-01-01 00:00:00'); $rateLimiter = $this->app->make(RateLimiter::class); - - $rateLimiter->for('test', function ($job) { - return Limit::perHour(1); - }); + $rateLimiter->for('test', fn () => SlidingWindow::perSecond(1, 2)); $this->assertJobRanSuccessfully(RateLimitedTestJob::class); - $this->assertJobWasReleased(RateLimitedTestJob::class); + $this->assertJobWasReleasedAfter(RateLimitedTestJob::class, 6); } public function testRateLimitedJobsCanBeSkippedOnLimitReached(): void @@ -177,6 +148,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 +251,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 +294,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/RateLimitedWithRedisTest.php b/tests/Integration/Queue/Redis/RateLimitedRedisStoreTest.php similarity index 87% rename from tests/Integration/Queue/RateLimitedWithRedisTest.php rename to tests/Integration/Queue/Redis/RateLimitedRedisStoreTest.php index 9db695298..12b32e025 100644 --- a/tests/Integration/Queue/RateLimitedWithRedisTest.php +++ b/tests/Integration/Queue/Redis/RateLimitedRedisStoreTest.php @@ -2,28 +2,29 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Queue\RateLimitedWithRedisTest; +namespace Hypervel\Tests\Integration\Queue\Redis\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/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/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php similarity index 99% rename from tests/Integration/Queue/RedisQueueTest.php rename to tests/Integration/Queue/Redis/RedisQueueTest.php index 8d0cd0c5f..4214685d3 100644 --- a/tests/Integration/Queue/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Queue\RedisQueueTest; +namespace Hypervel\Tests\Integration\Queue\Redis\RedisQueueTest; use Hypervel\Container\Container; use Hypervel\Contracts\Events\Dispatcher; diff --git a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php similarity index 74% rename from tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php rename to tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php index 4ac4cbd09..5b9852cf4 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php +++ b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Queue\ThrottlesExceptionsWithRedisTest; +namespace Hypervel\Tests\Integration\Queue\Redis\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/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 diff --git a/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php new file mode 100644 index 000000000..fbc352a6c --- /dev/null +++ b/tests/Integration/RateLimiter/Database/DatabaseStoreTestCase.php @@ -0,0 +1,331 @@ +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(0, (int) $row->secondary_value); + $this->assertGreaterThan(0, (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 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(); + $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('secondary_value')->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, + 'secondary_value' => 0, + 'expires_at' => 1, + ]); + } + + DB::table('rate_limits')->insert([ + 'key' => str_repeat('l', 32), + 'value' => 1, + 'secondary_value' => 0, + '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, + 'secondary_value' => 0, + '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([ + '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, + 'secondary_value' => 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 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(); + $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.'); + } + + public function testConcurrentSlidingWindowFirstUseAdmitsExactlyTheConfiguredCapacity(): 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/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php new file mode 100644 index 000000000..eec307b18 --- /dev/null +++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php @@ -0,0 +1,442 @@ +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 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'); + $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); + } + + #[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'); + $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'), + SlidingWindow::perMinute(20)->cost(3)->by('concurrent-weighted-sliding'), + 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 + { + 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, + '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'); + $sliding = SlidingWindow::perMinute(2)->by('encoded-sliding'); + $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->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()); + + $redis = $this->rawRedisClientWithoutPrefix($connection); + + 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)), + ); + $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/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 4c045cebb..6c2b84985 100644 --- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php +++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php @@ -202,6 +202,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/Integration/Routing/RoutingServiceProviderTest.php b/tests/Integration/Routing/RoutingServiceProviderTest.php index f6f94a878..da77c6c97 100644 --- a/tests/Integration/Routing/RoutingServiceProviderTest.php +++ b/tests/Integration/Routing/RoutingServiceProviderTest.php @@ -4,12 +4,9 @@ namespace Hypervel\Tests\Integration\Routing; -use Hypervel\Routing\Middleware\ThrottleRequestsWithRedis; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use function Hypervel\Coroutine\parallel; - class RoutingServiceProviderTest extends RoutingTestCase { public function testResolvingPsrRequest(): void @@ -25,34 +22,4 @@ public function testResolvingPsrResponse(): void $this->assertInstanceOf(ResponseInterface::class, $psrResponse); } - - public function testRedisThrottleResolvesAsAFreshInstance(): void - { - $this->assertNotSame( - $this->app->make(ThrottleRequestsWithRedis::class), - $this->app->make(ThrottleRequestsWithRedis::class), - ); - } - - public function testRedisThrottleStateIsIsolatedBetweenConcurrentResolutions(): void - { - $results = parallel([ - function (): int { - $middleware = $this->app->make(ThrottleRequestsWithRedis::class); - $middleware->remaining['shared-key'] = 1; - usleep(5000); - - return $middleware->remaining['shared-key']; - }, - function (): int { - $middleware = $this->app->make(ThrottleRequestsWithRedis::class); - $middleware->remaining['shared-key'] = 2; - usleep(1000); - - return $middleware->remaining['shared-key']; - }, - ]); - - $this->assertSame([1, 2], $results); - } } 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']); 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; 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..ab9be8b7a --- /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, + 'secondary_value' => 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, + 'secondary_value' => 0, + '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, + 'secondary_value' => 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, + 'secondary_value' => 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, + 'secondary_value' => 0, + '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, + 'secondary_value' => 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, + 'secondary_value' => 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['secondary_value'] === 0 + && $state['expires_at'] > 60_000_000; + })) + ->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..bdf690d08 --- /dev/null +++ b/tests/RateLimiter/Fixtures/RateLimiterStoreContract.php @@ -0,0 +1,267 @@ +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 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(); + $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..2c96563e4 --- /dev/null +++ b/tests/RateLimiter/KeyResolverTest.php @@ -0,0 +1,159 @@ + 'tenant:7'); + + $this->assertSame( + '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'), + ); + $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 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'); + + $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); + $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..7e6970ed4 --- /dev/null +++ b/tests/RateLimiter/LeakyBucketTest.php @@ -0,0 +1,114 @@ +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 + { + $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..73af735f0 --- /dev/null +++ b/tests/RateLimiter/LimitTest.php @@ -0,0 +1,97 @@ +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()); + } + + #[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. + + 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..2d32404b8 --- /dev/null +++ b/tests/RateLimiter/LimiterTest.php @@ -0,0 +1,222 @@ + 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), + SlidingWindow::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 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; + $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..c53b5c3d4 --- /dev/null +++ b/tests/RateLimiter/RedisStoreTest.php @@ -0,0 +1,146 @@ +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 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 = []; + $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/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/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); + } +} diff --git a/tests/RateLimiter/SwooleStoreConcurrencyTest.php b/tests/RateLimiter/SwooleStoreConcurrencyTest.php new file mode 100644 index 000000000..737fa1476 --- /dev/null +++ b/tests/RateLimiter/SwooleStoreConcurrencyTest.php @@ -0,0 +1,309 @@ +state(); + $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); + } + } + + public static function admissionPolicyProvider(): array + { + return [ + 'fixed window' => [Limit::perMinute(50)], + 'sliding window' => [SlidingWindow::perMinute(50)], + ]; + } + + /** + * 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..88adf2c2f --- /dev/null +++ b/tests/RateLimiter/SwooleStoreTest.php @@ -0,0 +1,322 @@ +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 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(); + $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, + 'secondary_value' => 0, + '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, + 'secondary_value' => $expiresAt + 1, + 'expires_at' => $expiresAt, + ])); + + $this->expectException(UnexpectedValueException::class); + + $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} + */ + 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, + 'secondary_value' => 0, + '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..f22ace369 --- /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, + 'secondary_value' => AdmissionPolicy::MAX_INTEGER, + 'expires_at' => AdmissionPolicy::MAX_INTEGER, + ])); + $this->assertSame([ + 'value' => AdmissionPolicy::MAX_INTEGER, + 'secondary_value' => 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..fb28d33bf --- /dev/null +++ b/tests/RateLimiter/WorkerArrayStoreTest.php @@ -0,0 +1,215 @@ +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 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'); + 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 $secondaryValue, int $expiresAt): void + { + $this->states[$key] = [ + 'value' => $value, + 'secondary_value' => $secondaryValue, + 'expires_at' => $expiresAt, + ]; + } + + /** + * @return array{int, int, int} + */ + public function stateFor(string $key): array + { + return $this->state($key); + } +} 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); + } } 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/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..1dbfe8854 100644 --- a/tests/Reverb/Protocols/Pusher/ServerTest.php +++ b/tests/Reverb/Protocols/Pusher/ServerTest.php @@ -5,21 +5,25 @@ namespace Hypervel\Tests\Reverb\Protocols\Pusher; use Hypervel\Contracts\Debug\ExceptionHandler; +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 { @@ -171,7 +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); + $server = $this->app->makeWith(Server::class, ['handler' => $handler]); $server->message( $connection = new FakeConnection, json_encode([ @@ -665,12 +669,12 @@ public function testRejectsAMessageWhenTheRateLimitIsExceeded(): void $this->assertFalse($connection->wasTerminated); } - public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): void + public function testEnforcesRateLimitConfiguredWithNumericStrings(): void { $this->app['config']->set('reverb.apps.apps.0.rate_limiting', [ 'enabled' => true, - 'max_attempts' => 1, - 'decay_seconds' => 60, + 'max_attempts' => '1', + 'decay_seconds' => '60', 'terminate_on_limit' => false, ]); @@ -684,13 +688,49 @@ public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): void ]) ); - $this->assertTrue( - $this->app->make('cache')->store('worker-array')->has('reverb:message:' . $connection->id()) + $this->server->message( + $connection, + json_encode([ + 'event' => 'pusher:subscribe', + 'data' => ['channel' => 'test-channel-overflow'], + ]) ); - $this->assertFalse( - $this->app->make('cache')->store('array')->has('reverb:message:' . $connection->id()) + + $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, + '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'], + ]) ); + $connection->assertReceived([ + 'event' => 'pusher_internal:subscription_succeeded', + 'data' => '{}', + 'channel' => 'test-channel', + ]); + $this->server->message( $connection, json_encode([ @@ -710,6 +750,16 @@ public function testMessageRateLimiterUsesWorkerLifetimeCacheStore(): 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', [ @@ -728,16 +778,28 @@ public function testCloseClearsInitializedMessageRateLimiterState(): void ]) ); - $cache = $this->app->make('cache')->store('worker-array'); - $key = 'reverb:message:' . $connection->id(); - $this->assertTrue($connection->hasInitializedRateLimiter()); - $this->assertTrue($cache->has($key)); $this->server->close($connection); $this->assertFalse($connection->hasInitializedRateLimiter()); - $this->assertFalse($cache->has($key)); + + $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 @@ -778,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); @@ -892,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); + } +} 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')); diff --git a/tests/Routing/PackageMetadataTest.php b/tests/Routing/PackageMetadataTest.php index 10e44995e..325724a37 100644 --- a/tests/Routing/PackageMetadataTest.php +++ b/tests/Routing/PackageMetadataTest.php @@ -28,7 +28,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'ext-hash' => '*', 'hypervel/auth' => '^0.4', 'hypervel/prompts' => '^0.4', - 'hypervel/redis' => '^0.4', + 'hypervel/rate-limiter' => '^0.4', 'laravel/serializable-closure' => '^2.0.10', 'psr/http-message' => '^2.0', ] as $dependency => $constraint) { 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 32b9a6627..cc9300355 100644 --- a/tests/Routing/ThrottleRequestsTest.php +++ b/tests/Routing/ThrottleRequestsTest.php @@ -5,20 +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\Contracts\Redis\Factory as RedisFactory; use Hypervel\Http\Request; -use Hypervel\Redis\RedisProxy; +use Hypervel\RateLimiter\Exceptions\InvalidRateLimitException; use Hypervel\Routing\Middleware\ThrottleRequests; -use Hypervel\Routing\Middleware\ThrottleRequestsWithRedis; +use Hypervel\Routing\Route; use Hypervel\Tests\Routing\RoutingTestCase; -use Mockery as m; use RuntimeException; -use Symfony\Component\HttpFoundation\Response; + +enum NamedLimiter: string +{ + case Uploads = 'uploads'; +} class ThrottleRequestsTest extends RoutingTestCase { @@ -31,181 +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(1, $limiter->attempts(hash('xxh128', '7:uploads0:'))); - } - - public function testUnlimitedNamedLimiterBypassesStorage(): 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', - ); - - $this->assertSame(1, $nextCalls); - $this->assertSame(0, $limiter->attempts(hash('xxh128', '7:uploads0:'))); - } - - public function testDefaultSignatureThrottleDoesNotResolveNamedLimiterScope(): void - { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $scopeCalls = 0; - $limiter->resolveKeyScopeUsing(function () use (&$scopeCalls): string { - ++$scopeCalls; - - 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'))); - } - - public function testRedisThrottleUsesOneAtomicAcquireForOrdinaryLimits(): void - { - $connection = m::mock(RedisProxy::class); - $connection->shouldReceive('eval')->once()->andReturn([1, time() + 60, 1]); - - $response = $this->redisThrottle(Limit::perMinute(2), $connection)->handle( - Request::create('/upload'), - fn () => new Response('ok'), - 'uploads', - ); - - $this->assertSame('1', $response->headers->get('X-RateLimit-Remaining')); - } - - public function testRedisThrottleUsesOnePrecheckForAnExcludedResponse(): void - { - $connection = m::mock(RedisProxy::class); - $connection->shouldReceive('eval')->once()->andReturn([time() + 60, 2]); - - $response = $this->redisThrottle( - Limit::perMinute(2)->after(fn (Response $response): bool => $response->getStatusCode() === 404), - $connection, - )->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('2', $response->headers->get('X-RateLimit-Remaining')); + $this->assertSame(ThrottleRequests::class . ':60,1,foo', ThrottleRequests::with(prefix: 'foo')); } - public function testRedisThrottleUsesAPrecheckAndAcquireForAQualifyingResponse(): void + public function testFractionalMiddlewareDurationsRoundUpToAWholeSecond(): void { - $connection = m::mock(RedisProxy::class); - $connection->shouldReceive('eval') - ->twice() - ->andReturn([time() + 60, 2], [1, time() + 60, 1]); - - $response = $this->redisThrottle( - Limit::perMinute(2)->after(fn (Response $response): bool => $response->getStatusCode() === 404), - $connection, - )->handle( - Request::create('/upload'), - fn () => new Response('not found', 404), - 'uploads', - ); + $middleware = new ExposesThrottleRequests; - $this->assertSame('1', $response->headers->get('X-RateLimit-Remaining')); + $this->assertSame(60, $middleware->resolveDecaySecondsForTest(1)); + $this->assertSame(30, $middleware->resolveDecaySecondsForTest('0.5')); + $this->assertSame(1, $middleware->resolveDecaySecondsForTest(0.001)); } - private function redisThrottle(Limit $limit, RedisProxy $connection): ThrottleRequestsWithRedis + public function testNonNumericMiddlewareDurationIsRejected(): void { - $limiter = new RateLimiter(new Repository(new ArrayStore)); - $limiter->for('uploads', fn () => $limit); - - $redis = m::mock(RedisFactory::class); - $redis->shouldReceive('connection')->andReturn($connection); + $this->expectException(InvalidRateLimitException::class); + $this->expectExceptionMessage('The rate limit decay minutes must be numeric.'); - return new ThrottleRequestsWithRedis($limiter, $redis); + (new ExposesThrottleRequests)->resolveDecaySecondsForTest('invalid'); } } -class ExposesThrottleRequestSignature extends ThrottleRequests +class ExposesThrottleRequests extends ThrottleRequests { public function __construct() { @@ -215,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); + } } 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 {