Feature Implementation Plan: API Cache Warming¶
Overview¶
Implement event-driven cache warming that automatically re-populates the cache for popular API endpoints after cache invalidation. This ensures the frontend almost always receives cached responses, even immediately after data changes.
Dependencies:
- api-request-logging.md (must be implemented first - provides pattern data)
- Existing CacheInvalidationSubscriber infrastructure
Related Plans:
- api-request-admin-ui.md (uses same pattern data)
Todo Checklist¶
- [ ] Add
relatedTagsfield toApiRequestPatternentity - [ ] Update logging to capture cache tags for each request
- [ ] Create
WarmCacheMessagemessage class - [ ] Create
WarmCacheMessageHandlerhandler - [ ] Create
CacheWarmingServicefor core warming logic - [ ] Configure new Messenger transport for cache warming
- [ ] Integrate with
CacheInvalidationSubscriber - [ ] Add freshness tracking (
lastWarmedfield) - [ ] Implement throttling and lock-based deduplication
- [ ] Write tests
Analysis & Investigation¶
Existing Infrastructure¶
Cache Invalidation Flow:
Entity Change (Doctrine event)
↓
CacheInvalidationSubscriber::invalidate()
↓
├── $apiCache->invalidateTags($tags) ← Backend cache cleared
└── $frontendRevalidation->revalidate($tags) ← Frontend notified
Tag Mapping:
- CacheInvalidationSubscriber maps entities to cache tags (e.g., CoffeeBean → coffee_beans_list)
- FrontendRevalidationService maps backend tags to frontend tags
- Tags are granular: coffee_beans_list, roasters_detail, filter_metadata, etc.
Async Processing: - Messenger with Redis transport - Multiple transports for different workloads - Retry strategies configured per transport
Architecture Decisions¶
| Decision | Choice | Rationale |
|---|---|---|
| Timing | Async via Messenger | Don't block Doctrine transactions |
| Scope | Tag-specific patterns only | Don't warm unrelated endpoints |
| Request method | Call service layer directly | Avoid HTTP overhead |
| Throttling | Lock + rate limit | Prevent overwhelming during bulk updates |
| Freshness | Track lastWarmed timestamp |
Skip recently warmed patterns |
Flow Diagram¶
┌─────────────────────────────────────────────────────────────────┐
│ Cache Warming Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Entity Update (e.g., CoffeeBean saved) │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ CacheInvalidationSubscriber│ │
│ │ - Invalidate backend tags│ │
│ │ - Notify frontend │ │
│ │ - Dispatch WarmCacheMsg │ ← NEW │
│ └────────────────────────────┘ │
│ │ │
│ ▼ (async via Messenger) │
│ ┌────────────────────────────┐ │
│ │ WarmCacheMessageHandler │ │
│ │ - Acquire lock │ │
│ │ - Query top patterns │ │
│ │ - Filter by tags │ │
│ │ - Check freshness │ │
│ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ CacheWarmingService │ │
│ │ - Reconstruct Request │ │
│ │ - Call service layer │ │
│ │ - Update lastWarmed │ │
│ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ Service Layer │ │
│ │ (FilterAggregationService, │ │
│ │ CoffeeBeanRepository) │ │
│ │ - Execute query │ │
│ │ - Store in Redis cache │ ← Cache now warm! │
│ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Implementation Plan¶
Prerequisites¶
ApiRequestPatternentity exists (fromapi-request-logging.md)- Logging pipeline is operational with pattern data
- Some traffic has been logged to have patterns to warm
Step 1: Add relatedTags and lastWarmed to ApiRequestPattern¶
File: src/Entity/ApiRequestPattern.php (modify)
#[ORM\Column(type: Types::JSON)]
private array $relatedTags = [];
#[ORM\Column(nullable: true)]
private ?DateTimeImmutable $lastWarmed = null;
public function getRelatedTags(): array
{
return $this->relatedTags;
}
public function setRelatedTags(array $relatedTags): self
{
$this->relatedTags = $relatedTags;
return $this;
}
public function getLastWarmed(): ?DateTimeImmutable
{
return $this->lastWarmed;
}
public function markAsWarmed(): self
{
$this->lastWarmed = new DateTimeImmutable();
return $this;
}
Generate migration:
Step 2: Create Tag Extractor Service¶
File: src/Service/Api/RequestTagExtractor.php
<?php
namespace App\Service\Api;
use Symfony\Component\HttpFoundation\Request;
/**
* Extracts cache tags that would be relevant for a given API request.
* Used to associate logged patterns with cache tags for warming.
*/
final class RequestTagExtractor
{
private const PATH_TO_TAGS_MAP = [
'/api/coffee-beans' => ['coffee_beans_list', 'coffee_beans'],
'/api/roasters' => ['roasters_list', 'roasters'],
'/api/filter-metadata' => ['filter_metadata'],
'/api/varieties' => ['varieties_list'],
'/api/regions' => ['regions_list'],
'/api/countries' => ['countries_list'],
'/api/processing-methods' => ['processing_methods_list'],
'/api/roast-levels' => ['roast_levels_list'],
'/api/flavor-wheel' => ['flavor_wheel'],
];
private const PARAM_TO_TAGS_MAP = [
'roaster' => ['roasters_detail'],
'roasters' => ['roasters_detail'],
'region' => ['regions_detail'],
'regions' => ['regions_detail'],
'variety' => ['varieties_detail'],
'varieties' => ['varieties_detail'],
'processingMethod' => ['processing_methods_detail'],
'processingMethods' => ['processing_methods_detail'],
'roastLevel' => ['roast_levels_detail'],
'roastLevels' => ['roast_levels_detail'],
];
/**
* Extract cache tags relevant to this request.
*
* @return string[]
*/
public function extractTags(Request $request): array
{
$path = $request->getPathInfo();
$params = $request->query->all();
$tags = [];
// Add tags based on path
foreach (self::PATH_TO_TAGS_MAP as $pathPrefix => $pathTags) {
if (str_starts_with($path, $pathPrefix)) {
$tags = array_merge($tags, $pathTags);
break;
}
}
// Add tags based on query params used
foreach ($params as $key => $value) {
$normalizedKey = rtrim($key, '[]');
if (isset(self::PARAM_TO_TAGS_MAP[$normalizedKey]) && !empty($value)) {
$tags = array_merge($tags, self::PARAM_TO_TAGS_MAP[$normalizedKey]);
}
}
return array_values(array_unique($tags));
}
}
Step 3: Update Logging Handler to Capture Tags¶
File: src/MessageHandler/LogApiRequestHandler.php (modify)
public function __construct(
private ApiRequestPatternRepository $patternRepository,
private EntityManagerInterface $entityManager,
private RequestTagExtractor $tagExtractor, // NEW
private LoggerInterface $logger,
) {}
public function __invoke(LogApiRequestMessage $message): void
{
// ... existing code ...
// Extract tags for the pattern
$request = Request::create($message->path, $message->method, $message->queryParams);
$tags = $this->tagExtractor->extractTags($request);
// Update pattern with tags (upsert should handle this)
$patternId = $this->patternRepository->upsertPattern(
$message->method,
$message->path,
$queryHash,
$message->queryParams,
$message->responseTimeMs,
$tags, // NEW parameter
);
// ... rest of code ...
}
Step 4: Update Repository Upsert to Handle Tags¶
File: src/Repository/ApiRequestPatternRepository.php (modify)
public function upsertPattern(
string $method,
string $path,
string $queryHash,
array $queryParams,
float $responseTimeMs,
array $relatedTags = [], // NEW
): string {
$conn = $this->getEntityManager()->getConnection();
$sql = <<<'SQL'
INSERT INTO api_request_pattern
(id, method, path, query_hash, query_params, related_tags, hit_count, avg_response_time_ms, last_seen_at, created_at)
VALUES
(gen_random_uuid(), :method, :path, :hash, :params, :tags, 1, :responseTime, NOW(), NOW())
ON CONFLICT (query_hash) DO UPDATE SET
hit_count = api_request_pattern.hit_count + 1,
avg_response_time_ms = (
(api_request_pattern.avg_response_time_ms * api_request_pattern.hit_count) + :responseTime
) / (api_request_pattern.hit_count + 1),
last_seen_at = NOW(),
related_tags = CASE
WHEN api_request_pattern.related_tags = '[]'::jsonb THEN :tags::jsonb
ELSE api_request_pattern.related_tags
END
RETURNING id
SQL;
$result = $conn->executeQuery($sql, [
'method' => $method,
'path' => $path,
'hash' => $queryHash,
'params' => json_encode($queryParams),
'tags' => json_encode($relatedTags),
'responseTime' => $responseTimeMs,
]);
return $result->fetchOne();
}
/**
* Find patterns that need warming based on invalidated tags.
*
* @param string[] $tags Invalidated cache tags
* @return ApiRequestPattern[]
*/
public function findPatternsToWarm(
array $tags,
int $limit = 10,
int $minSecondsSinceLastWarm = 60,
): array {
$conn = $this->getEntityManager()->getConnection();
// Use native query for JSON array overlap check
$sql = <<<'SQL'
SELECT p.id
FROM api_request_pattern p
WHERE p.related_tags ?| :tags
AND (p.last_warmed IS NULL OR p.last_warmed < :threshold)
ORDER BY p.hit_count DESC
LIMIT :limit
SQL;
$threshold = new \DateTimeImmutable("-{$minSecondsSinceLastWarm} seconds");
$result = $conn->executeQuery($sql, [
'tags' => '{' . implode(',', $tags) . '}',
'threshold' => $threshold->format('Y-m-d H:i:s'),
'limit' => $limit,
]);
$ids = $result->fetchFirstColumn();
if (empty($ids)) {
return [];
}
return $this->createQueryBuilder('p')
->where('p.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
Step 5: Create WarmCacheMessage¶
File: src/Message/WarmCacheMessage.php
<?php
namespace App\Message;
final readonly class WarmCacheMessage
{
/**
* @param string[] $invalidatedTags Cache tags that were invalidated
*/
public function __construct(
public array $invalidatedTags,
) {}
}
Step 6: Create CacheWarmingService¶
File: src/Service/Api/CacheWarmingService.php
<?php
namespace App\Service\Api;
use App\Entity\ApiRequestPattern;
use App\Repository\ApiRequestPatternRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Lock\LockFactory;
final class CacheWarmingService
{
private const MAX_PATTERNS_PER_RUN = 10;
private const MIN_SECONDS_SINCE_LAST_WARM = 60;
private const DELAY_BETWEEN_PATTERNS_MS = 50;
private const LOCK_TTL_SECONDS = 120;
public function __construct(
private readonly ApiRequestPatternRepository $patternRepository,
private readonly EntityManagerInterface $entityManager,
private readonly FilterAggregationService $filterAggregationService,
private readonly CoffeeBeanListService $coffeeBeanListService,
private readonly LockFactory $lockFactory,
private readonly LoggerInterface $logger,
) {}
/**
* Warm cache for patterns matching the invalidated tags.
*
* @param string[] $invalidatedTags
*/
public function warmForTags(array $invalidatedTags): void
{
if (empty($invalidatedTags)) {
return;
}
// Acquire lock to prevent concurrent warming
$lock = $this->lockFactory->createLock(
'cache_warming',
self::LOCK_TTL_SECONDS
);
if (!$lock->acquire()) {
$this->logger->debug('Cache warming skipped - another job is running');
return;
}
try {
$patterns = $this->patternRepository->findPatternsToWarm(
$invalidatedTags,
self::MAX_PATTERNS_PER_RUN,
self::MIN_SECONDS_SINCE_LAST_WARM,
);
if (empty($patterns)) {
$this->logger->debug('No patterns to warm for tags', [
'tags' => $invalidatedTags,
]);
return;
}
$warmed = 0;
foreach ($patterns as $pattern) {
if ($this->warmPattern($pattern)) {
$warmed++;
}
// Small delay to avoid overwhelming the system
usleep(self::DELAY_BETWEEN_PATTERNS_MS * 1000);
}
$this->logger->info('Cache warming completed', [
'tags' => $invalidatedTags,
'patterns_warmed' => $warmed,
'patterns_found' => count($patterns),
]);
} finally {
$lock->release();
}
}
private function warmPattern(ApiRequestPattern $pattern): bool
{
try {
// Reconstruct request from pattern
$request = Request::create(
$pattern->getPath(),
$pattern->getMethod(),
$pattern->getQueryParams(),
);
// Route to appropriate service based on path
$this->executeForPath($pattern->getPath(), $request);
// Mark as warmed
$pattern->markAsWarmed();
$this->entityManager->flush();
return true;
} catch (\Throwable $e) {
$this->logger->warning('Failed to warm pattern', [
'pattern_id' => $pattern->getId(),
'path' => $pattern->getPath(),
'error' => $e->getMessage(),
]);
return false;
}
}
private function executeForPath(string $path, Request $request): void
{
// Match path to service - this naturally populates cache
// via the existing cache logic in these services
match (true) {
str_starts_with($path, '/api/coffee-beans') => $this->coffeeBeanListService->getList($request),
str_starts_with($path, '/api/filter-metadata') => $this->filterAggregationService->computeFacets($request),
// Add more endpoints as needed
default => throw new \RuntimeException("Unknown path for warming: {$path}"),
};
}
}
Step 7: Create WarmCacheMessageHandler¶
File: src/MessageHandler/WarmCacheMessageHandler.php
<?php
namespace App\MessageHandler;
use App\Message\WarmCacheMessage;
use App\Service\Api\CacheWarmingService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class WarmCacheMessageHandler
{
public function __construct(
private CacheWarmingService $cacheWarmingService,
private LoggerInterface $logger,
) {}
public function __invoke(WarmCacheMessage $message): void
{
$this->logger->debug('Processing cache warming message', [
'tags' => $message->invalidatedTags,
]);
$this->cacheWarmingService->warmForTags($message->invalidatedTags);
}
}
Step 8: Configure Messenger Transport¶
File: config/packages/messenger.php (add to existing config)
// Add new transport for cache warming
'cache_warming' => [
'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%',
'options' => [
'queue_name' => 'cache_warming',
],
'retry_strategy' => [
'max_retries' => 1, // Best-effort, don't retry much
'delay' => 5000,
],
],
// Add routing
'routing' => [
// ... existing routes ...
WarmCacheMessage::class => 'cache_warming',
],
Step 9: Integrate with CacheInvalidationSubscriber¶
File: src/EventSubscriber/CacheInvalidationSubscriber.php (modify)
use App\Message\WarmCacheMessage;
use Symfony\Component\Messenger\MessageBusInterface;
final class CacheInvalidationSubscriber
{
public function __construct(
#[Autowire(service: 'api.cache')]
private readonly TagAwareCacheInterface $apiCache,
private readonly FrontendRevalidationService $frontendRevalidation,
private readonly MessageBusInterface $messageBus, // NEW
) {}
private function invalidate(LifecycleEventArgs $args): void
{
// ... existing tag determination code ...
if ($tagsToInvalidate === []) {
return;
}
$this->apiCache->invalidateTags($tagsToInvalidate);
$this->frontendRevalidation->revalidate($tagsToInvalidate);
// NEW: Dispatch cache warming message
$this->messageBus->dispatch(new WarmCacheMessage($tagsToInvalidate));
}
}
Step 10: Create CoffeeBeanListService (if not exists)¶
If there's no dedicated service for coffee bean list queries, create one that wraps the repository with caching:
File: src/Service/Api/CoffeeBeanListService.php
<?php
namespace App\Service\Api;
use App\Repository\CoffeeBeanRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final readonly class CoffeeBeanListService
{
public function __construct(
private CoffeeBeanRepository $repository,
private CacheKeyGenerator $cacheKeyGenerator,
#[Autowire(service: 'api.cache')]
private TagAwareCacheInterface $cache,
) {}
public function getList(Request $request): array
{
$cacheKey = $this->cacheKeyGenerator->createKey($request, 'coffee_beans_');
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($request) {
$item->tag(['coffee_beans_list', 'coffee_beans']);
$page = max(1, $request->query->getInt('page', 1));
$limit = min(100, max(1, $request->query->getInt('limit', 20)));
return $this->repository->findByRequest($request, $page, $limit);
});
}
}
Testing Strategy¶
Unit Tests¶
- RequestTagExtractor
- Test path-to-tags mapping
- Test param-to-tags mapping
-
Test combined extraction
-
CacheWarmingService
- Test lock acquisition
- Test pattern filtering by tags
-
Test freshness check (skip recently warmed)
-
WarmCacheMessageHandler
- Test message processing
Integration Tests¶
- Full warming flow:
- Create pattern with specific tags
- Dispatch WarmCacheMessage with matching tags
-
Verify cache is populated
-
Freshness skip test:
- Warm a pattern
- Immediately try to warm again
-
Verify second warming is skipped
-
Lock test:
- Start warming job
- Try concurrent warming
- Verify second job is skipped
Manual Testing¶
# Start cache warming worker
make sf c="messenger:consume cache_warming -vv"
# Trigger cache invalidation (e.g., update a coffee bean in admin)
# Watch worker logs for warming activity
# Verify cache is populated
make api path="/api/coffee-beans" # Should show X-Cache: HIT
Success Criteria¶
- [ ] Cache warming triggers automatically after invalidation
- [ ] Only patterns matching invalidated tags are warmed
- [ ] Recently warmed patterns are skipped (freshness check)
- [ ] Concurrent warming jobs are deduplicated (lock)
- [ ] System is not overwhelmed during bulk updates (rate limiting)
- [ ] Cache hit rate improves after implementation
Metrics to Monitor¶
Add logging/metrics for: - Patterns warmed per invalidation event - Time spent warming - Cache hit rate before/after - Patterns frequently warmed but rarely hit (false positives)
Configuration Options¶
Consider making these configurable via environment variables:
# .env
CACHE_WARMING_MAX_PATTERNS=10
CACHE_WARMING_MIN_SECONDS_SINCE_LAST=60
CACHE_WARMING_DELAY_MS=50
Future Enhancements (Out of Scope)¶
- Priority-based warming - Warm highest-hit patterns first even if tags don't match
- Scheduled warming - Proactive warming on a schedule, not just reactive
- Warming analytics - Dashboard showing warming effectiveness
- Per-endpoint configuration - Different warming strategies per endpoint
- Warm on deploy - Trigger warming after deployment to pre-populate cache