From d275ce3e703ec8c19b13a69efd69049140dcfe5b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:50 +0530 Subject: [PATCH 01/20] feat(sliding-window): add storage-agnostic SlidingWindow adapter contract --- src/Abuse/Adapters/SlidingWindow.php | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow.php diff --git a/src/Abuse/Adapters/SlidingWindow.php b/src/Abuse/Adapters/SlidingWindow.php new file mode 100644 index 0000000..6c6712e --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow.php @@ -0,0 +1,105 @@ +limit - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the limit integer + * + * @return int + */ + public function limit(): int + { + return $this->limit; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the counters for the current key so the limit starts fresh. + * Implementations must clear both the current and previous window buckets. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} From 0dbc1f055a61cedcb975c570168e6c38e91a62e4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:50 +0530 Subject: [PATCH 02/20] feat(sliding-window): add RedisBase with atomic Lua check-and-increment --- .../Adapters/SlidingWindow/RedisBase.php | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow/RedisBase.php diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php new file mode 100644 index 0000000..c10f066 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -0,0 +1,217 @@ += max_requests then + return { 0, 0, math.floor(current_count) } + end + + local new_count = redis.call('INCR', current_key) + redis.call('EXPIRE', current_key, ttl) + + local new_estimate = weighted_prev + new_count + local remaining = math.max(0, math.floor(max_requests - new_estimate)) + return { 1, remaining, new_count } + LUA; + + /** + * @var int + */ + protected int $windowSize; + + /** + * @var int + */ + protected int $ttl; + + /** + * @var float + */ + protected float $elapsed; + + /** + * Run the atomic check-and-increment script against the storage backend. + * + * @param list $keys current + previous bucket keys + * @param list $argv max_requests, elapsed, ttl + * @return array{0:int,1:int,2:int} { allowed, remaining, current_count } + */ + abstract protected function evaluateLimit(array $keys, array $argv): array; + + /** + * Read a bucket counter as an int (missing key => 0). + * + * @param string $key + * @return int + */ + abstract protected function bucketCount(string $key): int; + + /** + * Delete the given bucket keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function deleteBuckets(string ...$keys): void; + + /** + * Initialise the window boundaries from the configured window size and ttl. + * Both `timestamp` (window start) and `elapsed` are derived from a single + * `now` so they stay consistent with the bucket being written. + * + * @param int $windowSize + * @param int $ttl + * @return void + */ + protected function initWindow(int $windowSize, int $ttl): void + { + if ($ttl < $windowSize) { + throw new \InvalidArgumentException('ttl must be greater than or equal to windowSize'); + } + + $now = \time(); + $this->windowSize = $windowSize; + $this->ttl = $ttl; + $this->timestamp = (int)($now - ($now % $windowSize)); // start of the current window + $this->elapsed = ($now - $this->timestamp) / $windowSize; // always in [0,1) + } + + /** + * Build a bucket key. The hash tag around $key forces the current and previous + * window buckets into the same cluster slot, so the multi-key Lua script and + * reset() do not raise CROSSSLOT on a Redis Cluster (harmless on single Redis). + * + * @param string $key + * @param int $timestamp + * @return string + */ + protected function bucketKey(string $key, int $timestamp): string + { + return self::NAMESPACE . '__{' . $key . '}__' . $timestamp; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->limit === 0) { + return false; + } + + $key = $this->parseKey(); + + $result = $this->evaluateLimit( + [ + $this->bucketKey($key, $this->timestamp), // KEYS[1] current bucket + $this->bucketKey($key, $this->timestamp - $this->windowSize), // KEYS[2] previous bucket + ], + [ + $this->limit, // ARGV[1] max_requests + $this->elapsed, // ARGV[2] elapsed fraction + $this->ttl, // ARGV[3] ttl seconds + ], + ); + + [$allowed, , $count] = $result; + $this->count = $count; + + return $allowed === 0; + } + + /** + * Count + * + * Read-only weighted estimate of hits in the current sliding window + * (current bucket + weighted previous bucket). Used by remaining(). + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if (0 == $this->limit) { + return 0; + } + + if (! \is_null($this->count)) { + return $this->count; + } + + $current = $this->bucketCount($this->bucketKey($key, $timestamp)); + $previous = $this->bucketCount($this->bucketKey($key, $timestamp - $this->windowSize)); + + $this->count = (int) \floor($current + $previous * (1 - $this->elapsed)); + + return $this->count; + } + + /** + * Reset + * + * Clear both the current and previous window buckets so the limit starts fresh. + * + * @return void + */ + public function reset(): void + { + $key = $this->parseKey(); + + $this->deleteBuckets( + $this->bucketKey($key, $this->timestamp), + $this->bucketKey($key, $this->timestamp - $this->windowSize), + ); + + $this->count = 0; + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} From f28109173175d4c9d4e1ea253baab18b04450420 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:50 +0530 Subject: [PATCH 03/20] feat(sliding-window): add Redis adapter --- src/Abuse/Adapters/SlidingWindow/Redis.php | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow/Redis.php diff --git a/src/Abuse/Adapters/SlidingWindow/Redis.php b/src/Abuse/Adapters/SlidingWindow/Redis.php new file mode 100644 index 0000000..b667a85 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/Redis.php @@ -0,0 +1,98 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \Redis $redis Redis connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \Redis $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param list $keys + * @param list $argv + * @return array{0:int,1:int,2:int} + * + * @throws \RedisException + */ + protected function evaluateLimit(array $keys, array $argv): array + { + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); + + return $result; + } + + /** + * @param string $key + * @return int + * + * @throws \RedisException + */ + protected function bucketCount(string $key): int + { + $raw = $this->redis->get($key); + + return \is_numeric($raw) ? (int) $raw : 0; + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function deleteBuckets(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->get($key); + } + + return $logs; + } +} From d68199f2a74effc550e35a8e79553fa8ad760a99 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:59 +0530 Subject: [PATCH 04/20] feat(sliding-window): add RedisCluster adapter with hash-tagged keys --- .../Adapters/SlidingWindow/RedisCluster.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow/RedisCluster.php diff --git a/src/Abuse/Adapters/SlidingWindow/RedisCluster.php b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php new file mode 100644 index 0000000..e15371e --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php @@ -0,0 +1,95 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param \RedisCluster $redis Redis Cluster connection used for storage + */ + public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \RedisCluster $redis) + { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param list $keys + * @param list $argv + * @return array{0:int,1:int,2:int} + * + * @throws \RedisClusterException + */ + protected function evaluateLimit(array $keys, array $argv): array + { + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); + + return $result; + } + + /** + * @param string $key + * @return int + * + * @throws \RedisClusterException + */ + protected function bucketCount(string $key): int + { + $raw = $this->redis->get($key); + + return \is_numeric($raw) ? (int) $raw : 0; + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function deleteBuckets(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $this->redis->mget($matches); + + return array_combine($matches, $values); + } +} From 2d15f05922f0751c3f1993de3a07d10012fa7187 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:59 +0530 Subject: [PATCH 05/20] feat(sliding-window): add RedisPool adapter --- .../Adapters/SlidingWindow/RedisPool.php | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow/RedisPool.php diff --git a/src/Abuse/Adapters/SlidingWindow/RedisPool.php b/src/Abuse/Adapters/SlidingWindow/RedisPool.php new file mode 100644 index 0000000..2159a28 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/RedisPool.php @@ -0,0 +1,162 @@ += $windowSize so the + * previous window's bucket survives long enough to be weighted + * @param UtopiaPool<\Redis>|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $limit, + int $windowSize, + int $ttl, + protected UtopiaPool $pool + ) { + $this->initWindow($windowSize, $ttl); + } + + /** + * @param list $keys + * @param list $argv + * @return array{0:int,1:int,2:int} + */ + protected function evaluateLimit(array $keys, array $argv): array + { + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys, $argv): array { + /** @var array{0:int,1:int,2:int} $result */ + $result = $redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); + + return $result; + }); + + return $result; + } + + /** + * @param string $key + * @return int + */ + protected function bucketCount(string $key): int + { + /** @var int $value */ + $value = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($key): int { + $raw = $redis->get($key); + + return \is_numeric($raw) ? (int) $raw : 0; + }); + + return $value; + } + + /** + * @param string ...$keys + * @return void + */ + protected function deleteBuckets(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->get($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $values = $redis->mget($matches); + if (!\is_array($values)) { + return []; + } + + $logs = \array_combine($matches, $values); + if (!\is_array($logs)) { + return []; + } + + return $logs; + } +} From 5daae51a376fb55b8275c31587aed9bdf216b9f3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:59 +0530 Subject: [PATCH 06/20] test(sliding-window): add shared adapter test base --- tests/Abuse/SlidingWindow/Base.php | 169 +++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 tests/Abuse/SlidingWindow/Base.php diff --git a/tests/Abuse/SlidingWindow/Base.php b/tests/Abuse/SlidingWindow/Base.php new file mode 100644 index 0000000..5781993 --- /dev/null +++ b/tests/Abuse/SlidingWindow/Base.php @@ -0,0 +1,169 @@ +getAdapter('sw-static-key', 2, 1, 2); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a limit of 2 requests per window + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('sw-dynamic-key-{{ip}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('sw-two-params-{{ip}}-{{email}}', 2, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a higher request rate like 10 requests per window + */ + public function testFastRequests(): void + { + $adapter = $this->getAdapter('sw-fast-requests-{{ip}}', 10, 1, 2); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the correct number of allowed requests + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('sw-remaining-{{ip}}', 3, 60, 120); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // nothing counted yet: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 used + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 used + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that the window resets once both buckets expire + */ + public function testWindowExpiry(): void + { + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 3; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Wait for both the current and previous buckets (ttl = 2) to expire + sleep(3); + + // A fresh adapter recomputes the window; the old buckets are gone + $adapter = $this->getAdapter('sw-window-expiry-{{ip}}', 3, 1, 2); + $adapter->setParam('{{ip}}', '127.0.0.1'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + } + + /** + * Verify that time() returns the aligned window start as an int + */ + public function testTimeFormat(): void + { + $windowSize = 1; + $now = \time(); + $adapter = $this->getAdapter('sw-time', 1, $windowSize, 2); + $this->assertSame((int)($now - ($now % $windowSize)), $adapter->time()); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality clears both buckets + */ + public function testReset(): void + { + $adapter = $this->getAdapter('sw-reset-test-{{ip}}', 5, 600, 1200); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset clears the counters + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a ttl smaller than the window size is rejected + */ + public function testTtlGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('sw-guard', 1, 10, 5); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('sw-unlimited', 0, 1, 2); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} From 4d8c86bd0d9e0143d023a70f14f0fe5a3d21ec21 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:37:59 +0530 Subject: [PATCH 07/20] test(sliding-window): add Redis, RedisCluster and RedisPool test cases --- .../Abuse/SlidingWindow/RedisClusterTest.php | 45 ++++++++++++++++ tests/Abuse/SlidingWindow/RedisPoolTest.php | 53 +++++++++++++++++++ tests/Abuse/SlidingWindow/RedisTest.php | 43 +++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 tests/Abuse/SlidingWindow/RedisClusterTest.php create mode 100644 tests/Abuse/SlidingWindow/RedisPoolTest.php create mode 100644 tests/Abuse/SlidingWindow/RedisTest.php diff --git a/tests/Abuse/SlidingWindow/RedisClusterTest.php b/tests/Abuse/SlidingWindow/RedisClusterTest.php new file mode 100644 index 0000000..cfaa8f0 --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisClusterTest.php @@ -0,0 +1,45 @@ +close(); + } + } +} diff --git a/tests/Abuse/SlidingWindow/RedisPoolTest.php b/tests/Abuse/SlidingWindow/RedisPoolTest.php new file mode 100644 index 0000000..20dbb8e --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisPoolTest.php @@ -0,0 +1,53 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-sw-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect('redis', 6379); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('sw-pool-' . $key, $limit, $windowSize, $ttl, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/tests/Abuse/SlidingWindow/RedisTest.php b/tests/Abuse/SlidingWindow/RedisTest.php new file mode 100644 index 0000000..126cea2 --- /dev/null +++ b/tests/Abuse/SlidingWindow/RedisTest.php @@ -0,0 +1,43 @@ +connect('redis', 6379); + + return $redis; + } + + public function getAdapter(string $key, int $limit, int $windowSize, int $ttl): SlidingWindow + { + return new AdapterRedis($key, $limit, $windowSize, $ttl, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} From 5c016a824f39f6bf68fcfc1780228a58ad5bd91e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:51:38 +0530 Subject: [PATCH 08/20] refactor(sliding-window): reduce adapter seams to eval/get/delete primitives --- src/Abuse/Adapters/SlidingWindow/Redis.php | 20 ++++------ .../Adapters/SlidingWindow/RedisBase.php | 37 +++++++++++-------- .../Adapters/SlidingWindow/RedisCluster.php | 20 ++++------ .../Adapters/SlidingWindow/RedisPool.php | 30 ++++----------- 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/src/Abuse/Adapters/SlidingWindow/Redis.php b/src/Abuse/Adapters/SlidingWindow/Redis.php index b667a85..af22065 100644 --- a/src/Abuse/Adapters/SlidingWindow/Redis.php +++ b/src/Abuse/Adapters/SlidingWindow/Redis.php @@ -18,31 +18,27 @@ public function __construct(protected string $key, protected int $limit, int $wi } /** + * @param string $script * @param list $keys * @param list $argv - * @return array{0:int,1:int,2:int} + * @return mixed * * @throws \RedisException */ - protected function evaluateLimit(array $keys, array $argv): array + protected function eval(string $script, array $keys, array $argv): mixed { - /** @var array{0:int,1:int,2:int} $result */ - $result = $this->redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); - - return $result; + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); } /** * @param string $key - * @return int + * @return mixed * * @throws \RedisException */ - protected function bucketCount(string $key): int + protected function get(string $key): mixed { - $raw = $this->redis->get($key); - - return \is_numeric($raw) ? (int) $raw : 0; + return $this->redis->get($key); } /** @@ -51,7 +47,7 @@ protected function bucketCount(string $key): int * * @throws \RedisException */ - protected function deleteBuckets(string ...$keys): void + protected function delete(string ...$keys): void { $this->redis->del(...$keys); } diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php index c10f066..aa3ef08 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisBase.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -8,8 +8,7 @@ * Shared implementation for the Redis-family sliding-window adapters * (Redis, RedisCluster, RedisPool). Owns the atomic Lua script, the window * math and the check/count/reset flow. Subclasses only implement how they - * talk to storage via the evaluateLimit()/bucketCount()/deleteBuckets() seams, plus - * their own getLogs(). + * talk to storage via the eval()/get()/delete() seams, plus their own getLogs(). */ abstract class RedisBase extends SlidingWindow { @@ -64,29 +63,30 @@ abstract class RedisBase extends SlidingWindow protected float $elapsed; /** - * Run the atomic check-and-increment script against the storage backend. + * Run a Lua script against the storage backend. * - * @param list $keys current + previous bucket keys - * @param list $argv max_requests, elapsed, ttl - * @return array{0:int,1:int,2:int} { allowed, remaining, current_count } + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result */ - abstract protected function evaluateLimit(array $keys, array $argv): array; + abstract protected function eval(string $script, array $keys, array $argv): mixed; /** - * Read a bucket counter as an int (missing key => 0). + * Get the raw value stored at $key (null/false when missing). * * @param string $key - * @return int + * @return mixed */ - abstract protected function bucketCount(string $key): int; + abstract protected function get(string $key): mixed; /** - * Delete the given bucket keys. + * Delete the given keys. * * @param string ...$keys * @return void */ - abstract protected function deleteBuckets(string ...$keys): void; + abstract protected function delete(string ...$keys): void; /** * Initialise the window boundaries from the configured window size and ttl. @@ -139,7 +139,9 @@ public function check(): bool $key = $this->parseKey(); - $result = $this->evaluateLimit( + /** @var array{0:int,1:int,2:int} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, [ $this->bucketKey($key, $this->timestamp), // KEYS[1] current bucket $this->bucketKey($key, $this->timestamp - $this->windowSize), // KEYS[2] previous bucket @@ -177,8 +179,11 @@ protected function count(string $key, int $timestamp): int return $this->count; } - $current = $this->bucketCount($this->bucketKey($key, $timestamp)); - $previous = $this->bucketCount($this->bucketKey($key, $timestamp - $this->windowSize)); + $currentRaw = $this->get($this->bucketKey($key, $timestamp)); + $previousRaw = $this->get($this->bucketKey($key, $timestamp - $this->windowSize)); + + $current = \is_numeric($currentRaw) ? (int) $currentRaw : 0; + $previous = \is_numeric($previousRaw) ? (int) $previousRaw : 0; $this->count = (int) \floor($current + $previous * (1 - $this->elapsed)); @@ -196,7 +201,7 @@ public function reset(): void { $key = $this->parseKey(); - $this->deleteBuckets( + $this->delete( $this->bucketKey($key, $this->timestamp), $this->bucketKey($key, $this->timestamp - $this->windowSize), ); diff --git a/src/Abuse/Adapters/SlidingWindow/RedisCluster.php b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php index e15371e..6e519a8 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisCluster.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisCluster.php @@ -18,31 +18,27 @@ public function __construct(protected string $key, protected int $limit, int $wi } /** + * @param string $script * @param list $keys * @param list $argv - * @return array{0:int,1:int,2:int} + * @return mixed * * @throws \RedisClusterException */ - protected function evaluateLimit(array $keys, array $argv): array + protected function eval(string $script, array $keys, array $argv): mixed { - /** @var array{0:int,1:int,2:int} $result */ - $result = $this->redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); - - return $result; + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); } /** * @param string $key - * @return int + * @return mixed * * @throws \RedisClusterException */ - protected function bucketCount(string $key): int + protected function get(string $key): mixed { - $raw = $this->redis->get($key); - - return \is_numeric($raw) ? (int) $raw : 0; + return $this->redis->get($key); } /** @@ -51,7 +47,7 @@ protected function bucketCount(string $key): int * * @throws \RedisClusterException */ - protected function deleteBuckets(string ...$keys): void + protected function delete(string ...$keys): void { $this->redis->del(...$keys); } diff --git a/src/Abuse/Adapters/SlidingWindow/RedisPool.php b/src/Abuse/Adapters/SlidingWindow/RedisPool.php index 2159a28..fd0729a 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisPool.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisPool.php @@ -25,44 +25,30 @@ public function __construct( } /** + * @param string $script * @param list $keys * @param list $argv - * @return array{0:int,1:int,2:int} + * @return mixed */ - protected function evaluateLimit(array $keys, array $argv): array + protected function eval(string $script, array $keys, array $argv): mixed { - /** @var array{0:int,1:int,2:int} $result */ - $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys, $argv): array { - /** @var array{0:int,1:int,2:int} $result */ - $result = $redis->eval(self::LIMIT_CHECK_SCRIPT, [...$keys, ...$argv], \count($keys)); - - return $result; - }); - - return $result; + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); } /** * @param string $key - * @return int + * @return mixed */ - protected function bucketCount(string $key): int + protected function get(string $key): mixed { - /** @var int $value */ - $value = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($key): int { - $raw = $redis->get($key); - - return \is_numeric($raw) ? (int) $raw : 0; - }); - - return $value; + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->get($key)); } /** * @param string ...$keys * @return void */ - protected function deleteBuckets(string ...$keys): void + protected function delete(string ...$keys): void { $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { $redis->del(...$keys); From bf8336dd410e1e6c8cf310ec2118d80c4c7157e0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 18:55:29 +0530 Subject: [PATCH 09/20] fix(sliding-window): validate window/ttl bounds and cache weighted estimate - reject windowSize <= 0 to avoid DivisionByZeroError - require ttl >= 2*windowSize so the previous bucket outlives the current window - return and cache the weighted estimate so remaining() stays consistent with check() --- .../Adapters/SlidingWindow/RedisBase.php | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php index aa3ef08..83c66b4 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisBase.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -20,7 +20,8 @@ abstract class RedisBase extends SlidingWindow * KEYS[1] current bucket, KEYS[2] previous bucket. * ARGV[1] max_requests, ARGV[2] elapsed fraction of current window [0,1), ARGV[3] ttl seconds. * - * Returns { allowed (0|1), remaining, current_count }. + * Returns { allowed (0|1), remaining, estimate } where estimate is the + * weighted sliding-window count (current bucket + weighted previous bucket). */ protected const string LIMIT_CHECK_SCRIPT = <<<'LUA' local current_key = KEYS[1] @@ -36,7 +37,7 @@ abstract class RedisBase extends SlidingWindow local estimated = weighted_prev + current_count if estimated >= max_requests then - return { 0, 0, math.floor(current_count) } + return { 0, 0, math.floor(estimated) } end local new_count = redis.call('INCR', current_key) @@ -44,7 +45,7 @@ abstract class RedisBase extends SlidingWindow local new_estimate = weighted_prev + new_count local remaining = math.max(0, math.floor(max_requests - new_estimate)) - return { 1, remaining, new_count } + return { 1, remaining, math.floor(new_estimate) } LUA; /** @@ -93,14 +94,27 @@ abstract protected function delete(string ...$keys): void; * Both `timestamp` (window start) and `elapsed` are derived from a single * `now` so they stay consistent with the bucket being written. * + * The window is captured once, at construction. An adapter instance is meant + * to be short-lived (created per request); reusing one across a window + * boundary keeps operating on the construction-time window. This matches the + * TimeLimit adapters' behaviour - construct a fresh adapter per check. + * * @param int $windowSize * @param int $ttl * @return void */ protected function initWindow(int $windowSize, int $ttl): void { - if ($ttl < $windowSize) { - throw new \InvalidArgumentException('ttl must be greater than or equal to windowSize'); + if ($windowSize <= 0) { + throw new \InvalidArgumentException('windowSize must be greater than 0'); + } + + // The previous bucket keeps contributing (weighted) throughout the whole + // current window, and its ttl is set from its last write - which in the + // worst case is at the very start of its own window. It therefore needs to + // survive up to two full windows, so ttl must be >= 2 * windowSize. + if ($ttl < $windowSize * 2) { + throw new \InvalidArgumentException('ttl must be at least twice the windowSize so the previous window bucket outlives the current window'); } $now = \time(); @@ -153,8 +167,10 @@ public function check(): bool ], ); - [$allowed, , $count] = $result; - $this->count = $count; + // $estimate is the weighted sliding-window count, so a following + // remaining() call stays consistent with what check() decided on. + [$allowed, , $estimate] = $result; + $this->count = $estimate; return $allowed === 0; } From 25016c0d256d655e1124b6f82b1ff018152c5030 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 12 Aug 2026 19:08:02 +0530 Subject: [PATCH 10/20] refactor(sliding-window): improve window computation and state management --- .../Adapters/SlidingWindow/RedisBase.php | 84 +++++++++++++------ 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php index 83c66b4..ade19ad 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisBase.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -9,6 +9,10 @@ * (Redis, RedisCluster, RedisPool). Owns the atomic Lua script, the window * math and the check/count/reset flow. Subclasses only implement how they * talk to storage via the eval()/get()/delete() seams, plus their own getLogs(). + * + * The window (start timestamp + elapsed fraction) is recomputed from the clock + * on every operation, so an adapter instance stays correct even if it is reused + * across a window boundary. */ abstract class RedisBase extends SlidingWindow { @@ -59,9 +63,11 @@ abstract class RedisBase extends SlidingWindow protected int $ttl; /** - * @var float + * Window start the cached $count was computed for (null when no cache). + * + * @var int|null */ - protected float $elapsed; + protected ?int $countTimestamp = null; /** * Run a Lua script against the storage backend. @@ -90,14 +96,9 @@ abstract protected function get(string $key): mixed; abstract protected function delete(string ...$keys): void; /** - * Initialise the window boundaries from the configured window size and ttl. - * Both `timestamp` (window start) and `elapsed` are derived from a single - * `now` so they stay consistent with the bucket being written. - * - * The window is captured once, at construction. An adapter instance is meant - * to be short-lived (created per request); reusing one across a window - * boundary keeps operating on the construction-time window. This matches the - * TimeLimit adapters' behaviour - construct a fresh adapter per check. + * Validate and store the window configuration. The window itself is derived + * live in window(); here we only seed $timestamp so the inherited property is + * initialised before remaining()/time() read it. * * @param int $windowSize * @param int $ttl @@ -117,11 +118,22 @@ protected function initWindow(int $windowSize, int $ttl): void throw new \InvalidArgumentException('ttl must be at least twice the windowSize so the previous window bucket outlives the current window'); } - $now = \time(); $this->windowSize = $windowSize; $this->ttl = $ttl; - $this->timestamp = (int)($now - ($now % $windowSize)); // start of the current window - $this->elapsed = ($now - $this->timestamp) / $windowSize; // always in [0,1) + [$this->timestamp] = $this->window(); + } + + /** + * Compute the live window from the current time. + * + * @return array{0:int,1:float} [window start timestamp, elapsed fraction in [0,1)] + */ + private function window(): array + { + $now = \time(); + $timestamp = (int)($now - ($now % $this->windowSize)); // start of the current window + + return [$timestamp, ($now - $timestamp) / $this->windowSize]; } /** @@ -138,6 +150,20 @@ protected function bucketKey(string $key, int $timestamp): string return self::NAMESPACE . '__{' . $key . '}__' . $timestamp; } + /** + * Time + * + * Start timestamp of the current window, recomputed from the clock. + * + * @return int + */ + public function time(): int + { + [$this->timestamp] = $this->window(); + + return $this->timestamp; + } + /** * Check * @@ -152,18 +178,20 @@ public function check(): bool } $key = $this->parseKey(); + [$timestamp, $elapsed] = $this->window(); + $this->timestamp = $timestamp; /** @var array{0:int,1:int,2:int} $result */ $result = $this->eval( self::LIMIT_CHECK_SCRIPT, [ - $this->bucketKey($key, $this->timestamp), // KEYS[1] current bucket - $this->bucketKey($key, $this->timestamp - $this->windowSize), // KEYS[2] previous bucket + $this->bucketKey($key, $timestamp), // KEYS[1] current bucket + $this->bucketKey($key, $timestamp - $this->windowSize), // KEYS[2] previous bucket ], [ - $this->limit, // ARGV[1] max_requests - $this->elapsed, // ARGV[2] elapsed fraction - $this->ttl, // ARGV[3] ttl seconds + $this->limit, // ARGV[1] max_requests + $elapsed, // ARGV[2] elapsed fraction + $this->ttl, // ARGV[3] ttl seconds ], ); @@ -171,6 +199,7 @@ public function check(): bool // remaining() call stays consistent with what check() decided on. [$allowed, , $estimate] = $result; $this->count = $estimate; + $this->countTimestamp = $timestamp; return $allowed === 0; } @@ -191,17 +220,21 @@ protected function count(string $key, int $timestamp): int return 0; } - if (! \is_null($this->count)) { + [$windowStart, $elapsed] = $this->window(); + $this->timestamp = $windowStart; + + if ($this->count !== null && $this->countTimestamp === $windowStart) { return $this->count; } - $currentRaw = $this->get($this->bucketKey($key, $timestamp)); - $previousRaw = $this->get($this->bucketKey($key, $timestamp - $this->windowSize)); + $currentRaw = $this->get($this->bucketKey($key, $windowStart)); + $previousRaw = $this->get($this->bucketKey($key, $windowStart - $this->windowSize)); $current = \is_numeric($currentRaw) ? (int) $currentRaw : 0; $previous = \is_numeric($previousRaw) ? (int) $previousRaw : 0; - $this->count = (int) \floor($current + $previous * (1 - $this->elapsed)); + $this->count = (int) \floor($current + $previous * (1 - $elapsed)); + $this->countTimestamp = $windowStart; return $this->count; } @@ -216,13 +249,16 @@ protected function count(string $key, int $timestamp): int public function reset(): void { $key = $this->parseKey(); + [$windowStart] = $this->window(); + $this->timestamp = $windowStart; $this->delete( - $this->bucketKey($key, $this->timestamp), - $this->bucketKey($key, $this->timestamp - $this->windowSize), + $this->bucketKey($key, $windowStart), + $this->bucketKey($key, $windowStart - $this->windowSize), ); $this->count = 0; + $this->countTimestamp = $windowStart; } /** From 252a10dfd1919003f75681ee31357e23cc09e47b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:01 +0530 Subject: [PATCH 11/20] feat(token-bucket): add agnostic adapter and Redis-family base Introduce the storage-agnostic TokenBucket adapter (mirrors SlidingWindow: count()/remaining()/limit()/time()) and RedisBase, which owns the atomic refill-and-consume Lua script, a read-only refill estimate and the eval()/delete() seams. Bucket state is a single Redis hash; ttl is derived from capacity/refillRate. --- src/Abuse/Adapters/TokenBucket.php | 107 +++++++++ src/Abuse/Adapters/TokenBucket/RedisBase.php | 239 +++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisBase.php diff --git a/src/Abuse/Adapters/TokenBucket.php b/src/Abuse/Adapters/TokenBucket.php new file mode 100644 index 0000000..899ce6e --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket.php @@ -0,0 +1,107 @@ +tokens - ($this->count($this->parseKey(), $this->timestamp) + 1); + + return (0 > $left) ? 0 : $left; + } + + /** + * Limit + * + * Return the bucket capacity + * + * @return int + */ + public function limit(): int + { + return $this->tokens; + } + + /** + * Time + * + * Return the timestamp + * + * @return int + */ + public function time(): int + { + return $this->timestamp; + } + + /** + * Reset + * + * Clear the bucket for the current key so it starts full again. + * + * @return void + * + * @throws \Exception + */ + abstract public function reset(): void; +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php new file mode 100644 index 0000000..fb633b8 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -0,0 +1,239 @@ += 1 then + tokens = tokens - 1 + allowed = 1 + end + + redis.call('HSET', key, 'tokens', tostring(tokens), 'last_refill', tostring(now)) + redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate) + 1) + + return { allowed, tostring(tokens) } + LUA; + + /** + * Read-only token estimate: refills the bucket for the elapsed time without + * consuming anything or writing back. Used by remaining(). + * + * KEYS[1] bucket hash key. + * ARGV[1] max_tokens, ARGV[2] refill_rate, ARGV[3] now. + * + * Returns the available token balance as a string. + */ + protected const string TOKENS_SCRIPT = <<<'LUA' + local key = KEYS[1] + local max_tokens = tonumber(ARGV[1]) + local refill_rate = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + + local data = redis.call('HMGET', key, 'tokens', 'last_refill') + local tokens = tonumber(data[1]) or max_tokens + local last_refill = tonumber(data[2]) or now + + local elapsed = now - last_refill + if elapsed < 0 then elapsed = 0 end + tokens = math.min(max_tokens, tokens + elapsed * refill_rate) + + return tostring(tokens) + LUA; + + /** + * Tokens refilled per second. + * + * @var float + */ + protected float $refillRate; + + /** + * Run a Lua script against the storage backend. + * + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed the raw script result + */ + abstract protected function eval(string $script, array $keys, array $argv): mixed; + + /** + * Delete the given keys. + * + * @param string ...$keys + * @return void + */ + abstract protected function delete(string ...$keys): void; + + /** + * Validate and store the bucket configuration. + * + * @param float $refillRate + * @return void + */ + protected function initBucket(float $refillRate): void + { + if ($refillRate <= 0) { + throw new \InvalidArgumentException('refillRate must be greater than 0'); + } + + $this->refillRate = $refillRate; + $this->timestamp = \time(); + } + + /** + * Build the bucket hash key for a given abuse key. + * + * @param string $key + * @return string + */ + protected function bucketKey(string $key): string + { + return self::NAMESPACE . '__' . $key; + } + + /** + * Check + * + * @return bool + * + * @throws \Throwable + */ + public function check(): bool + { + if ($this->tokens === 0) { + return false; + } + + $key = $this->parseKey(); + $this->timestamp = \time(); + + /** @var array{0:int,1:string} $result */ + $result = $this->eval( + self::LIMIT_CHECK_SCRIPT, + [ + $this->bucketKey($key), // KEYS[1] bucket hash + ], + [ + $this->tokens, // ARGV[1] max_tokens + $this->refillRate, // ARGV[2] refill_rate + \microtime(true), // ARGV[3] now (fractional seconds) + ], + ); + + // $available is the token balance left after consuming; store the + // consumed count so a following remaining() stays consistent with it. + [$allowed, $available] = $result; + $balance = \is_numeric($available) ? (float) $available : 0.0; + $this->count = (int) \floor($this->tokens - $balance); + + return (int) $allowed === 0; + } + + /** + * Count + * + * Read-only estimate of the tokens already consumed from the bucket + * (capacity minus the tokens available after refilling). Used by remaining(). + * Reuses the value recorded by the most recent check()/reset() this request; + * otherwise reads a fresh estimate from storage. + * + * @param string $key + * @param int $timestamp + * @return int + */ + protected function count(string $key, int $timestamp): int + { + if ($this->tokens === 0) { + return 0; + } + + $this->timestamp = \time(); + + if ($this->count !== null) { + return $this->count; + } + + $raw = $this->eval( + self::TOKENS_SCRIPT, + [ + $this->bucketKey($key), + ], + [ + $this->tokens, + $this->refillRate, + \microtime(true), + ], + ); + + $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; + $this->count = (int) \floor($this->tokens - $balance); + + return $this->count; + } + + /** + * Reset + * + * Drop the bucket state so the next request sees a full bucket. + * + * @return void + */ + public function reset(): void + { + $this->delete($this->bucketKey($this->parseKey())); + + $this->count = 0; + } + + /** + * No need for manual cleanup - Redis TTL handles this automatically + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} From 1830b617832666b77793b92f86c3460080e4feeb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:10 +0530 Subject: [PATCH 12/20] feat(token-bucket): add Redis, RedisCluster and RedisPool adapters Concrete adapters implementing the eval()/delete() seams plus getLogs() (reads bucket hashes via hGetAll). Constructor takes (key, tokens, refillRate). --- src/Abuse/Adapters/TokenBucket/Redis.php | 81 +++++++++++ .../Adapters/TokenBucket/RedisCluster.php | 81 +++++++++++ src/Abuse/Adapters/TokenBucket/RedisPool.php | 131 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket/Redis.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisCluster.php create mode 100644 src/Abuse/Adapters/TokenBucket/RedisPool.php diff --git a/src/Abuse/Adapters/TokenBucket/Redis.php b/src/Abuse/Adapters/TokenBucket/Redis.php new file mode 100644 index 0000000..58e2402 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/Redis.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $this->redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisCluster.php b/src/Abuse/Adapters/TokenBucket/RedisCluster.php new file mode 100644 index 0000000..ced320c --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisCluster.php @@ -0,0 +1,81 @@ +initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + * + * @throws \RedisClusterException + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->redis->eval($script, [...$keys, ...$argv], \count($keys)); + } + + /** + * @param string ...$keys + * @return void + * + * @throws \RedisClusterException + */ + protected function delete(string ...$keys): void + { + $this->redis->del(...$keys); + } + + /** + * Get abuse logs with cursor-based pagination across masters + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = 0, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($this->redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $this->redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + $matches = array_merge($matches, $keys); + } + } while ($cursor > 0 && count($matches) < $offset + $limit); + } + + sort($matches); + $matches = array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $this->redis->hGetAll($key); + } + + return $logs; + } +} diff --git a/src/Abuse/Adapters/TokenBucket/RedisPool.php b/src/Abuse/Adapters/TokenBucket/RedisPool.php new file mode 100644 index 0000000..34cfec9 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/RedisPool.php @@ -0,0 +1,131 @@ +|UtopiaPool<\RedisCluster> $pool Pool yielding a Redis or RedisCluster connection + */ + public function __construct( + protected string $key, + protected int $tokens, + float $refillRate, + protected UtopiaPool $pool + ) { + $this->initBucket($refillRate); + } + + /** + * @param string $script + * @param list $keys + * @param list $argv + * @return mixed + */ + protected function eval(string $script, array $keys, array $argv): mixed + { + return $this->pool->use(fn (\Redis|\RedisCluster $redis): mixed => $redis->eval($script, [...$keys, ...$argv], \count($keys))); + } + + /** + * @param string ...$keys + * @return void + */ + protected function delete(string ...$keys): void + { + $this->pool->use(function (\Redis|\RedisCluster $redis) use ($keys): void { + $redis->del(...$keys); + }); + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + $offset = $offset ?? 0; + $limit = $limit ?? 25; + + /** @var array $result */ + $result = $this->pool->use(function (\Redis|\RedisCluster $redis) use ($offset, $limit): array { + if ($redis instanceof \RedisCluster) { + return $this->getRedisClusterLogs($redis, $offset, $limit); + } + + $cursor = null; + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + do { + $keys = $redis->scan($cursor, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + }); + + return $result; + } + + /** + * @param \RedisCluster $redis + * @param int $offset + * @param int $limit + * @return array + */ + private function getRedisClusterLogs(\RedisCluster $redis, int $offset, int $limit): array + { + $matches = []; + $pattern = self::NAMESPACE . '__*'; + + foreach ($redis->_masters() as $master) { + $cursor = null; + do { + /** @phpstan-ignore-next-line */ + $keys = $redis->scan($cursor, $master, $pattern, 100); + if ($keys !== false) { + \array_push($matches, ...$keys); + } + } while ($cursor > 0); + } + + \sort($matches); + $matches = \array_slice($matches, $offset, $limit); + + if (empty($matches)) { + return []; + } + + $logs = []; + foreach ($matches as $key) { + $logs[$key] = $redis->hGetAll($key); + } + + return $logs; + } +} From 84f4424310081e034ffafecb6c52bbecaa7b4945 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:43:10 +0530 Subject: [PATCH 13/20] test(token-bucket): add Redis, RedisCluster and RedisPool test cases Shared Base suite covering burst/capacity, remaining, refill over time, reset, refill-rate guard and unlimited (tokens 0). --- tests/Abuse/TokenBucket/Base.php | 160 +++++++++++++++++++ tests/Abuse/TokenBucket/RedisClusterTest.php | 45 ++++++ tests/Abuse/TokenBucket/RedisPoolTest.php | 53 ++++++ tests/Abuse/TokenBucket/RedisTest.php | 43 +++++ 4 files changed, 301 insertions(+) create mode 100644 tests/Abuse/TokenBucket/Base.php create mode 100644 tests/Abuse/TokenBucket/RedisClusterTest.php create mode 100644 tests/Abuse/TokenBucket/RedisPoolTest.php create mode 100644 tests/Abuse/TokenBucket/RedisTest.php diff --git a/tests/Abuse/TokenBucket/Base.php b/tests/Abuse/TokenBucket/Base.php new file mode 100644 index 0000000..e9ce08b --- /dev/null +++ b/tests/Abuse/TokenBucket/Base.php @@ -0,0 +1,160 @@ +getAdapter('tb-static-key', 2, 0.001); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with a capacity of 2 tokens + */ + public function testDynamicKey(): void + { + $adapter = $this->getAdapter('tb-dynamic-key-{{ip}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test a dynamic key with 2 params + */ + public function testDynamicKeyWith2Params(): void + { + $adapter = $this->getAdapter('tb-two-params-{{ip}}-{{email}}', 2, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.10'); + $adapter->setParam('{{email}}', 'test@test.com'); + $abuse = new Abuse($adapter); + $this->assertSame(false, $abuse->check()); + $this->assertSame(false, $abuse->check()); + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a full bucket allows a burst up to its capacity + */ + public function testBurst(): void + { + $adapter = $this->getAdapter('tb-burst-{{ip}}', 10, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.11'); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 10; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that remaining reports the tokens still available + */ + public function testRemaining(): void + { + $adapter = $this->getAdapter('tb-remaining-{{ip}}', 3, 0.001); + $adapter->setParam('{{ip}}', '0.0.0.12'); + $abuse = new Abuse($adapter); + + $this->assertSame(2, $adapter->remaining()); // full bucket: limit - (0 + 1) + $this->assertSame(false, $abuse->check()); // 1 consumed + $this->assertSame(1, $adapter->remaining()); + $this->assertSame(false, $abuse->check()); // 2 consumed + $this->assertSame(0, $adapter->remaining()); + } + + /** + * Test that tokens refill over time + */ + public function testRefill(): void + { + // 1 token/sec, capacity 1: consume it, then a refill lets one more through + $adapter = $this->getAdapter('tb-refill-{{ip}}', 1, 1.0); + $adapter->setParam('{{ip}}', '0.0.0.13'); + $abuse = new Abuse($adapter); + + $this->assertSame(false, $abuse->check()); // consume the only token + $this->assertSame(true, $abuse->check()); // empty, throttled + + sleep(2); // refill ~2 tokens (capped at capacity 1) + + $this->assertSame(false, $abuse->check()); // refilled, allowed again + } + + /** + * Verify that time() returns the current time as an int + */ + public function testTimeFormat(): void + { + $adapter = $this->getAdapter('tb-time', 1, 1.0); + $this->assertSame(true, \is_int($adapter->time())); + } + + /** + * Test the reset functionality refills the bucket + */ + public function testReset(): void + { + $adapter = $this->getAdapter('tb-reset-test-{{ip}}', 5, 0.001); + $adapter->setParam('{{ip}}', '192.168.1.1'); + $abuse = new Abuse($adapter); + + // 5 OK, 6th limited + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + + // Reset refills the bucket + $abuse->reset(); + + // 5 more OK, then limited again + for ($i = 0; $i < 5; $i++) { + $this->assertSame(false, $abuse->check()); + } + $this->assertSame(true, $abuse->check()); + } + + /** + * Test that a non-positive refill rate is rejected + */ + public function testRefillRateGuard(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->getAdapter('tb-guard', 1, 0.0); + } + + /** + * Test that limit 0 means unlimited + */ + public function testUnlimited(): void + { + $adapter = $this->getAdapter('tb-unlimited', 0, 1.0); + $abuse = new Abuse($adapter); + for ($i = 0; $i < 20; $i++) { + $this->assertSame(false, $abuse->check()); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisClusterTest.php b/tests/Abuse/TokenBucket/RedisClusterTest.php new file mode 100644 index 0000000..7b9a0c5 --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisClusterTest.php @@ -0,0 +1,45 @@ +close(); + } + } +} diff --git a/tests/Abuse/TokenBucket/RedisPoolTest.php b/tests/Abuse/TokenBucket/RedisPoolTest.php new file mode 100644 index 0000000..750666f --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisPoolTest.php @@ -0,0 +1,53 @@ +|null + */ + protected static ?Pool $pool = null; + + public static function setUpBeforeClass(): void + { + if (isset(self::$pool)) { + return; + } + + self::$pool = new Pool(new Stack(), 'abuse-tb-redis', 2, function (): \Redis { + $redis = new \Redis(); + $redis->connect('redis', 6379); + + return $redis; + }, timeout: 0.0); + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + $pool = self::$pool; + $this->assertInstanceOf(Pool::class, $pool); + + /** @var Pool<\Redis> $pool */ + return new AdapterRedisPool('tb-pool-' . $key, $tokens, $refillRate, $pool); + } + + public static function tearDownAfterClass(): void + { + if (!isset(self::$pool)) { + return; + } + + self::$pool->use(function (mixed $redis): void { + if ($redis instanceof \Redis) { + $redis->close(); + } + }); + self::$pool = null; + } +} diff --git a/tests/Abuse/TokenBucket/RedisTest.php b/tests/Abuse/TokenBucket/RedisTest.php new file mode 100644 index 0000000..bb1b94a --- /dev/null +++ b/tests/Abuse/TokenBucket/RedisTest.php @@ -0,0 +1,43 @@ +connect('redis', 6379); + + return $redis; + } + + public function getAdapter(string $key, int $tokens, float $refillRate): TokenBucket + { + return new AdapterRedis($key, $tokens, $refillRate, self::$redis); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$redis)) { + self::$redis->close(); + } + } +} From 70e1e4cdb5b9bf953e15b3d052e7c0a7444b2703 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 11:48:15 +0530 Subject: [PATCH 14/20] fix(token-bucket): floor available balance before deriving consumed count Fractional refill made floor(capacity - balance) undercount consumed tokens by one (e.g. balance 1.00001 -> 1 consumed instead of 2). Floor the available balance first, then subtract from capacity so remaining() stays exact. --- src/Abuse/Adapters/TokenBucket/RedisBase.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php index fb633b8..f806b7b 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisBase.php +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -165,7 +165,7 @@ public function check(): bool // consumed count so a following remaining() stays consistent with it. [$allowed, $available] = $result; $balance = \is_numeric($available) ? (float) $available : 0.0; - $this->count = (int) \floor($this->tokens - $balance); + $this->count = $this->tokens - (int) \floor($balance); return (int) $allowed === 0; } @@ -207,7 +207,7 @@ protected function count(string $key, int $timestamp): int ); $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; - $this->count = (int) \floor($this->tokens - $balance); + $this->count = $this->tokens - (int) \floor($balance); return $this->count; } From 6c152d3e3f4a98b80fed6e5e56459b351674e104 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 12:12:19 +0530 Subject: [PATCH 15/20] fix(token-bucket): always read a fresh refill estimate in count() The bucket refills continuously, so caching the consumed count made remaining() report a stale quota once time elapsed after a check(). Drop the cache and compute a fresh estimate on every count(); check() now only needs the allow decision. --- src/Abuse/Adapters/TokenBucket.php | 5 ----- src/Abuse/Adapters/TokenBucket/RedisBase.php | 19 ++++--------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/Abuse/Adapters/TokenBucket.php b/src/Abuse/Adapters/TokenBucket.php index 899ce6e..19d50d3 100644 --- a/src/Abuse/Adapters/TokenBucket.php +++ b/src/Abuse/Adapters/TokenBucket.php @@ -15,11 +15,6 @@ abstract class TokenBucket extends Adapter */ protected int $tokens = 0; - /** - * @var int|null - */ - protected ?int $count = null; - /** * @var int */ diff --git a/src/Abuse/Adapters/TokenBucket/RedisBase.php b/src/Abuse/Adapters/TokenBucket/RedisBase.php index f806b7b..6fa8ec9 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisBase.php +++ b/src/Abuse/Adapters/TokenBucket/RedisBase.php @@ -161,11 +161,7 @@ public function check(): bool ], ); - // $available is the token balance left after consuming; store the - // consumed count so a following remaining() stays consistent with it. - [$allowed, $available] = $result; - $balance = \is_numeric($available) ? (float) $available : 0.0; - $this->count = $this->tokens - (int) \floor($balance); + [$allowed] = $result; return (int) $allowed === 0; } @@ -175,8 +171,8 @@ public function check(): bool * * Read-only estimate of the tokens already consumed from the bucket * (capacity minus the tokens available after refilling). Used by remaining(). - * Reuses the value recorded by the most recent check()/reset() this request; - * otherwise reads a fresh estimate from storage. + * The bucket refills continuously, so this always reads a fresh estimate + * rather than reusing a cached value that would go stale as tokens refill. * * @param string $key * @param int $timestamp @@ -190,10 +186,6 @@ protected function count(string $key, int $timestamp): int $this->timestamp = \time(); - if ($this->count !== null) { - return $this->count; - } - $raw = $this->eval( self::TOKENS_SCRIPT, [ @@ -207,9 +199,8 @@ protected function count(string $key, int $timestamp): int ); $balance = \is_numeric($raw) ? (float) $raw : (float) $this->tokens; - $this->count = $this->tokens - (int) \floor($balance); - return $this->count; + return $this->tokens - (int) \floor($balance); } /** @@ -222,8 +213,6 @@ protected function count(string $key, int $timestamp): int public function reset(): void { $this->delete($this->bucketKey($this->parseKey())); - - $this->count = 0; } /** From 6a011fe110fca922ac5cfdc7d91aff68994f6fda Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 12:12:19 +0530 Subject: [PATCH 16/20] fix(token-bucket): scan every cluster master fully before paginating logs Stopping each master's unordered scan at offset+limit and then sorting/slicing the partial candidate set returned a biased page and could omit keys in range. Scan all masters completely, then sort and slice. --- src/Abuse/Adapters/TokenBucket/RedisCluster.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Abuse/Adapters/TokenBucket/RedisCluster.php b/src/Abuse/Adapters/TokenBucket/RedisCluster.php index ced320c..beb86ec 100644 --- a/src/Abuse/Adapters/TokenBucket/RedisCluster.php +++ b/src/Abuse/Adapters/TokenBucket/RedisCluster.php @@ -61,7 +61,7 @@ public function getLogs(?int $offset = 0, ?int $limit = 25): array if ($keys !== false) { $matches = array_merge($matches, $keys); } - } while ($cursor > 0 && count($matches) < $offset + $limit); + } while ($cursor > 0); } sort($matches); From e5000d3a68460d836eea9b8534aa6469a7b066d4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 15:35:47 +0530 Subject: [PATCH 17/20] feat(sliding-window): add None adapter --- src/Abuse/Adapters/SlidingWindow/None.php | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/Abuse/Adapters/SlidingWindow/None.php diff --git a/src/Abuse/Adapters/SlidingWindow/None.php b/src/Abuse/Adapters/SlidingWindow/None.php new file mode 100644 index 0000000..a7e59b9 --- /dev/null +++ b/src/Abuse/Adapters/SlidingWindow/None.php @@ -0,0 +1,58 @@ +key = $key; + $this->limit = $limit; + $now = \time(); + $this->timestamp = (int) ($now - ($now % $windowSize)); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} From 4afb6a26477b64a61c32037ead4acdb45d2b8966 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 15:36:21 +0530 Subject: [PATCH 18/20] feat(token-bucket): add None adapter --- src/Abuse/Adapters/TokenBucket/None.php | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/Abuse/Adapters/TokenBucket/None.php diff --git a/src/Abuse/Adapters/TokenBucket/None.php b/src/Abuse/Adapters/TokenBucket/None.php new file mode 100644 index 0000000..cdec354 --- /dev/null +++ b/src/Abuse/Adapters/TokenBucket/None.php @@ -0,0 +1,57 @@ +key = $key; + $this->tokens = $tokens; + $this->timestamp = \time(); + } + + protected function count(string $key, int $timestamp): int + { + return 0; + } + + public function check(): bool + { + return false; + } + + public function reset(): void + { + } + + /** + * Get abuse logs + * + * Return logs with an offset and limit + * + * @param int|null $offset + * @param int|null $limit + * @return array + */ + public function getLogs(?int $offset = null, ?int $limit = 25): array + { + return []; + } + + /** + * Delete all logs older than $timestamp + * + * @param int $timestamp + * @return bool + */ + public function cleanup(int $timestamp): bool + { + return true; + } +} From fba428fc14618cf1502c26723a806a5200fb1c6f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 15:47:02 +0530 Subject: [PATCH 19/20] fix(sliding-window): guard None against zero-sized window --- src/Abuse/Adapters/SlidingWindow/None.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Abuse/Adapters/SlidingWindow/None.php b/src/Abuse/Adapters/SlidingWindow/None.php index a7e59b9..74f9554 100644 --- a/src/Abuse/Adapters/SlidingWindow/None.php +++ b/src/Abuse/Adapters/SlidingWindow/None.php @@ -11,6 +11,10 @@ class None extends SlidingWindow */ public function __construct(string $key, int $limit, int $windowSize, int $ttl) // @phpstan-ignore constructor.unusedParameter { + if ($windowSize <= 0) { + throw new \InvalidArgumentException('windowSize must be greater than 0'); + } + $this->key = $key; $this->limit = $limit; $now = \time(); From 49ac83a8a7072f088d1ddb384a3beeceb4c45f2d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 13 Aug 2026 15:47:02 +0530 Subject: [PATCH 20/20] fix(sliding-window): drop stale count cache so estimate keeps decaying --- src/Abuse/Adapters/SlidingWindow.php | 5 ---- .../Adapters/SlidingWindow/RedisBase.php | 25 ++----------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/Abuse/Adapters/SlidingWindow.php b/src/Abuse/Adapters/SlidingWindow.php index 6c6712e..a488db6 100644 --- a/src/Abuse/Adapters/SlidingWindow.php +++ b/src/Abuse/Adapters/SlidingWindow.php @@ -13,11 +13,6 @@ abstract class SlidingWindow extends Adapter */ protected int $limit = 0; - /** - * @var int|null - */ - protected ?int $count = null; - /** * @var int */ diff --git a/src/Abuse/Adapters/SlidingWindow/RedisBase.php b/src/Abuse/Adapters/SlidingWindow/RedisBase.php index ade19ad..39b357e 100644 --- a/src/Abuse/Adapters/SlidingWindow/RedisBase.php +++ b/src/Abuse/Adapters/SlidingWindow/RedisBase.php @@ -62,13 +62,6 @@ abstract class RedisBase extends SlidingWindow */ protected int $ttl; - /** - * Window start the cached $count was computed for (null when no cache). - * - * @var int|null - */ - protected ?int $countTimestamp = null; - /** * Run a Lua script against the storage backend. * @@ -195,11 +188,7 @@ public function check(): bool ], ); - // $estimate is the weighted sliding-window count, so a following - // remaining() call stays consistent with what check() decided on. - [$allowed, , $estimate] = $result; - $this->count = $estimate; - $this->countTimestamp = $timestamp; + [$allowed] = $result; return $allowed === 0; } @@ -223,20 +212,13 @@ protected function count(string $key, int $timestamp): int [$windowStart, $elapsed] = $this->window(); $this->timestamp = $windowStart; - if ($this->count !== null && $this->countTimestamp === $windowStart) { - return $this->count; - } - $currentRaw = $this->get($this->bucketKey($key, $windowStart)); $previousRaw = $this->get($this->bucketKey($key, $windowStart - $this->windowSize)); $current = \is_numeric($currentRaw) ? (int) $currentRaw : 0; $previous = \is_numeric($previousRaw) ? (int) $previousRaw : 0; - $this->count = (int) \floor($current + $previous * (1 - $elapsed)); - $this->countTimestamp = $windowStart; - - return $this->count; + return (int) \floor($current + $previous * (1 - $elapsed)); } /** @@ -256,9 +238,6 @@ public function reset(): void $this->bucketKey($key, $windowStart), $this->bucketKey($key, $windowStart - $this->windowSize), ); - - $this->count = 0; - $this->countTimestamp = $windowStart; } /**