diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 12f0712..e4ba40f 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -7,11 +7,13 @@ The logic of Async Cache PHP is built on a **Middleware Pipeline**. Every reques When you create a manager using `AsyncCacheManager::configure()`, the following middleware stack is assembled (in order): 1. **`CoalesceMiddleware`**: Prevents duplicate requests for the same key happening at the same time. If 10 requests come in for `key_A`, only one factory is executed, and the result is shared. -2. **`StaleOnErrorMiddleware`**: If the factory fails (throws an exception), this middleware tries to return stale data from the cache instead of propagating the error. -3. **`CacheLookupMiddleware`**: Checks if the item is in the cache. Handles `CacheStrategy` logic (Strict vs Background) and X-Fetch calculations. -4. **`TagValidationMiddleware`**: Validates cache tags and handles tag-based invalidation. -5. **`AsyncLockMiddleware`**: Acquires a lock before calling the factory to ensure only one process generates the data (Cache Stampede protection via locking). -6. **`SourceFetchMiddleware`**: The final handler. It executes the user's factory function and saves the result to the storage. +2. **`CacheLookupMiddleware`**: Fetches the item from the storage and populates the context with the cached data (if any). It also handles X-Fetch calculations. +3. **`TagValidationMiddleware`**: Validates cache tags and handles tag-based invalidation. It can short-circuit if the item is fresh and tags are valid. +4. **`StrategyMiddleware`**: **The decision maker.** It determines whether to return fresh data, return stale data immediately (Background strategy), or wait for a refresh (Strict strategy). +5. **`RateLimitMiddleware`**: Enforces rate limiting. If limited, it can fall back to stale data if `serve_stale_if_limited` is enabled. +6. **`AsyncLockMiddleware`**: Acquires an asynchronous lock to prevent Cache Stampedes. It also performs a "Double-Check" after acquiring the lock to see if the cache was already populated. +7. **`StaleOnErrorMiddleware`**: Catches exceptions from the source fetch and returns stale data as a fallback. +8. **`SourceFetchMiddleware`**: The final handler. It executes the user's factory function and saves the result to the storage. ## Additional Middleware diff --git a/docs/reference.md b/docs/reference.md index a2c4672..826659a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -39,12 +39,18 @@ Clears the entire cache storage. #### `delete(string $key): PromiseInterface` Deletes a specific item from the cache. +#### `resetRateLimit(string $key): void` +Resets the rate limit for a specific key. + +#### `getRateLimiter(): ?RateLimiterFactoryInterface` +Returns the rate limiter factory instance. + ## `AsyncCacheConfigBuilder` Fluent builder for configuring the manager. -### `withRateLimiter(LimiterInterface $rate_limiter): self` -Configures a Symfony Rate Limiter. +### `withRateLimiter(RateLimiterFactoryInterface $rate_limiter): self` +Configures a Symfony Rate Limiter factory. ### `withLogger(LoggerInterface $logger): self` Sets a PSR-3 logger. diff --git a/docs/usage/rate-limiting.md b/docs/usage/rate-limiting.md index 6c0ac51..0bcbde2 100644 --- a/docs/usage/rate-limiting.md +++ b/docs/usage/rate-limiting.md @@ -4,7 +4,7 @@ The library integrates with **Symfony Rate Limiter** to manage how often the dat ## Integration -Instead of implementing a custom interface, you can use any implementation of `Symfony\Component\RateLimiter\LimiterInterface`. +The library uses `Symfony\Component\RateLimiter\RateLimiterFactoryInterface`. This allows the manager to create specific limiters dynamically based on the `rate_limit_key` provided in the options. ### Example with Symfony Rate Limiter @@ -19,11 +19,10 @@ $factory = new RateLimiterFactory([ 'rate' => ['interval' => '10 seconds'], ], new InMemoryStorage()); -$limiter = $factory->create(); - +// Pass the factory, not a specific limiter $manager = new AsyncCacheManager( AsyncCacheManager::configure($cache) - ->withRateLimiter($limiter) + ->withRateLimiter($factory) ->build() ); ``` @@ -32,12 +31,21 @@ $manager = new AsyncCacheManager( When a cache item is stale and a refresh is needed: 1. The manager checks if a `rate_limit_key` is provided in `CacheOptions`. -2. It calls `$limiter->consume(1)`. -3. If **Accepted**: The factory function is called to fetch fresh data. -4. If **Rejected**: - - If `serve_stale_if_limited` is **true** and stale data exists, the stale data is returned. +2. It uses the factory to create/get a limiter for that key: `$factory->create($rate_limit_key)`. +3. It calls `->consume(1)` on that limiter. +4. If **Accepted**: The pipeline continues to fetch fresh data. +5. If **Rejected**: + - If `serve_stale_if_limited` is **true** and stale data exists in the context, the stale data is returned immediately. - Otherwise, a `RateLimitException` is thrown. +## Manual Reset + +If you need to manually reset the limit for a specific key (e.g. after an administrative action), use the manager: + +```php +$manager->resetRateLimit('user_123'); +``` + ## Benefit of Symfony Integration By using Symfony Rate Limiter, you gain access to various storage backends (Redis, Database, PHP-APC) and sophisticated policies (Token Bucket, Fixed Window, Sliding Window) without additional configuration in this library. diff --git a/src/AsyncCacheManager.php b/src/AsyncCacheManager.php index c3443fc..f0fec2f 100644 --- a/src/AsyncCacheManager.php +++ b/src/AsyncCacheManager.php @@ -32,8 +32,10 @@ use Fyennyi\AsyncCache\Middleware\AsyncLockMiddleware; use Fyennyi\AsyncCache\Middleware\CacheLookupMiddleware; use Fyennyi\AsyncCache\Middleware\CoalesceMiddleware; +use Fyennyi\AsyncCache\Middleware\RateLimitMiddleware; use Fyennyi\AsyncCache\Middleware\SourceFetchMiddleware; use Fyennyi\AsyncCache\Middleware\StaleOnErrorMiddleware; +use Fyennyi\AsyncCache\Middleware\StrategyMiddleware; use Fyennyi\AsyncCache\Middleware\TagValidationMiddleware; use Fyennyi\AsyncCache\Storage\AsyncCacheAdapterInterface; use Fyennyi\AsyncCache\Storage\CacheStorage; @@ -48,7 +50,7 @@ use Symfony\Component\Clock\NativeClock; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\Store\SemaphoreStore; -use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use function React\Promise\Timer\resolve as delay; /** @@ -60,7 +62,7 @@ final class AsyncCacheManager private CacheStorage $storage; private LockFactory $lock_factory; private ClockInterface $clock; - private ?LimiterInterface $rate_limiter; + private ?RateLimiterFactoryInterface $rate_limiter; /** * Creates the manager from configuration. @@ -86,11 +88,28 @@ public function __construct(AsyncCacheConfig $config) $this->storage = new CacheStorage($cache_adapter, $logger, $serializer, $this->clock); $default_middlewares = [ + // 1. Request deduplication new CoalesceMiddleware($logger), - new StaleOnErrorMiddleware($logger, $config->getDispatcher()), + + // 2. Cache lookup + freshness check new CacheLookupMiddleware($this->storage, $logger, $config->getDispatcher()), + + // 3. Tag validation new TagValidationMiddleware($this->storage, $logger), + + // 4. Strategy handling + new StrategyMiddleware($logger, $config->getDispatcher()), + + // 5. Rate limiting + new RateLimitMiddleware($this->rate_limiter, $logger, $config->getDispatcher()), + + // 6. Lock acquisition new AsyncLockMiddleware($this->lock_factory, $this->storage, $logger, $config->getDispatcher()), + + // 7. Stale-on-error fallback (NOW in correct position) + new StaleOnErrorMiddleware($logger, $config->getDispatcher()), + + // 8. Final source fetch new SourceFetchMiddleware($this->storage, $logger, $config->getDispatcher()) ]; @@ -133,7 +152,7 @@ public static function configure(PsrCacheInterface|ReactCacheInterface|AsyncCach * }); * @example * // Using an async factory that returns a Promise - * $manager->wrap('user:42', function () use ($http) { + * $manager->wrap('user:42', function use ($http) { * return $http->getJson('https://api.example/users/42'); * }, new CacheOptions(ttl: 60))->then(function ($value) { * // handle async result @@ -286,18 +305,20 @@ public function delete(string $key) : PromiseInterface /** * Returns the rate limiter instance. * - * @return LimiterInterface|null The Symfony Rate Limiter or null if not set + * @return RateLimiterFactoryInterface|null The Symfony Rate Limiter factory or null if not set */ - public function getRateLimiter() : ?LimiterInterface + public function getRateLimiter() : ?RateLimiterFactoryInterface { return $this->rate_limiter; } /** - * Resets the rate limiter state. + * Resets the rate limit for a specific key. + * + * @param string $key The rate limit key to reset */ - public function clearRateLimiter() : void + public function resetRateLimit(string $key) : void { - $this->rate_limiter?->reset(); + $this->rate_limiter?->create($key)->reset(); } } diff --git a/src/Config/AsyncCacheConfig.php b/src/Config/AsyncCacheConfig.php index 7b407a8..b634c30 100644 --- a/src/Config/AsyncCacheConfig.php +++ b/src/Config/AsyncCacheConfig.php @@ -34,7 +34,7 @@ use Psr\SimpleCache\CacheInterface as PsrCacheInterface; use React\Cache\CacheInterface as ReactCacheInterface; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; /** * Configuration object for AsyncCacheManager. @@ -52,7 +52,7 @@ final class AsyncCacheConfig */ public function __construct( private readonly PsrCacheInterface|ReactCacheInterface|AsyncCacheAdapterInterface $cache_adapter, - private readonly ?LimiterInterface $rate_limiter = null, + private readonly ?RateLimiterFactoryInterface $rate_limiter = null, private readonly ?LoggerInterface $logger = null, private readonly ?LockFactory $lock_factory = null, private readonly ?EventDispatcherInterface $dispatcher = null, @@ -68,7 +68,7 @@ public function getCacheAdapter() : PsrCacheInterface|ReactCacheInterface|AsyncC return $this->cache_adapter; } - public function getRateLimiter() : ?LimiterInterface + public function getRateLimiter() : ?RateLimiterFactoryInterface { return $this->rate_limiter; } diff --git a/src/Config/AsyncCacheConfigBuilder.php b/src/Config/AsyncCacheConfigBuilder.php index e3cb698..da6d157 100644 --- a/src/Config/AsyncCacheConfigBuilder.php +++ b/src/Config/AsyncCacheConfigBuilder.php @@ -34,14 +34,14 @@ use Psr\SimpleCache\CacheInterface as PsrCacheInterface; use React\Cache\CacheInterface as ReactCacheInterface; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; /** * Fluent builder for AsyncCacheConfig. */ final class AsyncCacheConfigBuilder { - private ?LimiterInterface $rate_limiter = null; + private ?RateLimiterFactoryInterface $rate_limiter = null; private ?LoggerInterface $logger = null; private ?LockFactory $lock_factory = null; /** @var MiddlewareInterface[] */ @@ -54,7 +54,7 @@ public function __construct( private readonly PsrCacheInterface|ReactCacheInterface|AsyncCacheAdapterInterface $cache_adapter ) {} - public function withRateLimiter(LimiterInterface $rate_limiter) : self + public function withRateLimiter(RateLimiterFactoryInterface $rate_limiter) : self { $this->rate_limiter = $rate_limiter; diff --git a/src/Middleware/AsyncLockMiddleware.php b/src/Middleware/AsyncLockMiddleware.php index 1ca95be..118af44 100644 --- a/src/Middleware/AsyncLockMiddleware.php +++ b/src/Middleware/AsyncLockMiddleware.php @@ -73,24 +73,40 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $lock = $this->lock_factory->createLock($lock_key, 30.0); if ($lock->acquire(false)) { - $this->logger->debug('AsyncCache LOCK_ACQUIRED: immediate', ['key' => $context->key]); + $this->logger->debug('AsyncCache LOCK_ACQUIRED: Immediate acquisition successful', ['key' => $context->key]); $this->active_locks[$lock_key] = $lock; return $this->handleWithLock($context, $next, $lock_key); } if (null !== $context->stale_item) { + $this->logger->debug('AsyncCache LOCK_BUSY: Lock is busy, returning stale data immediately', ['key' => $context->key]); + $now = (float) $context->clock->now()->format('U.u'); - $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Stale, $now - $context->start_time, $context->options->tags, $now)); + $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Stale, $context->getElapsedTime(), $context->options->tags, $now)); $this->dispatcher?->dispatch(new CacheHitEvent($context->key, $context->stale_item->data, $now)); + /** @var T $stale_data */ $stale_data = $context->stale_item->data; return \React\Promise\resolve($stale_data); } - $this->logger->debug('AsyncCache LOCK_BUSY: waiting for lock asynchronously', ['key' => $context->key]); + $this->logger->debug('AsyncCache LOCK_WAIT: Lock is busy and no stale data available, waiting asynchronously', ['key' => $context->key]); + + return $this->waitForLock($context, $next, $lock_key); + } + /** + * @template T + * + * @param CacheContext $context + * @param callable(CacheContext):PromiseInterface $next + * @param string $lock_key + * @return PromiseInterface + */ + private function waitForLock(CacheContext $context, callable $next, string $lock_key) : PromiseInterface + { $start_time = (float) $context->clock->now()->format('U.u'); $timeout = 10.0; $deferred = new Deferred(); @@ -100,34 +116,35 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $lock = $this->lock_factory->createLock($lock_key, 30.0); if ($lock->acquire(false)) { - $this->logger->debug('AsyncCache LOCK_ACQUIRED: async', ['key' => $context->key]); + $this->logger->debug('AsyncCache LOCK_ACQUIRED: Async acquisition successful after waiting', ['key' => $context->key]); $this->active_locks[$lock_key] = $lock; + // Double-check cache after acquiring lock (someone else might have populated it) $this->storage->get($context->key, $context->options)->then( - function ($cached_item) use ($context, $next, $lock_key, $start_time, $deferred) { - $now = (float) $context->clock->now()->format('U.u'); - if ($cached_item instanceof CachedItem && $cached_item->isFresh((int) $now)) { - $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Hit, $now - $start_time, $context->options->tags, $now)); + function ($item) use ($context, $next, $lock_key, $deferred) { + $now_ts = $context->clock->now()->getTimestamp(); + if ($item instanceof CachedItem && $item->isFresh($now_ts)) { + $this->logger->debug('AsyncCache LOCK_DOUBLE_CHECK: Item is fresh after lock acquisition, skipping fetch', ['key' => $context->key]); $this->releaseLock($lock_key); - $deferred->resolve($cached_item->data); + $deferred->resolve($item->data); return; } - /** @var PromiseInterface $inner_promise */ - $inner_promise = $this->handleWithLock($context, $next, $lock_key); - $inner_promise->then( - fn ($v) => $deferred->resolve($v) - )->catch(function (\Throwable $e) use ($deferred) { - $this->logger->error('AsyncCache LOCK_INNER_ERROR: {msg}', ['msg' => $e->getMessage()]); - $deferred->reject($e); - }); + $this->handleWithLock($context, $next, $lock_key)->then( + fn ($v) => $deferred->resolve($v), + function ($e) use ($deferred) { + $this->logger->error('AsyncCache LOCK_INNER_ERROR: Fetch failed after lock acquisition', ['error' => $e->getMessage()]); + $deferred->reject($e); + } + ); + }, + function ($e) use ($lock_key, $deferred) { + $this->logger->error('AsyncCache LOCK_STORAGE_ERROR: Cache check failed after lock acquisition', ['error' => $e->getMessage()]); + $this->releaseLock($lock_key); + $deferred->reject($e); } - )->catch(function (\Throwable $e) use ($context, $lock_key, $deferred) { - $this->logger->error('AsyncCache LOCK_STORAGE_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - $this->releaseLock($lock_key); - $deferred->reject($e); - }); + ); return; } @@ -138,12 +155,11 @@ function ($cached_item) use ($context, $next, $lock_key, $start_time, $deferred) return; } - // Retry after delay \React\Promise\Timer\resolve(0.05)->then(function () use ($attempt) { $attempt(); }); } catch (\Throwable $e) { - $this->logger->error('AsyncCache LOCK_RETRY_ERROR: {msg}', ['msg' => $e->getMessage()]); + $this->logger->error('AsyncCache LOCK_RETRY_ERROR: Lock acquisition attempt failed', ['error' => $e->getMessage()]); $deferred->reject($e); } }; @@ -152,9 +168,6 @@ function ($cached_item) use ($context, $next, $lock_key, $start_time, $deferred) /** @var PromiseInterface $promise */ $promise = $deferred->promise(); - $promise->catch(function (\Throwable $e) use ($context) { - $this->logger->debug('AsyncCache LOCK_PIPELINE_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - }); return $promise; } diff --git a/src/Middleware/CacheLookupMiddleware.php b/src/Middleware/CacheLookupMiddleware.php index a537cf3..f651d08 100644 --- a/src/Middleware/CacheLookupMiddleware.php +++ b/src/Middleware/CacheLookupMiddleware.php @@ -28,7 +28,7 @@ use Fyennyi\AsyncCache\Core\CacheContext; use Fyennyi\AsyncCache\Enum\CacheStatus; use Fyennyi\AsyncCache\Enum\CacheStrategy; -use Fyennyi\AsyncCache\Event\CacheHitEvent; +use Fyennyi\AsyncCache\Event\CacheMissEvent; use Fyennyi\AsyncCache\Event\CacheStatusEvent; use Fyennyi\AsyncCache\Model\CachedItem; use Fyennyi\AsyncCache\Storage\CacheStorage; @@ -62,79 +62,63 @@ public function __construct( */ public function handle(CacheContext $context, callable $next) : PromiseInterface { - // Basic tracing for debugging - $this->logger->debug('CacheLookupMiddleware: handling cache context', ['key' => $context->key, 'strategy' => $context->options->strategy->value]); + $this->logger->debug('AsyncCache LOOKUP_START: Beginning cache retrieval', ['key' => $context->key, 'strategy' => $context->options->strategy->value]); + if (CacheStrategy::ForceRefresh === $context->options->strategy) { + $this->logger->debug('AsyncCache LOOKUP_BYPASS: Strategy is ForceRefresh, bypassing cache', ['key' => $context->key]); $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Bypass, 0, $context->options->tags, (float) $context->clock->now()->format('U.u'))); return $next($context); } - /** @var PromiseInterface $promise */ - $promise = $this->storage->get($context->key, $context->options)->then( + return $this->storage->get($context->key, $context->options)->then( function ($cached_item) use ($context, $next) { - if ($cached_item instanceof CachedItem) { - $context->stale_item = $cached_item; - $now_ts = $context->clock->now()->getTimestamp(); - $is_fresh = $cached_item->isFresh($now_ts); - - if ($is_fresh && $context->options->x_fetch_beta > 0 && $cached_item->generation_time > 0) { - $rand = mt_rand(1, mt_getrandmax()) / mt_getrandmax(); - $check = $now_ts - ($cached_item->generation_time * $context->options->x_fetch_beta * log($rand)); - - if ($check > $cached_item->logical_expire_time) { - $now = (float) $context->clock->now()->format('U.u'); - $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::XFetch, $context->getElapsedTime(), $context->options->tags, $now)); - $is_fresh = false; - } - } - - if ($is_fresh) { - $now = (float) $context->clock->now()->format('U.u'); - $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Hit, $context->getElapsedTime(), $context->options->tags, $now)); - $this->dispatcher?->dispatch(new CacheHitEvent($context->key, $cached_item->data, $now)); + if (! ($cached_item instanceof CachedItem)) { + $this->logger->debug('AsyncCache LOOKUP_MISS: Item not found in cache', ['key' => $context->key]); + $this->dispatcher?->dispatch(new CacheMissEvent($context->key, (float) $context->clock->now()->format('U.u'))); - // If item has tags, we MUST continue to TagValidationMiddleware - if (! empty($cached_item->tag_versions)) { - return $next($context); - } + return $next($context); + } - /** @var T $item_data */ - $item_data = $cached_item->data; + $context->stale_item = $cached_item; + $now_ts = $context->clock->now()->getTimestamp(); + $is_fresh = $cached_item->isFresh($now_ts); - return \React\Promise\resolve($item_data); - } + if ($is_fresh && $context->options->x_fetch_beta > 0 && $cached_item->generation_time > 0) { + $rand = mt_rand(1, mt_getrandmax()) / mt_getrandmax(); + $check = $now_ts - ($cached_item->generation_time * $context->options->x_fetch_beta * log($rand)); - if (CacheStrategy::Background === $context->options->strategy) { + if ($check > $cached_item->logical_expire_time) { + $this->logger->debug('AsyncCache LOOKUP_XFETCH: Probabilistic early expiration triggered', ['key' => $context->key]); $now = (float) $context->clock->now()->format('U.u'); - $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Stale, $context->getElapsedTime(), $context->options->tags, $now)); - $this->dispatcher?->dispatch(new CacheHitEvent($context->key, $cached_item->data, $now)); - - // Background fetch - catch errors to prevent unhandled rejection since this promise is not returned - $next($context)->catch(function (\Throwable $e) use ($context) { - $this->logger->error('AsyncCache BACKGROUND_FETCH_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - }); - - /** @var T $item_data */ - $item_data = $cached_item->data; - - return \React\Promise\resolve($item_data); + $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::XFetch, $context->getElapsedTime(), $context->options->tags, $now)); + + // Create a stale version of the item by setting logical_expire_time to current time - 1 + $context->stale_item = new CachedItem( + $cached_item->data, + $now_ts - 1, + $cached_item->version, + $cached_item->is_compressed, + $cached_item->generation_time, + $cached_item->tag_versions + ); + $is_fresh = false; } } + if ($is_fresh) { + $this->logger->debug('AsyncCache LOOKUP_FRESH: Fresh item found in cache', ['key' => $context->key]); + } else { + $this->logger->debug('AsyncCache LOOKUP_STALE: Stale item found in cache', ['key' => $context->key]); + } + return $next($context); }, function (\Throwable $e) use ($context, $next) { - $this->logger->error('AsyncCache CACHE_LOOKUP_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); + $this->logger->error('AsyncCache LOOKUP_ERROR: Cache retrieval failed', ['key' => $context->key, 'error' => $e->getMessage()]); return $next($context); } ); - - $promise->catch(function (\Throwable $e) use ($context) { - $this->logger->debug('AsyncCache LOOKUP_PIPELINE_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - }); - - return $promise; } } diff --git a/src/Middleware/CoalesceMiddleware.php b/src/Middleware/CoalesceMiddleware.php index ba3c9f1..ad86623 100644 --- a/src/Middleware/CoalesceMiddleware.php +++ b/src/Middleware/CoalesceMiddleware.php @@ -58,7 +58,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface if (isset($this->pending[$context->key])) { /** @var PromiseInterface $pending_promise */ $pending_promise = $this->pending[$context->key]; - $this->logger?->debug('AsyncCache COALESCE_HIT: returning existing promise', ['key' => $context->key]); + $this->logger?->debug('AsyncCache COALESCE_HIT: Returning existing promise for concurrent request', ['key' => $context->key]); return $pending_promise; } @@ -71,9 +71,9 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $promise->finally(function () use ($context) { unset($this->pending[$context->key]); })->catch(function (\Throwable $e) use ($context) { - $this->logger?->debug('AsyncCache COALESCE_ERROR: pending request failed', [ + $this->logger?->debug('AsyncCache COALESCE_ERROR: Concurrent request failed', [ 'key' => $context->key, - 'msg' => $e->getMessage() + 'error' => $e->getMessage() ]); }); diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..b89730b --- /dev/null +++ b/src/Middleware/RateLimitMiddleware.php @@ -0,0 +1,104 @@ + $next + * @return PromiseInterface + */ + public function handle(CacheContext $context, callable $next) : PromiseInterface + { + $rate_limit_key = $context->options->rate_limit_key; + + if (null === $this->limiter || null === $rate_limit_key) { + return $next($context); + } + + $this->logger->debug('AsyncCache RATELIMIT_CHECK: Checking rate limit', [ + 'key' => $context->key, + 'rate_limit_key' => $rate_limit_key, + ]); + + $limiter = $this->limiter->create($rate_limit_key); + $rate_limit = $limiter->consume(); + + if ($rate_limit->isAccepted()) { + return $next($context); + } + + $this->logger->warning('AsyncCache RATELIMIT_EXCEEDED: Rate limit exceeded', [ + 'key' => $context->key, + 'rate_limit_key' => $rate_limit_key, + ]); + + $now = (float) $context->clock->now()->format('U.u'); + $this->dispatcher?->dispatch(new RateLimitExceededEvent($context->key, $rate_limit_key, $now)); + $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::RateLimited, $context->getElapsedTime(), $context->options->tags, $now)); + + if ($context->options->serve_stale_if_limited && null !== $context->stale_item) { + $this->logger->debug('AsyncCache RATELIMIT_SERVE_STALE: Serving stale data due to rate limit', [ + 'key' => $context->key, + ]); + + /** @var T $data */ + $data = $context->stale_item->data; + + return resolve($data); + } + + throw new RateLimitException($rate_limit_key); + } +} diff --git a/src/Middleware/SourceFetchMiddleware.php b/src/Middleware/SourceFetchMiddleware.php index 189a396..7238317 100644 --- a/src/Middleware/SourceFetchMiddleware.php +++ b/src/Middleware/SourceFetchMiddleware.php @@ -27,7 +27,6 @@ use Fyennyi\AsyncCache\Core\CacheContext; use Fyennyi\AsyncCache\Enum\CacheStatus; -use Fyennyi\AsyncCache\Event\CacheMissEvent; use Fyennyi\AsyncCache\Event\CacheStatusEvent; use Fyennyi\AsyncCache\Storage\CacheStorage; use Psr\EventDispatcher\EventDispatcherInterface; @@ -54,8 +53,7 @@ public function __construct( */ public function handle(CacheContext $context, callable $next) : PromiseInterface { - $this->logger->debug('AsyncCache MISS: fetching from source', ['key' => $context->key]); - $this->dispatcher?->dispatch(new CacheMissEvent($context->key, (float) $context->clock->now()->format('U.u'))); + $this->logger->debug('AsyncCache SOURCE_FETCH: Fetching fresh data from source', ['key' => $context->key]); $start = (float) $context->clock->now()->format('U.u'); @@ -65,27 +63,45 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface /** @var PromiseInterface $result */ $result = $promise->then( - /** @param T $data */ + /** + * @param T $data + * @return T + */ function ($data) use ($context, $start) { $now = (float) $context->clock->now()->format('U.u'); $generation_time = $now - $start; + + $this->logger->debug('AsyncCache SOURCE_FETCH_SUCCESS: Successfully fetched from source', [ + 'key' => $context->key, + 'generation_time' => round($generation_time, 4), + ]); + $this->dispatcher?->dispatch(new CacheStatusEvent( $context->key, CacheStatus::Miss, - $now - $context->start_time, + $context->getElapsedTime(), $context->options->tags, $now )); // Background persistence - handle errors to avoid breaking the response - $this->storage->set($context->key, $data, $context->options, $generation_time)->catch(function (\Throwable $e) use ($context) { - $this->logger->error('AsyncCache PERSISTENCE_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - }); + $this->storage->set($context->key, $data, $context->options, $generation_time)->catch( + function (\Throwable $e) use ($context) { + $this->logger->error('AsyncCache PERSISTENCE_ERROR: Failed to save fresh data to cache', [ + 'key' => $context->key, + 'error' => $e->getMessage(), + ]); + } + ); return $data; } - )->catch(function (\Throwable $e) use ($context) : never { - $this->logger->debug('AsyncCache FETCH_PIPELINE_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); + )->catch(function (\Throwable $e) use ($context) { + $this->logger->debug('AsyncCache SOURCE_FETCH_ERROR: Pipeline execution failed', [ + 'key' => $context->key, + 'error' => $e->getMessage(), + ]); + throw $e; }); diff --git a/src/Middleware/StaleOnErrorMiddleware.php b/src/Middleware/StaleOnErrorMiddleware.php index a9c16a6..128bfe8 100644 --- a/src/Middleware/StaleOnErrorMiddleware.php +++ b/src/Middleware/StaleOnErrorMiddleware.php @@ -52,7 +52,7 @@ public function __construct( } /** - * Catches errors and returns stale data if available. + * Catches errors from downstream middlewares (like SourceFetch) and returns stale data if available. * * @template T * @@ -61,24 +61,19 @@ public function __construct( */ public function handle(CacheContext $context, callable $next) : PromiseInterface { - /** @var PromiseInterface $promise */ - $promise = $next($context); - - return $promise->catch( - function (\Throwable $reason) use ($context) { - $msg = $reason->getMessage(); - + return $next($context)->catch( + function (\Throwable $error) use ($context) { if (null !== $context->stale_item) { - $this->logger->warning('AsyncCache STALE_ON_ERROR: fetch failed, serving stale data', [ + $this->logger->warning('AsyncCache STALE_ON_ERROR: Fetch failed, serving stale data as fallback', [ 'key' => $context->key, - 'reason' => $msg + 'error' => $error->getMessage(), ]); $now = (float) $context->clock->now()->format('U.u'); $this->dispatcher?->dispatch(new CacheStatusEvent( $context->key, CacheStatus::Stale, - $now - $context->start_time, + $context->getElapsedTime(), $context->options->tags, $now )); @@ -89,7 +84,12 @@ function (\Throwable $reason) use ($context) { return $stale_data; } - throw $reason; + $this->logger->error('AsyncCache FETCH_ERROR: Fetch failed and no stale data available', [ + 'key' => $context->key, + 'error' => $error->getMessage(), + ]); + + throw $error; } ); } diff --git a/src/Middleware/StrategyMiddleware.php b/src/Middleware/StrategyMiddleware.php new file mode 100644 index 0000000..f3ba825 --- /dev/null +++ b/src/Middleware/StrategyMiddleware.php @@ -0,0 +1,126 @@ + $next + * @return PromiseInterface + */ + public function handle(CacheContext $context, callable $next) : PromiseInterface + { + $stale_item = $context->stale_item; + + if (null === $stale_item) { + $this->logger->debug('AsyncCache STRATEGY_MISS: No cached item found, proceeding to fetch', ['key' => $context->key]); + + return $next($context); + } + + $now_ts = $context->clock->now()->getTimestamp(); + $is_fresh = $stale_item->isFresh($now_ts); + + if ($is_fresh) { + $this->logger->debug('AsyncCache STRATEGY_HIT: Item is fresh, returning from cache', ['key' => $context->key]); + $this->dispatchHit($context, $stale_item->data); + + /** @var T $data */ + $data = $stale_item->data; + + return resolve($data); + } + + if (CacheStrategy::Background === $context->options->strategy) { + $this->logger->debug('AsyncCache STRATEGY_BACKGROUND: Item is stale, returning stale and refreshing in background', ['key' => $context->key]); + $this->dispatchStatus($context, CacheStatus::Stale); + $this->dispatchHit($context, $stale_item->data); + + // Fire-and-forget background refresh + $next($context)->catch(function (\Throwable $e) use ($context) { + $this->logger->error('AsyncCache STRATEGY_BACKGROUND_ERROR: Background refresh failed', [ + 'key' => $context->key, + 'error' => $e->getMessage(), + ]); + }); + + /** @var T $data */ + $data = $stale_item->data; + + return resolve($data); + } + + $this->logger->debug('AsyncCache STRATEGY_STRICT: Item is stale, waiting for fresh data', ['key' => $context->key]); + + return $next($context); + } + + /** + * @param CacheContext $context + * @param mixed $data + */ + private function dispatchHit(CacheContext $context, mixed $data) : void + { + $now = (float) $context->clock->now()->format('U.u'); + $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, CacheStatus::Hit, $context->getElapsedTime(), $context->options->tags, $now)); + $this->dispatcher?->dispatch(new CacheHitEvent($context->key, $data, $now)); + } + + /** + * @param CacheContext $context + * @param CacheStatus $status + */ + private function dispatchStatus(CacheContext $context, CacheStatus $status) : void + { + $now = (float) $context->clock->now()->format('U.u'); + $this->dispatcher?->dispatch(new CacheStatusEvent($context->key, $status, $context->getElapsedTime(), $context->options->tags, $now)); + } +} diff --git a/src/Middleware/TagValidationMiddleware.php b/src/Middleware/TagValidationMiddleware.php index cdf479a..c110843 100644 --- a/src/Middleware/TagValidationMiddleware.php +++ b/src/Middleware/TagValidationMiddleware.php @@ -61,41 +61,49 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $next($context); } + $this->logger->debug('AsyncCache TAG_VALIDATION_START: Validating tags', ['key' => $context->key, 'tags' => array_keys($item->tag_versions)]); + $tags = array_map('strval', array_keys($item->tag_versions)); /** @var PromiseInterface> $versions_promise */ $versions_promise = $this->storage->fetchTagVersions($tags); - return $versions_promise->then(function (array $current_versions) use ($context, $item, $next) { - /** @var array $tag_versions */ - $tag_versions = $item->tag_versions; - foreach ($tag_versions as $tag => $saved_version) { - if (($current_versions[$tag] ?? null) !== $saved_version) { - $this->logger->debug('AsyncCache TAG_INVALID: Version mismatch for tag {tag} in key {key}', [ - 'key' => $context->key, - 'tag' => $tag - ]); - $context->stale_item = null; - - return $next($context); + return $versions_promise->then( + function (array $current_versions) use ($context, $item, $next) { + foreach ($item->tag_versions as $tag => $saved_version) { + if (($current_versions[$tag] ?? null) !== $saved_version) { + $this->logger->debug('AsyncCache TAG_INVALID: Version mismatch for tag', [ + 'key' => $context->key, + 'tag' => $tag, + 'saved' => $saved_version, + 'current' => $current_versions[$tag] ?? 'null', + ]); + $context->stale_item = null; + + return $next($context); + } } - } - // Tags are valid. If the item is fresh, we can return it now. - if ($item->isFresh()) { - /** @var T $item_data */ - $item_data = $item->data; + $this->logger->debug('AsyncCache TAG_VALID: All tags are valid', ['key' => $context->key]); - return \React\Promise\resolve($item_data); - } + // Tags are valid. If the item is fresh, we can short-circuit and return it. + if ($item->isFresh($context->clock->now()->getTimestamp())) { + /** @var T $item_data */ + $item_data = $item->data; - return $next($context); - }, function (\Throwable $e) use ($context, $next) { - $this->logger->error('AsyncCache TAG_FETCH_ERROR: {msg}', ['key' => $context->key, 'msg' => $e->getMessage()]); - // On tag fetch error, we conservatively treat as stale/invalid - $context->stale_item = null; + return \React\Promise\resolve($item_data); + } - return $next($context); - }); + // Item is stale but tags are valid, continue to StrategyMiddleware + return $next($context); + }, + function (\Throwable $e) use ($context, $next) { + $this->logger->error('AsyncCache TAG_FETCH_ERROR: Failed to fetch tag versions', ['key' => $context->key, 'error' => $e->getMessage()]); + // On tag fetch error, we conservatively treat as stale/invalid + $context->stale_item = null; + + return $next($context); + } + ); } } diff --git a/tests/Unit/AsyncCacheManagerTest.php b/tests/Unit/AsyncCacheManagerTest.php index c815931..c52387a 100644 --- a/tests/Unit/AsyncCacheManagerTest.php +++ b/tests/Unit/AsyncCacheManagerTest.php @@ -15,13 +15,13 @@ use Symfony\Component\Clock\MockClock; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\Store\InMemoryStore; -use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; use function React\Async\await; class AsyncCacheManagerTest extends TestCase { private MockObject|CacheInterface $cache; - private MockObject|LimiterInterface $rateLimiter; + private MockObject|RateLimiterFactoryInterface $rateLimiter; private LockFactory $lockFactory; private MockClock $clock; private AsyncCacheManager $manager; @@ -29,7 +29,7 @@ class AsyncCacheManagerTest extends TestCase protected function setUp() : void { $this->cache = $this->createMock(CacheInterface::class); - $this->rateLimiter = $this->createMock(LimiterInterface::class); + $this->rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); $this->lockFactory = new LockFactory(new InMemoryStore()); // Use real in-memory locks $this->clock = new MockClock(); @@ -232,13 +232,26 @@ public function testInvalidateTags() : void $this->assertTrue(await($mgr->invalidateTags($tags))); } - public function testClearAndRateLimiter() : void + public function testGetRateLimiter() : void { - $this->rateLimiter->expects($this->once())->method('reset'); - $this->manager->clearRateLimiter(); $this->assertSame($this->rateLimiter, $this->manager->getRateLimiter()); } + public function testResetRateLimit() : void + { + $key = 'api_limit_key'; + $limiter = $this->createMock(\Symfony\Component\RateLimiter\LimiterInterface::class); + + $this->rateLimiter->expects($this->once()) + ->method('create') + ->with($key) + ->willReturn($limiter); + + $limiter->expects($this->once())->method('reset'); + + $this->manager->resetRateLimit($key); + } + public function testConstructorWrappers() : void { $psr = $this->createMock(CacheInterface::class); diff --git a/tests/Unit/Config/AsyncCacheConfigBuilderTest.php b/tests/Unit/Config/AsyncCacheConfigBuilderTest.php index 0c82d8a..ac70c5b 100644 --- a/tests/Unit/Config/AsyncCacheConfigBuilderTest.php +++ b/tests/Unit/Config/AsyncCacheConfigBuilderTest.php @@ -10,7 +10,7 @@ use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; class AsyncCacheConfigBuilderTest extends TestCase { @@ -60,7 +60,7 @@ public function testWithEventDispatcher() : void public function testWithRateLimiter() : void { - $rateLimiter = $this->createMock(LimiterInterface::class); + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); $config = AsyncCacheConfig::builder($this->cache) ->withRateLimiter($rateLimiter) @@ -105,7 +105,7 @@ public function testWithClock() : void public function testFluentInterface() : void { $logger = $this->createMock(LoggerInterface::class); - $rateLimiter = $this->createMock(LimiterInterface::class); + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); $dispatcher = $this->createMock(EventDispatcherInterface::class); $middleware = $this->createMock(MiddlewareInterface::class); diff --git a/tests/Unit/Middleware/CacheLookupMiddlewareTest.php b/tests/Unit/Middleware/CacheLookupMiddlewareTest.php index 0bac681..2cd34f1 100644 --- a/tests/Unit/Middleware/CacheLookupMiddlewareTest.php +++ b/tests/Unit/Middleware/CacheLookupMiddlewareTest.php @@ -29,12 +29,17 @@ protected function setUp() : void $this->middleware = new CacheLookupMiddleware($this->storage, $this->logger); } - public function testReturnsCachedDataIfFresh() : void + public function testSetsStaleItemAndCallsNextIfFound() : void { $item = new CachedItem('data', $this->clock->now()->getTimestamp() + 100); $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); - $this->assertSame('data', await($this->middleware->handle($context, fn () => \React\Promise\resolve(null)))); + $next = fn () => \React\Promise\resolve('from_next'); + + $res = await($this->middleware->handle($context, $next)); + + $this->assertSame('from_next', $res); + $this->assertSame($item, $context->stale_item); } public function testCallsNextOnCacheMiss() : void @@ -42,7 +47,9 @@ public function testCallsNextOnCacheMiss() : void $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); $this->storage->method('get')->willReturn(\React\Promise\resolve(null)); $next = fn () => \React\Promise\resolve('from_next'); + $this->assertSame('from_next', await($this->middleware->handle($context, $next))); + $this->assertNull($context->stale_item); } public function testBypassesOnForceRefresh() : void @@ -50,22 +57,8 @@ public function testBypassesOnForceRefresh() : void $context = new CacheContext('k', fn () => null, new CacheOptions(strategy: CacheStrategy::ForceRefresh), $this->clock); $this->storage->expects($this->never())->method('get'); $next = fn () => \React\Promise\resolve('bypassed'); - $this->assertSame('bypassed', await($this->middleware->handle($context, $next))); - } - public function testBackgroundRefreshReturnsStaleAndCallsNext() : void - { - $item = new CachedItem('stale', $this->clock->now()->getTimestamp() - 10); - $context = new CacheContext('k', fn () => null, new CacheOptions(strategy: CacheStrategy::Background), $this->clock); - $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); - $nextCalled = false; - $next = function () use (&$nextCalled) { - $nextCalled = true; - return \React\Promise\resolve('ignored'); - }; - $res = await($this->middleware->handle($context, $next)); - $this->assertSame('stale', $res); - $this->assertTrue($nextCalled); + $this->assertSame('bypassed', await($this->middleware->handle($context, $next))); } public function testXFetchTriggered() : void @@ -74,16 +67,12 @@ public function testXFetchTriggered() : void $context = new CacheContext('k', fn () => null, new CacheOptions(x_fetch_beta: 1000.0), $this->clock); $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); $next = fn () => \React\Promise\resolve('xfetch_triggered'); - $this->assertSame('xfetch_triggered', await($this->middleware->handle($context, $next))); - } - public function testProceedsIfItemHasTags() : void - { - $item = new CachedItem('data', $this->clock->now()->getTimestamp() + 100, tag_versions: ['t1' => 'v1']); - $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); - $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); - $next = fn () => \React\Promise\resolve('validated'); - $this->assertSame('validated', await($this->middleware->handle($context, $next))); + $res = await($this->middleware->handle($context, $next)); + + $this->assertSame('xfetch_triggered', $res); + $this->assertNotNull($context->stale_item); + $this->assertFalse($context->stale_item->isFresh($this->clock->now()->getTimestamp())); } public function testHandlesStorageError() : void @@ -91,30 +80,7 @@ public function testHandlesStorageError() : void $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); $this->storage->method('get')->willReturn(\React\Promise\reject(new \Exception('Storage error'))); $next = fn () => \React\Promise\resolve('fallback'); - $this->assertSame('fallback', await($this->middleware->handle($context, $next))); - } - - public function testHandlesBackgroundFetchError() : void - { - $item = new CachedItem('stale', $this->clock->now()->getTimestamp() - 10); - $context = new CacheContext('k', fn () => null, new CacheOptions(strategy: CacheStrategy::Background), $this->clock); - $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); - $next = fn () => \React\Promise\reject(new \Exception('Background fail')); - $res = await($this->middleware->handle($context, $next)); - $this->assertSame('stale', $res); - } - public function testHandlesPipelineErrorLogging() : void - { - $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); - $this->storage->method('get')->willReturn(\React\Promise\resolve(null)); - - $this->logger->expects($this->atLeastOnce())->method('debug'); - - $next = fn () => \React\Promise\reject(new \Exception('Pipeline fail')); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Pipeline fail'); - await($this->middleware->handle($context, $next)); + $this->assertSame('fallback', await($this->middleware->handle($context, $next))); } } diff --git a/tests/Unit/Middleware/RateLimitMiddlewareTest.php b/tests/Unit/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 0000000..621cfa3 --- /dev/null +++ b/tests/Unit/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,95 @@ +logger = $this->createMock(LoggerInterface::class); + $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->factory = $this->createMock(RateLimiterFactoryInterface::class); + $this->limiter = $this->createMock(LimiterInterface::class); + $this->clock = new MockClock(); + $this->middleware = new RateLimitMiddleware($this->factory, $this->logger, $this->dispatcher); + } + + public function testBypassesIfNoLimiterOrNoKey() : void + { + $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); + $next = fn () => resolve('ok'); + + $this->assertSame('ok', await($this->middleware->handle($context, $next))); + + $middlewareNoLimiter = new RateLimitMiddleware(null, $this->logger); + $this->assertSame('ok', await($middlewareNoLimiter->handle($context, $next))); + } + + public function testCallsNextIfAccepted() : void + { + $key = 'api_limit'; + $context = new CacheContext('k', fn () => null, new CacheOptions(rate_limit_key: $key), $this->clock); + $next = fn () => resolve('ok'); + + $rateLimit = new RateLimit(10, new \DateTimeImmutable(), true, 10); + + $this->factory->expects($this->once())->method('create')->with($key)->willReturn($this->limiter); + $this->limiter->expects($this->once())->method('consume')->willReturn($rateLimit); + + $this->assertSame('ok', await($this->middleware->handle($context, $next))); + } + + public function testThrowsExceptionIfExceededAndNoStale() : void + { + $key = 'api_limit'; + $context = new CacheContext('k', fn () => null, new CacheOptions(rate_limit_key: $key, serve_stale_if_limited: false), $this->clock); + $next = fn () => resolve('ok'); + + $rateLimit = new RateLimit(0, new \DateTimeImmutable(), false, 10); + + $this->factory->method('create')->willReturn($this->limiter); + $this->limiter->method('consume')->willReturn($rateLimit); + + $this->expectException(RateLimitException::class); + await($this->middleware->handle($context, $next)); + } + + public function testReturnsStaleIfExceededAndConfigured() : void + { + $key = 'api_limit'; + $item = new CachedItem('stale_data', $this->clock->now()->getTimestamp() - 10); + $context = new CacheContext('k', fn () => null, new CacheOptions(rate_limit_key: $key, serve_stale_if_limited: true), $this->clock); + $context->stale_item = $item; + $next = fn () => resolve('should_not_be_called'); + + $rateLimit = new RateLimit(0, new \DateTimeImmutable(), false, 10); + + $this->factory->method('create')->willReturn($this->limiter); + $this->limiter->method('consume')->willReturn($rateLimit); + + $this->assertSame('stale_data', await($this->middleware->handle($context, $next))); + } +} diff --git a/tests/Unit/Middleware/StrategyMiddlewareTest.php b/tests/Unit/Middleware/StrategyMiddlewareTest.php new file mode 100644 index 0000000..ab39eb1 --- /dev/null +++ b/tests/Unit/Middleware/StrategyMiddlewareTest.php @@ -0,0 +1,77 @@ +logger = $this->createMock(LoggerInterface::class); + $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->clock = new MockClock(); + $this->middleware = new StrategyMiddleware($this->logger, $this->dispatcher); + } + + public function testCallsNextOnMiss() : void + { + $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); + $next = fn () => resolve('from_next'); + + $this->assertSame('from_next', await($this->middleware->handle($context, $next))); + } + + public function testReturnsFreshHitImmediately() : void + { + $item = new CachedItem('data', $this->clock->now()->getTimestamp() + 100); + $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); + $context->stale_item = $item; + $next = fn () => resolve('should_not_be_called'); + + $this->assertSame('data', await($this->middleware->handle($context, $next))); + } + + public function testBackgroundStrategyReturnsStaleAndRefreshesInBackground() : void + { + $item = new CachedItem('stale_data', $this->clock->now()->getTimestamp() - 10); + $context = new CacheContext('k', fn () => null, new CacheOptions(strategy: CacheStrategy::Background), $this->clock); + $context->stale_item = $item; + + $nextCalled = false; + $next = function () use (&$nextCalled) { + $nextCalled = true; + return resolve('refreshed_data'); + }; + + $this->assertSame('stale_data', await($this->middleware->handle($context, $next))); + $this->assertTrue($nextCalled); + } + + public function testStrictStrategyWaitsForFreshData() : void + { + $item = new CachedItem('stale_data', $this->clock->now()->getTimestamp() - 10); + $context = new CacheContext('k', fn () => null, new CacheOptions(strategy: CacheStrategy::Strict), $this->clock); + $context->stale_item = $item; + + $next = fn () => resolve('fresh_data'); + + $this->assertSame('fresh_data', await($this->middleware->handle($context, $next))); + } +}