Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9b7469c
feat: Implement StrategyMiddleware for centralized strategy logic
ChernegaSergiy Feb 28, 2026
8e048ac
feat: Implement RateLimitMiddleware using Symfony RateLimiterFactoryI…
ChernegaSergiy Feb 28, 2026
7ed048c
refactor: Remove strategy logic from CacheLookupMiddleware and delega…
ChernegaSergiy Feb 28, 2026
423076e
refactor: Update AsyncLockMiddleware with double-check after lock and…
ChernegaSergiy Feb 28, 2026
12d9ef9
refactor: Update TagValidationMiddleware with new logging and clarifi…
ChernegaSergiy Feb 28, 2026
eeac721
refactor: Update StaleOnErrorMiddleware to follow new logging and cla…
ChernegaSergiy Feb 28, 2026
5ee21e9
refactor: Update SourceFetchMiddleware with new logging and clarified…
ChernegaSergiy Feb 28, 2026
38d3f62
refactor: Update AsyncCacheManager with new middleware order and Rate…
ChernegaSergiy Feb 28, 2026
934c808
refactor: Update AsyncCacheConfig to use RateLimiterFactoryInterface
ChernegaSergiy Feb 28, 2026
3cca2d4
refactor: Update AsyncCacheConfigBuilder to use RateLimiterFactoryInt…
ChernegaSergiy Feb 28, 2026
99149d4
test: Update AsyncCacheManagerTest for RateLimiterFactoryInterface an…
ChernegaSergiy Feb 28, 2026
af5b56e
test: Update AsyncCacheConfigBuilderTest for RateLimiterFactoryInterface
ChernegaSergiy Feb 28, 2026
e9b3fcb
test: Update CacheLookupMiddlewareTest to reflect new middleware flow
ChernegaSergiy Feb 28, 2026
f37c59f
test: Add unit tests for StrategyMiddleware
ChernegaSergiy Feb 28, 2026
c803fe1
test: Add unit tests for RateLimitMiddleware
ChernegaSergiy Feb 28, 2026
24cee10
fix: Add missing CacheMissEvent import in CacheLookupMiddleware
ChernegaSergiy Feb 28, 2026
6f7f2ae
fix: Restore EventDispatcher usage in AsyncLockMiddleware to track st…
ChernegaSergiy Feb 28, 2026
80dc9d8
fix: Add explicit return type hint for the promise in waitForLock to …
ChernegaSergiy Feb 28, 2026
5a8ec29
fix: Add explicit return type hint for the source fetch result to sat…
ChernegaSergiy Feb 28, 2026
97682af
refactor: Standardize middleware logging with AsyncCache prefix
ChernegaSergiy Feb 28, 2026
ab141c4
feat: Add resetRateLimit method to AsyncCacheManager for specific buc…
ChernegaSergiy Feb 28, 2026
0ff89bc
test: Add unit test for resetRateLimit functionality
ChernegaSergiy Feb 28, 2026
6da1ad1
refactor: Remove obsolete clearRateLimiter method
ChernegaSergiy Feb 28, 2026
ebc45a0
style: Apply PHP CS Fixer formatting
ChernegaSergiy Feb 28, 2026
0f00a89
style: Apply PHP CS Fixer formatting to tests
ChernegaSergiy Feb 28, 2026
b30432f
test: Add unit test for getRateLimiter in AsyncCacheManager
ChernegaSergiy Feb 28, 2026
0fff12c
docs: Update middleware pipeline description and rate limiting integr…
ChernegaSergiy Feb 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 16 additions & 8 deletions docs/usage/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
);
```
Expand All @@ -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.
39 changes: 30 additions & 9 deletions src/AsyncCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand All @@ -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.
Expand All @@ -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())
];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}
6 changes: 3 additions & 3 deletions src/Config/AsyncCacheConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions src/Config/AsyncCacheConfigBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[] */
Expand All @@ -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;

Expand Down
67 changes: 40 additions & 27 deletions src/Middleware/AsyncLockMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> $next
* @param string $lock_key
* @return PromiseInterface<T>
*/
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();
Expand All @@ -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<T> $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;
}
Expand All @@ -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);
}
};
Expand All @@ -152,9 +168,6 @@ function ($cached_item) use ($context, $next, $lock_key, $start_time, $deferred)

/** @var PromiseInterface<T> $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;
}
Expand Down
Loading