From 9b7469cc9bf91ef051e84cad7619ffc28a3995cd Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:22:40 +0200 Subject: [PATCH 01/27] feat: Implement StrategyMiddleware for centralized strategy logic --- src/Middleware/StrategyMiddleware.php | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/Middleware/StrategyMiddleware.php diff --git a/src/Middleware/StrategyMiddleware.php b/src/Middleware/StrategyMiddleware.php new file mode 100644 index 0000000..d02ce96 --- /dev/null +++ b/src/Middleware/StrategyMiddleware.php @@ -0,0 +1,127 @@ + $next + * @return PromiseInterface + */ + public function handle(CacheContext $context, callable $next) : PromiseInterface + { + $stale_item = $context->stale_item; + + if (null === $stale_item) { + $this->logger->debug('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('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('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('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('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)); + } +} From 8e048ac31ea5640ea7be8686b4bbe689ed76eb52 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:22:54 +0200 Subject: [PATCH 02/27] feat: Implement RateLimitMiddleware using Symfony RateLimiterFactoryInterface --- src/Middleware/RateLimitMiddleware.php | 105 +++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Middleware/RateLimitMiddleware.php diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php new file mode 100644 index 0000000..9bb7f62 --- /dev/null +++ b/src/Middleware/RateLimitMiddleware.php @@ -0,0 +1,105 @@ + $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('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('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('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); + } +} From 7ed048cdb4eae888bfa1d664aeeead72ed899232 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:02 +0200 Subject: [PATCH 03/27] refactor: Remove strategy logic from CacheLookupMiddleware and delegate to StrategyMiddleware --- src/Middleware/CacheLookupMiddleware.php | 88 ++++++++++-------------- 1 file changed, 36 insertions(+), 52 deletions(-) diff --git a/src/Middleware/CacheLookupMiddleware.php b/src/Middleware/CacheLookupMiddleware.php index a537cf3..f124a68 100644 --- a/src/Middleware/CacheLookupMiddleware.php +++ b/src/Middleware/CacheLookupMiddleware.php @@ -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('LOOKUP_START: Beginning cache retrieval', ['key' => $context->key, 'strategy' => $context->options->strategy->value]); + if (CacheStrategy::ForceRefresh === $context->options->strategy) { + $this->logger->debug('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('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('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('LOOKUP_FRESH: Fresh item found in cache', ['key' => $context->key]); + } else { + $this->logger->debug('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('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; } } From 423076e3754e04baf860f02b9d81531d942b6b5d Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:06 +0200 Subject: [PATCH 04/27] refactor: Update AsyncLockMiddleware with double-check after lock and simplified flow --- src/Middleware/AsyncLockMiddleware.php | 72 ++++++++++++++------------ 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/src/Middleware/AsyncLockMiddleware.php b/src/Middleware/AsyncLockMiddleware.php index 1ca95be..8190ffd 100644 --- a/src/Middleware/AsyncLockMiddleware.php +++ b/src/Middleware/AsyncLockMiddleware.php @@ -73,24 +73,36 @@ 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('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) { - $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 CacheHitEvent($context->key, $context->stale_item->data, $now)); + $this->logger->debug('LOCK_BUSY: Lock is busy, returning stale data immediately', ['key' => $context->key]); + /** @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('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 +112,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('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('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('LOCK_INNER_ERROR: Fetch failed after lock acquisition', ['error' => $e->getMessage()]); + $deferred->reject($e); + } + ); + }, + function ($e) use ($lock_key, $deferred) { + $this->logger->error('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,25 +151,18 @@ 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('LOCK_RETRY_ERROR: Lock acquisition attempt failed', ['error' => $e->getMessage()]); $deferred->reject($e); } }; $attempt(); - /** @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; + return $deferred->promise(); } /** From 12d9ef90c40aaefefd6e8c8b801207ee4e114f88 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:10 +0200 Subject: [PATCH 05/27] refactor: Update TagValidationMiddleware with new logging and clarified flow --- src/Middleware/TagValidationMiddleware.php | 60 ++++++++++++---------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Middleware/TagValidationMiddleware.php b/src/Middleware/TagValidationMiddleware.php index cdf479a..e5960ad 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('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('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('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('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); + } + ); } } From eeac721b4ef6fc82cad5e37732bce86c8a744511 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:14 +0200 Subject: [PATCH 06/27] refactor: Update StaleOnErrorMiddleware to follow new logging and clarified flow --- src/Middleware/StaleOnErrorMiddleware.php | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Middleware/StaleOnErrorMiddleware.php b/src/Middleware/StaleOnErrorMiddleware.php index a9c16a6..7fb3b94 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('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('FETCH_ERROR: Fetch failed and no stale data available', [ + 'key' => $context->key, + 'error' => $error->getMessage(), + ]); + + throw $error; } ); } From 5ee21e956b620a9bfb2ba608a2a13ab64ccae4ba Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:19 +0200 Subject: [PATCH 07/27] refactor: Update SourceFetchMiddleware with new logging and clarified flow --- src/Middleware/SourceFetchMiddleware.php | 35 ++++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/Middleware/SourceFetchMiddleware.php b/src/Middleware/SourceFetchMiddleware.php index 189a396..8a2010d 100644 --- a/src/Middleware/SourceFetchMiddleware.php +++ b/src/Middleware/SourceFetchMiddleware.php @@ -54,8 +54,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('SOURCE_FETCH: Fetching fresh data from source', ['key' => $context->key]); $start = (float) $context->clock->now()->format('U.u'); @@ -63,33 +62,45 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface /** @var PromiseInterface $promise */ $promise = $next($context); - /** @var PromiseInterface $result */ - $result = $promise->then( + return $promise->then( /** @param T $data */ function ($data) use ($context, $start) { $now = (float) $context->clock->now()->format('U.u'); $generation_time = $now - $start; + + $this->logger->debug('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('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('SOURCE_FETCH_ERROR: Pipeline execution failed', [ + 'key' => $context->key, + 'error' => $e->getMessage(), + ]); + throw $e; }); - - return $result; } catch (\Throwable $e) { return \React\Promise\reject($e); } From 38d3f62cf82757da4a0afd9cea788b29cc099318 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:23 +0200 Subject: [PATCH 08/27] refactor: Update AsyncCacheManager with new middleware order and RateLimiterFactoryInterface --- src/AsyncCacheManager.php | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/AsyncCacheManager.php b/src/AsyncCacheManager.php index c3443fc..28b985b 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. + * + * @note RateLimiterFactoryInterface does not support global reset in Symfony. */ public function clearRateLimiter() : void { - $this->rate_limiter?->reset(); + // No-op for factory. } } From 934c808b210a1bcdbc75fda03e1c9491cf3eaedf Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:29 +0200 Subject: [PATCH 09/27] refactor: Update AsyncCacheConfig to use RateLimiterFactoryInterface --- src/Config/AsyncCacheConfig.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; } From 3cca2d44b4a553527a6fd1b47fc37350598515e9 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:33 +0200 Subject: [PATCH 10/27] refactor: Update AsyncCacheConfigBuilder to use RateLimiterFactoryInterface --- src/Config/AsyncCacheConfigBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; From 99149d465d5a1e7f39f6b25c8bf31e7723d6a5b7 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:38 +0200 Subject: [PATCH 11/27] test: Update AsyncCacheManagerTest for RateLimiterFactoryInterface and reset removal --- tests/Unit/AsyncCacheManagerTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Unit/AsyncCacheManagerTest.php b/tests/Unit/AsyncCacheManagerTest.php index c815931..bad18f5 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(); @@ -234,7 +234,6 @@ public function testInvalidateTags() : void public function testClearAndRateLimiter() : void { - $this->rateLimiter->expects($this->once())->method('reset'); $this->manager->clearRateLimiter(); $this->assertSame($this->rateLimiter, $this->manager->getRateLimiter()); } From af5b56ecb0d43b2d3e507ef5ace55cd88d2a7802 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:44 +0200 Subject: [PATCH 12/27] test: Update AsyncCacheConfigBuilderTest for RateLimiterFactoryInterface --- tests/Unit/Config/AsyncCacheConfigBuilderTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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); From e9b3fcbc1c2cc64144c08dc8d4dd81ed698c3a31 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:48 +0200 Subject: [PATCH 13/27] test: Update CacheLookupMiddlewareTest to reflect new middleware flow --- .../Middleware/CacheLookupMiddlewareTest.php | 68 +++++-------------- 1 file changed, 17 insertions(+), 51 deletions(-) diff --git a/tests/Unit/Middleware/CacheLookupMiddlewareTest.php b/tests/Unit/Middleware/CacheLookupMiddlewareTest.php index 0bac681..0d5113b 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,40 +57,22 @@ 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); - } - public function testXFetchTriggered() : void { $item = new CachedItem('data', $this->clock->now()->getTimestamp() + 1, generation_time: 1.0); $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)); - } } From f37c59f3d6d1c73fcf41037658e0be66aee66bdf Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:51 +0200 Subject: [PATCH 14/27] test: Add unit tests for StrategyMiddleware --- .../Middleware/StrategyMiddlewareTest.php | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/Unit/Middleware/StrategyMiddlewareTest.php diff --git a/tests/Unit/Middleware/StrategyMiddlewareTest.php b/tests/Unit/Middleware/StrategyMiddlewareTest.php new file mode 100644 index 0000000..94807a3 --- /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))); + } +} From c803fe10e127ff4100e7ecc9ebd4bf92d20fe442 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:23:59 +0200 Subject: [PATCH 15/27] test: Add unit tests for RateLimitMiddleware --- .../Middleware/RateLimitMiddlewareTest.php | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/Unit/Middleware/RateLimitMiddlewareTest.php diff --git a/tests/Unit/Middleware/RateLimitMiddlewareTest.php b/tests/Unit/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 0000000..f168d9f --- /dev/null +++ b/tests/Unit/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,96 @@ +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))); + } +} From 24cee1099451ae31cac7882f0c792188da260ec1 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:29:42 +0200 Subject: [PATCH 16/27] fix: Add missing CacheMissEvent import in CacheLookupMiddleware --- src/Middleware/CacheLookupMiddleware.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Middleware/CacheLookupMiddleware.php b/src/Middleware/CacheLookupMiddleware.php index f124a68..e4b6354 100644 --- a/src/Middleware/CacheLookupMiddleware.php +++ b/src/Middleware/CacheLookupMiddleware.php @@ -29,6 +29,7 @@ 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; From 6f7f2ae3b713173c955fb68e67b66ac980bb71ff Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:29:42 +0200 Subject: [PATCH 17/27] fix: Restore EventDispatcher usage in AsyncLockMiddleware to track stale events --- src/Middleware/AsyncLockMiddleware.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Middleware/AsyncLockMiddleware.php b/src/Middleware/AsyncLockMiddleware.php index 8190ffd..a763f34 100644 --- a/src/Middleware/AsyncLockMiddleware.php +++ b/src/Middleware/AsyncLockMiddleware.php @@ -82,6 +82,10 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface if (null !== $context->stale_item) { $this->logger->debug('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, $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; From 80dc9d8570f53176e3b07a7bbc270b7ee015f619 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:35:31 +0200 Subject: [PATCH 18/27] fix: Add explicit return type hint for the promise in waitForLock to satisfy PHPStan --- src/Middleware/AsyncLockMiddleware.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Middleware/AsyncLockMiddleware.php b/src/Middleware/AsyncLockMiddleware.php index a763f34..b7d8b35 100644 --- a/src/Middleware/AsyncLockMiddleware.php +++ b/src/Middleware/AsyncLockMiddleware.php @@ -166,7 +166,10 @@ function ($e) use ($lock_key, $deferred) { $attempt(); - return $deferred->promise(); + /** @var PromiseInterface $promise */ + $promise = $deferred->promise(); + + return $promise; } /** From 5a8ec29b8f3929ede0b465cea601cab061ddfa2b Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:35:35 +0200 Subject: [PATCH 19/27] fix: Add explicit return type hint for the source fetch result to satisfy PHPStan --- src/Middleware/SourceFetchMiddleware.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Middleware/SourceFetchMiddleware.php b/src/Middleware/SourceFetchMiddleware.php index 8a2010d..d3412a3 100644 --- a/src/Middleware/SourceFetchMiddleware.php +++ b/src/Middleware/SourceFetchMiddleware.php @@ -62,8 +62,12 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface /** @var PromiseInterface $promise */ $promise = $next($context); - return $promise->then( - /** @param T $data */ + /** @var PromiseInterface $result */ + $result = $promise->then( + /** + * @param T $data + * @return T + */ function ($data) use ($context, $start) { $now = (float) $context->clock->now()->format('U.u'); $generation_time = $now - $start; @@ -101,6 +105,8 @@ function (\Throwable $e) use ($context) { throw $e; }); + + return $result; } catch (\Throwable $e) { return \React\Promise\reject($e); } From 97682afa980a595a755e16319b689035e940ac03 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 11:56:35 +0200 Subject: [PATCH 20/27] refactor: Standardize middleware logging with AsyncCache prefix --- src/Middleware/AsyncLockMiddleware.php | 16 ++++++++-------- src/Middleware/CacheLookupMiddleware.php | 14 +++++++------- src/Middleware/CoalesceMiddleware.php | 6 +++--- src/Middleware/RateLimitMiddleware.php | 6 +++--- src/Middleware/SourceFetchMiddleware.php | 8 ++++---- src/Middleware/StaleOnErrorMiddleware.php | 4 ++-- src/Middleware/StrategyMiddleware.php | 10 +++++----- src/Middleware/TagValidationMiddleware.php | 8 ++++---- 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/Middleware/AsyncLockMiddleware.php b/src/Middleware/AsyncLockMiddleware.php index b7d8b35..118af44 100644 --- a/src/Middleware/AsyncLockMiddleware.php +++ b/src/Middleware/AsyncLockMiddleware.php @@ -73,14 +73,14 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $lock = $this->lock_factory->createLock($lock_key, 30.0); if ($lock->acquire(false)) { - $this->logger->debug('LOCK_ACQUIRED: Immediate acquisition successful', ['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('LOCK_BUSY: Lock is busy, returning stale data immediately', ['key' => $context->key]); + $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, $context->getElapsedTime(), $context->options->tags, $now)); @@ -92,7 +92,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return \React\Promise\resolve($stale_data); } - $this->logger->debug('LOCK_WAIT: Lock is busy and no stale data available, waiting 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); } @@ -116,7 +116,7 @@ private function waitForLock(CacheContext $context, callable $next, string $lock $lock = $this->lock_factory->createLock($lock_key, 30.0); if ($lock->acquire(false)) { - $this->logger->debug('LOCK_ACQUIRED: Async acquisition successful after waiting', ['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) @@ -124,7 +124,7 @@ private function waitForLock(CacheContext $context, callable $next, string $lock 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('LOCK_DOUBLE_CHECK: Item is fresh after lock acquisition, skipping fetch', ['key' => $context->key]); + $this->logger->debug('AsyncCache LOCK_DOUBLE_CHECK: Item is fresh after lock acquisition, skipping fetch', ['key' => $context->key]); $this->releaseLock($lock_key); $deferred->resolve($item->data); @@ -134,13 +134,13 @@ function ($item) use ($context, $next, $lock_key, $deferred) { $this->handleWithLock($context, $next, $lock_key)->then( fn ($v) => $deferred->resolve($v), function ($e) use ($deferred) { - $this->logger->error('LOCK_INNER_ERROR: Fetch failed after lock acquisition', ['error' => $e->getMessage()]); + $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('LOCK_STORAGE_ERROR: Cache check failed after lock acquisition', ['error' => $e->getMessage()]); + $this->logger->error('AsyncCache LOCK_STORAGE_ERROR: Cache check failed after lock acquisition', ['error' => $e->getMessage()]); $this->releaseLock($lock_key); $deferred->reject($e); } @@ -159,7 +159,7 @@ function ($e) use ($lock_key, $deferred) { $attempt(); }); } catch (\Throwable $e) { - $this->logger->error('LOCK_RETRY_ERROR: Lock acquisition attempt failed', ['error' => $e->getMessage()]); + $this->logger->error('AsyncCache LOCK_RETRY_ERROR: Lock acquisition attempt failed', ['error' => $e->getMessage()]); $deferred->reject($e); } }; diff --git a/src/Middleware/CacheLookupMiddleware.php b/src/Middleware/CacheLookupMiddleware.php index e4b6354..7b5b50c 100644 --- a/src/Middleware/CacheLookupMiddleware.php +++ b/src/Middleware/CacheLookupMiddleware.php @@ -63,10 +63,10 @@ public function __construct( */ public function handle(CacheContext $context, callable $next) : PromiseInterface { - $this->logger->debug('LOOKUP_START: Beginning cache retrieval', ['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('LOOKUP_BYPASS: Strategy is ForceRefresh, bypassing cache', ['key' => $context->key]); + $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); @@ -75,7 +75,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $this->storage->get($context->key, $context->options)->then( function ($cached_item) use ($context, $next) { if (! ($cached_item instanceof CachedItem)) { - $this->logger->debug('LOOKUP_MISS: Item not found in cache', ['key' => $context->key]); + $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'))); return $next($context); @@ -90,7 +90,7 @@ function ($cached_item) use ($context, $next) { $check = $now_ts - ($cached_item->generation_time * $context->options->x_fetch_beta * log($rand)); if ($check > $cached_item->logical_expire_time) { - $this->logger->debug('LOOKUP_XFETCH: Probabilistic early expiration triggered', ['key' => $context->key]); + $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::XFetch, $context->getElapsedTime(), $context->options->tags, $now)); @@ -108,15 +108,15 @@ function ($cached_item) use ($context, $next) { } if ($is_fresh) { - $this->logger->debug('LOOKUP_FRESH: Fresh item found in cache', ['key' => $context->key]); + $this->logger->debug('AsyncCache LOOKUP_FRESH: Fresh item found in cache', ['key' => $context->key]); } else { - $this->logger->debug('LOOKUP_STALE: Stale item found in cache', ['key' => $context->key]); + $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('LOOKUP_ERROR: Cache retrieval failed', ['key' => $context->key, 'error' => $e->getMessage()]); + $this->logger->error('AsyncCache LOOKUP_ERROR: Cache retrieval failed', ['key' => $context->key, 'error' => $e->getMessage()]); return $next($context); } 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 index 9bb7f62..110fc31 100644 --- a/src/Middleware/RateLimitMiddleware.php +++ b/src/Middleware/RateLimitMiddleware.php @@ -68,7 +68,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $next($context); } - $this->logger->debug('RATELIMIT_CHECK: Checking rate limit', [ + $this->logger->debug('AsyncCache RATELIMIT_CHECK: Checking rate limit', [ 'key' => $context->key, 'rate_limit_key' => $rate_limit_key, ]); @@ -80,7 +80,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $next($context); } - $this->logger->warning('RATELIMIT_EXCEEDED: Rate limit exceeded', [ + $this->logger->warning('AsyncCache RATELIMIT_EXCEEDED: Rate limit exceeded', [ 'key' => $context->key, 'rate_limit_key' => $rate_limit_key, ]); @@ -90,7 +90,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $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('RATELIMIT_SERVE_STALE: Serving stale data due to rate limit', [ + $this->logger->debug('AsyncCache RATELIMIT_SERVE_STALE: Serving stale data due to rate limit', [ 'key' => $context->key, ]); diff --git a/src/Middleware/SourceFetchMiddleware.php b/src/Middleware/SourceFetchMiddleware.php index d3412a3..f4bc265 100644 --- a/src/Middleware/SourceFetchMiddleware.php +++ b/src/Middleware/SourceFetchMiddleware.php @@ -54,7 +54,7 @@ public function __construct( */ public function handle(CacheContext $context, callable $next) : PromiseInterface { - $this->logger->debug('SOURCE_FETCH: Fetching fresh data from source', ['key' => $context->key]); + $this->logger->debug('AsyncCache SOURCE_FETCH: Fetching fresh data from source', ['key' => $context->key]); $start = (float) $context->clock->now()->format('U.u'); @@ -72,7 +72,7 @@ function ($data) use ($context, $start) { $now = (float) $context->clock->now()->format('U.u'); $generation_time = $now - $start; - $this->logger->debug('SOURCE_FETCH_SUCCESS: Successfully fetched from source', [ + $this->logger->debug('AsyncCache SOURCE_FETCH_SUCCESS: Successfully fetched from source', [ 'key' => $context->key, 'generation_time' => round($generation_time, 4), ]); @@ -88,7 +88,7 @@ function ($data) use ($context, $start) { // 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('PERSISTENCE_ERROR: Failed to save fresh data to cache', [ + $this->logger->error('AsyncCache PERSISTENCE_ERROR: Failed to save fresh data to cache', [ 'key' => $context->key, 'error' => $e->getMessage(), ]); @@ -98,7 +98,7 @@ function (\Throwable $e) use ($context) { return $data; } )->catch(function (\Throwable $e) use ($context) { - $this->logger->debug('SOURCE_FETCH_ERROR: Pipeline execution failed', [ + $this->logger->debug('AsyncCache SOURCE_FETCH_ERROR: Pipeline execution failed', [ 'key' => $context->key, 'error' => $e->getMessage(), ]); diff --git a/src/Middleware/StaleOnErrorMiddleware.php b/src/Middleware/StaleOnErrorMiddleware.php index 7fb3b94..128bfe8 100644 --- a/src/Middleware/StaleOnErrorMiddleware.php +++ b/src/Middleware/StaleOnErrorMiddleware.php @@ -64,7 +64,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $next($context)->catch( function (\Throwable $error) use ($context) { if (null !== $context->stale_item) { - $this->logger->warning('STALE_ON_ERROR: Fetch failed, serving stale data as fallback', [ + $this->logger->warning('AsyncCache STALE_ON_ERROR: Fetch failed, serving stale data as fallback', [ 'key' => $context->key, 'error' => $error->getMessage(), ]); @@ -84,7 +84,7 @@ function (\Throwable $error) use ($context) { return $stale_data; } - $this->logger->error('FETCH_ERROR: Fetch failed and no stale data available', [ + $this->logger->error('AsyncCache FETCH_ERROR: Fetch failed and no stale data available', [ 'key' => $context->key, 'error' => $error->getMessage(), ]); diff --git a/src/Middleware/StrategyMiddleware.php b/src/Middleware/StrategyMiddleware.php index d02ce96..87e2a50 100644 --- a/src/Middleware/StrategyMiddleware.php +++ b/src/Middleware/StrategyMiddleware.php @@ -62,7 +62,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $stale_item = $context->stale_item; if (null === $stale_item) { - $this->logger->debug('STRATEGY_MISS: No cached item found, proceeding to fetch', ['key' => $context->key]); + $this->logger->debug('AsyncCache STRATEGY_MISS: No cached item found, proceeding to fetch', ['key' => $context->key]); return $next($context); } @@ -71,7 +71,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface $is_fresh = $stale_item->isFresh($now_ts); if ($is_fresh) { - $this->logger->debug('STRATEGY_HIT: Item is fresh, returning from cache', ['key' => $context->key]); + $this->logger->debug('AsyncCache STRATEGY_HIT: Item is fresh, returning from cache', ['key' => $context->key]); $this->dispatchHit($context, $stale_item->data); /** @var T $data */ @@ -81,13 +81,13 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface } if (CacheStrategy::Background === $context->options->strategy) { - $this->logger->debug('STRATEGY_BACKGROUND: Item is stale, returning stale and refreshing in background', ['key' => $context->key]); + $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('STRATEGY_BACKGROUND_ERROR: Background refresh failed', [ + $this->logger->error('AsyncCache STRATEGY_BACKGROUND_ERROR: Background refresh failed', [ 'key' => $context->key, 'error' => $e->getMessage(), ]); @@ -99,7 +99,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return resolve($data); } - $this->logger->debug('STRATEGY_STRICT: Item is stale, waiting for fresh data', ['key' => $context->key]); + $this->logger->debug('AsyncCache STRATEGY_STRICT: Item is stale, waiting for fresh data', ['key' => $context->key]); return $next($context); } diff --git a/src/Middleware/TagValidationMiddleware.php b/src/Middleware/TagValidationMiddleware.php index e5960ad..c110843 100644 --- a/src/Middleware/TagValidationMiddleware.php +++ b/src/Middleware/TagValidationMiddleware.php @@ -61,7 +61,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface return $next($context); } - $this->logger->debug('TAG_VALIDATION_START: Validating tags', ['key' => $context->key, 'tags' => array_keys($item->tag_versions)]); + $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)); @@ -72,7 +72,7 @@ public function handle(CacheContext $context, callable $next) : PromiseInterface 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('TAG_INVALID: Version mismatch for tag', [ + $this->logger->debug('AsyncCache TAG_INVALID: Version mismatch for tag', [ 'key' => $context->key, 'tag' => $tag, 'saved' => $saved_version, @@ -84,7 +84,7 @@ function (array $current_versions) use ($context, $item, $next) { } } - $this->logger->debug('TAG_VALID: All tags are valid', ['key' => $context->key]); + $this->logger->debug('AsyncCache TAG_VALID: All tags are valid', ['key' => $context->key]); // Tags are valid. If the item is fresh, we can short-circuit and return it. if ($item->isFresh($context->clock->now()->getTimestamp())) { @@ -98,7 +98,7 @@ function (array $current_versions) use ($context, $item, $next) { return $next($context); }, function (\Throwable $e) use ($context, $next) { - $this->logger->error('TAG_FETCH_ERROR: Failed to fetch tag versions', ['key' => $context->key, 'error' => $e->getMessage()]); + $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; From ab141c40b235af6148c572600336fa204e15ab0d Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:10:44 +0200 Subject: [PATCH 21/27] feat: Add resetRateLimit method to AsyncCacheManager for specific bucket resets --- src/AsyncCacheManager.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AsyncCacheManager.php b/src/AsyncCacheManager.php index 28b985b..4a6e235 100644 --- a/src/AsyncCacheManager.php +++ b/src/AsyncCacheManager.php @@ -321,4 +321,14 @@ public function clearRateLimiter() : void { // No-op for factory. } + + /** + * Resets the rate limit for a specific key. + * + * @param string $key The rate limit key to reset + */ + public function resetRateLimit(string $key) : void + { + $this->rate_limiter?->create($key)->reset(); + } } From 0ff89bc3806455f9a76966df845b1c577f2eee43 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:10:49 +0200 Subject: [PATCH 22/27] test: Add unit test for resetRateLimit functionality --- tests/Unit/AsyncCacheManagerTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Unit/AsyncCacheManagerTest.php b/tests/Unit/AsyncCacheManagerTest.php index bad18f5..7abd44d 100644 --- a/tests/Unit/AsyncCacheManagerTest.php +++ b/tests/Unit/AsyncCacheManagerTest.php @@ -238,6 +238,21 @@ public function testClearAndRateLimiter() : void $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); From 6da1ad104a1859c544d359e37137504e7e27667a Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:19:17 +0200 Subject: [PATCH 23/27] refactor: Remove obsolete clearRateLimiter method --- src/AsyncCacheManager.php | 10 ---------- tests/Unit/AsyncCacheManagerTest.php | 6 ------ 2 files changed, 16 deletions(-) diff --git a/src/AsyncCacheManager.php b/src/AsyncCacheManager.php index 4a6e235..f0fec2f 100644 --- a/src/AsyncCacheManager.php +++ b/src/AsyncCacheManager.php @@ -312,16 +312,6 @@ public function getRateLimiter() : ?RateLimiterFactoryInterface return $this->rate_limiter; } - /** - * Resets the rate limiter state. - * - * @note RateLimiterFactoryInterface does not support global reset in Symfony. - */ - public function clearRateLimiter() : void - { - // No-op for factory. - } - /** * Resets the rate limit for a specific key. * diff --git a/tests/Unit/AsyncCacheManagerTest.php b/tests/Unit/AsyncCacheManagerTest.php index 7abd44d..40c65a8 100644 --- a/tests/Unit/AsyncCacheManagerTest.php +++ b/tests/Unit/AsyncCacheManagerTest.php @@ -232,12 +232,6 @@ public function testInvalidateTags() : void $this->assertTrue(await($mgr->invalidateTags($tags))); } - public function testClearAndRateLimiter() : void - { - $this->manager->clearRateLimiter(); - $this->assertSame($this->rateLimiter, $this->manager->getRateLimiter()); - } - public function testResetRateLimit() : void { $key = 'api_limit_key'; From ebc45a00c223c58889cede5387a60a7b4758f444 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:25:26 +0200 Subject: [PATCH 24/27] style: Apply PHP CS Fixer formatting --- src/Middleware/CacheLookupMiddleware.php | 1 - src/Middleware/RateLimitMiddleware.php | 1 - src/Middleware/SourceFetchMiddleware.php | 1 - src/Middleware/StrategyMiddleware.php | 1 - 4 files changed, 4 deletions(-) diff --git a/src/Middleware/CacheLookupMiddleware.php b/src/Middleware/CacheLookupMiddleware.php index 7b5b50c..f651d08 100644 --- a/src/Middleware/CacheLookupMiddleware.php +++ b/src/Middleware/CacheLookupMiddleware.php @@ -28,7 +28,6 @@ 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; diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php index 110fc31..b89730b 100644 --- a/src/Middleware/RateLimitMiddleware.php +++ b/src/Middleware/RateLimitMiddleware.php @@ -34,7 +34,6 @@ use Psr\Log\LoggerInterface; use React\Promise\PromiseInterface; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; - use function React\Promise\resolve; /** diff --git a/src/Middleware/SourceFetchMiddleware.php b/src/Middleware/SourceFetchMiddleware.php index f4bc265..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; diff --git a/src/Middleware/StrategyMiddleware.php b/src/Middleware/StrategyMiddleware.php index 87e2a50..f3ba825 100644 --- a/src/Middleware/StrategyMiddleware.php +++ b/src/Middleware/StrategyMiddleware.php @@ -33,7 +33,6 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use React\Promise\PromiseInterface; - use function React\Promise\resolve; /** From 0f00a897c039b4ff448e70bd7703bce5558b2737 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:27:17 +0200 Subject: [PATCH 25/27] style: Apply PHP CS Fixer formatting to tests --- .../Middleware/CacheLookupMiddlewareTest.php | 14 +++++++------- .../Unit/Middleware/RateLimitMiddlewareTest.php | 17 ++++++++--------- .../Unit/Middleware/StrategyMiddlewareTest.php | 12 ++++++------ 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/Unit/Middleware/CacheLookupMiddlewareTest.php b/tests/Unit/Middleware/CacheLookupMiddlewareTest.php index 0d5113b..2cd34f1 100644 --- a/tests/Unit/Middleware/CacheLookupMiddlewareTest.php +++ b/tests/Unit/Middleware/CacheLookupMiddlewareTest.php @@ -35,9 +35,9 @@ public function testSetsStaleItemAndCallsNextIfFound() : void $context = new CacheContext('k', fn () => null, new CacheOptions(), $this->clock); $this->storage->method('get')->willReturn(\React\Promise\resolve($item)); $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); } @@ -47,7 +47,7 @@ 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); } @@ -57,7 +57,7 @@ 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))); } @@ -67,9 +67,9 @@ 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'); - + $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())); @@ -80,7 +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))); } } diff --git a/tests/Unit/Middleware/RateLimitMiddlewareTest.php b/tests/Unit/Middleware/RateLimitMiddlewareTest.php index f168d9f..621cfa3 100644 --- a/tests/Unit/Middleware/RateLimitMiddlewareTest.php +++ b/tests/Unit/Middleware/RateLimitMiddlewareTest.php @@ -4,10 +4,9 @@ use Fyennyi\AsyncCache\CacheOptions; use Fyennyi\AsyncCache\Core\CacheContext; -use Fyennyi\AsyncCache\Enum\CacheStatus; +use Fyennyi\AsyncCache\Exception\RateLimitException; use Fyennyi\AsyncCache\Middleware\RateLimitMiddleware; use Fyennyi\AsyncCache\Model\CachedItem; -use Fyennyi\AsyncCache\Exception\RateLimitException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; @@ -42,7 +41,7 @@ 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); @@ -56,10 +55,10 @@ public function testCallsNextIfAccepted() : void $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))); } @@ -70,10 +69,10 @@ public function testThrowsExceptionIfExceededAndNoStale() : void $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)); } @@ -87,10 +86,10 @@ public function testReturnsStaleIfExceededAndConfigured() : void $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 index 94807a3..ab39eb1 100644 --- a/tests/Unit/Middleware/StrategyMiddlewareTest.php +++ b/tests/Unit/Middleware/StrategyMiddlewareTest.php @@ -34,7 +34,7 @@ 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))); } @@ -44,7 +44,7 @@ public function testReturnsFreshHitImmediately() : void $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))); } @@ -53,13 +53,13 @@ public function testBackgroundStrategyReturnsStaleAndRefreshesInBackground() : v $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); } @@ -69,9 +69,9 @@ 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))); } } From b30432f488fe174e2132aeff1035cc29a51972a3 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 12:52:07 +0200 Subject: [PATCH 26/27] test: Add unit test for getRateLimiter in AsyncCacheManager --- tests/Unit/AsyncCacheManagerTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Unit/AsyncCacheManagerTest.php b/tests/Unit/AsyncCacheManagerTest.php index 40c65a8..c52387a 100644 --- a/tests/Unit/AsyncCacheManagerTest.php +++ b/tests/Unit/AsyncCacheManagerTest.php @@ -232,6 +232,11 @@ public function testInvalidateTags() : void $this->assertTrue(await($mgr->invalidateTags($tags))); } + public function testGetRateLimiter() : void + { + $this->assertSame($this->rateLimiter, $this->manager->getRateLimiter()); + } + public function testResetRateLimit() : void { $key = 'api_limit_key'; From 0fff12c3f9f6624a992f4de1a7e4899565b05003 Mon Sep 17 00:00:00 2001 From: Serhii Cherneha Date: Sat, 28 Feb 2026 13:34:50 +0200 Subject: [PATCH 27/27] docs: Update middleware pipeline description and rate limiting integration --- docs/advanced/middleware.md | 12 +++++++----- docs/reference.md | 10 ++++++++-- docs/usage/rate-limiting.md | 24 ++++++++++++++++-------- 3 files changed, 31 insertions(+), 15 deletions(-) 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.