diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 98ebb1ff..69f7ffd4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,6 +34,7 @@ jobs: - '8.2' - '8.4' - '8.6' + - '8.8' steps: @@ -41,7 +42,8 @@ jobs: run: | # Mapping of original redis versions to client test containers declare -A redis_clients_version_mapping=( - ["8.6"]="8.6.0" + ["8.8"]="unstable-23321515778-debian" + ["8.6"]="8.6.1" ["8.4"]="8.4.0" ["8.2"]="8.2.2" ["8.0"]="8.0.2" diff --git a/CHANGELOG.md b/CHANGELOG.md index f7686eee..cbceaebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Added support for GCRA command (#1657) - Handle `-READONLY` responses in Redis Cluster for AWS ElastiCache Redis OSS failover events ### Changed - Include command name in unsupported container command error messages (#1653) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 4047265a..d55d7074 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -142,6 +142,7 @@ use Predis\Command\Redis\VADD; * @method $this ftsyndump(string $index) * @method $this ftsynupdate(string $index, string $synonymGroupId, ?SynUpdateArguments $arguments = null, string ...$terms) * @method $this fttagvals(string $index, string $fieldName) + * @method $this gcra(string $key, int $maxBurst, int $requestsPerPeriod, float $period, ?int $numRequests = null) * @method $this get($key) * @method $this getbit($key, $offset) * @method $this getex(string $key, $modifier = '', $value = false) diff --git a/src/ClientInterface.php b/src/ClientInterface.php index a56ce940..0ff66a92 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -152,6 +152,7 @@ use Predis\Response\Status; * @method array ftsyndump(string $index) * @method Status ftsynupdate(string $index, string $synonymGroupId, ?SynUpdateArguments $arguments = null, string ...$terms) * @method array fttagvals(string $index, string $fieldName) + * @method array gcra(string $key, int $maxBurst, int $requestsPerPeriod, float $period, ?int $numRequests = null) * @method string|null get(string $key) * @method int getbit(string $key, $offset) * @method int|null getex(string $key, $modifier = '', $value = false) diff --git a/src/Command/Redis/GCRA.php b/src/Command/Redis/GCRA.php new file mode 100644 index 00000000..96bf329e --- /dev/null +++ b/src/Command/Redis/GCRA.php @@ -0,0 +1,83 @@ +responseSchema, $data); + } + + /** + * {@inheritdoc} + */ + public function parseResp3Response($data) + { + return $this->parseResponse($data); + } + + /** + * {@inheritdoc} + */ + public function prefixKeys($prefix) + { + $this->applyPrefixForFirstArgument($prefix); + } +} diff --git a/tests/Predis/Command/Redis/GCRA_Test.php b/tests/Predis/Command/Redis/GCRA_Test.php new file mode 100644 index 00000000..4478da90 --- /dev/null +++ b/tests/Predis/Command/Redis/GCRA_Test.php @@ -0,0 +1,288 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $raw = [0, 5, 4, -1, 10]; + $expected = [ + 'limited' => 0, + 'maxRequests' => 5, + 'availableRequests' => 4, + 'retryAfter' => -1, + 'fullBurstAfter' => 10, + ]; + $command = $this->getCommand(); + + $this->assertSame($expected, $command->parseResponse($raw)); + } + + /** + * @group disconnected + */ + public function testPrefixKeys(): void + { + /** @var PrefixableCommand $command */ + $command = $this->getCommand(); + $actualArguments = ['mykey', 4, 10, 1.0]; + $prefix = 'prefix:'; + $expectedArguments = ['prefix:mykey', 4, 10, 1.0]; + + $command->setArguments($actualArguments); + $command->prefixKeys($prefix); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testRequiredArgumentsDrainAllowanceByOne(): void + { + $redis = $this->getClient(); + + // max_burst=4, requests_per_period=10, period=10 => 10 req/10s, emission_interval=1s + // With max_burst=4, max-req-num = 4+1 = 5 + $result = $redis->gcra('ratelimit:user1', 4, 10, 10); + $expectedResult = [ + 'limited' => 0, + 'maxRequests' => 5, + 'availableRequests' => 4, + 'retryAfter' => -1, + 'fullBurstAfter' => 1, + ]; + + // First request: not limited + $this->assertSame($expectedResult, $result); + + $result = $redis->gcra('ratelimit:user1', 4, 10, 10); + $expectedResult = [ + 'limited' => 0, + 'maxRequests' => 5, + 'availableRequests' => 3, + 'retryAfter' => -1, + 'fullBurstAfter' => 2, + ]; + + // Second request: allowance drained by 1 + $this->assertSame($expectedResult, $result); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testRequiredArgumentsDrainAllowanceByOneResp3(): void + { + $redis = $this->getResp3Client(); + + // max_burst=4, requests_per_period=10, period=10 => 10 req/10s, emission_interval=1s + // With max_burst=4, max-req-num = 4+1 = 5 + $result = $redis->gcra('ratelimit:user1', 4, 10, 10); + $expectedResult = [ + 'limited' => 0, + 'maxRequests' => 5, + 'availableRequests' => 4, + 'retryAfter' => -1, + 'fullBurstAfter' => 1, + ]; + + // First request: not limited + $this->assertSame($expectedResult, $result); + + $result = $redis->gcra('ratelimit:user1', 4, 10, 10); + $expectedResult = [ + 'limited' => 0, + 'maxRequests' => 5, + 'availableRequests' => 3, + 'retryAfter' => -1, + 'fullBurstAfter' => 2, + ]; + + // Second request: allowance drained by 1 + $this->assertSame($expectedResult, $result); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testRequestsPerPeriodAndPeriodCombination(): void + { + $redis = $this->getClient(); + + // max_burst=0, requests_per_period=1, period=60 + // Only 1 request allowed per 60 seconds, no burst + // max-req-num = 0+1 = 1 + $result1 = $redis->gcra('ratelimit:strict', 0, 1, 60); + $expectedResult1 = [ + 'limited' => 0, + 'maxRequests' => 1, + 'availableRequests' => 0, + 'retryAfter' => -1, + 'fullBurstAfter' => 60, + ]; + + // First request: not limited + $this->assertSame($expectedResult1, $result1); + + // Second request is limited (no burst, only 1/60s allowed) + $result2 = $redis->gcra('ratelimit:strict', 0, 1, 60); + $this->assertSame(1, $result2['limited']); + $this->assertSame(1, $result2['maxRequests']); + $this->assertSame(0, $result2['availableRequests']); + $this->assertGreaterThan(0, $result2['retryAfter']); // retry-after > 0 (must wait) + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testNumRequestsDrainsAllowanceByCount(): void + { + $redis = $this->getClient(); + + // max_burst=9, requests_per_period=10, period=10 => max-req-num = 10 + // Use NUM_REQUESTS=3 to drain 3 at once + $result1 = $redis->gcra('ratelimit:bulk', 9, 10, 10, 3); + $expectedResult1 = [ + 'limited' => 0, + 'maxRequests' => 10, + 'availableRequests' => 7, + 'retryAfter' => -1, + 'fullBurstAfter' => 3, + ]; + + // First request: not limited, 7 available (10 - 3) + $this->assertSame($expectedResult1, $result1); + + // Another request with NUM_REQUESTS=3 + $result2 = $redis->gcra('ratelimit:bulk', 9, 10, 10, 3); + $expectedResult2 = [ + 'limited' => 0, + 'maxRequests' => 10, + 'availableRequests' => 4, + 'retryAfter' => -1, + 'fullBurstAfter' => 6, + ]; + + // Second request: not limited, 4 available (7 - 3) + $this->assertSame($expectedResult2, $result2); + + // Another request with NUM_REQUESTS=3 + $result3 = $redis->gcra('ratelimit:bulk', 9, 10, 10, 3); + $expectedResult3 = [ + 'limited' => 0, + 'maxRequests' => 10, + 'availableRequests' => 1, + 'retryAfter' => -1, + 'fullBurstAfter' => 9, + ]; + + // Third request: not limited, 1 available (4 - 3) + $this->assertSame($expectedResult3, $result3); + + // Next request with NUM_REQUESTS=3 should be limited (only 1 left) + $result4 = $redis->gcra('ratelimit:bulk', 9, 10, 10, 3); + $this->assertSame(1, $result4['limited']); + $this->assertGreaterThan(0, $result4['retryAfter']); // must wait + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testRetryAfterBehaviorAllowsRequestsAfterWaiting(): void + { + $redis = $this->getClient(); + + // max_burst=2, requests_per_period=3, period=3 => 3 req/3s, emission_interval=1s + // max-req-num = 2+1 = 3 + // Drain all allowance with 3 requests + $redis->gcra('ratelimit:retry', 2, 3, 3); + $redis->gcra('ratelimit:retry', 2, 3, 3); + $redis->gcra('ratelimit:retry', 2, 3, 3); + + // Next request should be limited + $limitedResult = $redis->gcra('ratelimit:retry', 2, 3, 3); + $this->assertSame(1, $limitedResult['limited']); + $this->assertGreaterThan(0, $limitedResult['retryAfter']); + + // Sleep for retryAfter seconds + $retryAfterSeconds = $limitedResult['retryAfter']; + sleep($retryAfterSeconds); + + // After waiting, request should no longer be limited + $afterWaitResult = $redis->gcra('ratelimit:retry', 2, 3, 3); + $this->assertSame(0, $afterWaitResult['limited']); + $this->assertSame(-1, $afterWaitResult['retryAfter']); + } + + public function argumentsProvider(): array + { + return [ + 'with required arguments only' => [ + ['mykey', 4, 10, 1.0], + ['mykey', 4, 10, 1.0], + ], + 'with NUM_REQUESTS' => [ + ['mykey', 4, 10, 1.0, 5], + ['mykey', 4, 10, 1.0, 'NUM_REQUESTS', 5], + ], + 'with null NUM_REQUESTS (omitted)' => [ + ['mykey', 4, 10, 1.0, null], + ['mykey', 4, 10, 1.0], + ], + ]; + } +}