Frontend Revalidation Batching¶
Problem¶
The frontend revalidation endpoint has a rate limit of 100 calls per minute. When multiple entities are updated (e.g., batch imports, crawl processing), each entity change triggers a separate revalidation call, causing 429 errors.
Evidence: Sentry issue BEANS-BACKEND-5Q shows 918 occurrences of HTTP 429 errors from the frontend.
Solution¶
Batch revalidation tags and send one request per "operation window" instead of one request per entity change.
Architecture¶
Current Flow¶
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::revalidate() → HTTP POST
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::revalidate() → HTTP POST
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::revalidate() → HTTP POST
(3 HTTP requests)
New Flow¶
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::queueTags()
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::queueTags()
Entity Update → CacheInvalidationSubscriber → FrontendRevalidationService::queueTags()
End of Request/Message → FrontendRevalidationService::flush() → HTTP POST (1 request with merged tags)
Implementation Steps¶
Step 1: Modify FrontendRevalidationService¶
Add tag collection instead of immediate dispatch:
final class FrontendRevalidationService
{
/** @var string[] */
private array $pendingTags = [];
// Rename current revalidate() to queueTags() - collects without sending
public function queueTags(array $backendTags): void
{
$frontendTags = $this->mapToFrontendTags($backendTags);
$this->pendingTags = array_unique(array_merge($this->pendingTags, $frontendTags));
}
// New method to send collected tags
public function flush(): void
{
if ($this->pendingTags === []) {
return;
}
$tagsToSend = $this->pendingTags;
$this->pendingTags = []; // Clear before sending to avoid duplicates on retry
$this->sendRevalidationRequest($tagsToSend);
}
// Extract current HTTP logic to private method
private function sendRevalidationRequest(array $frontendTags): void
{
// Current HTTP POST logic with Sentry error handling
}
}
Step 2: Update CacheInvalidationSubscriber¶
Change from revalidate() to queueTags():
// In CacheInvalidationSubscriber
$this->frontendRevalidation->queueTags($tagsToInvalidate);
// Instead of: $this->frontendRevalidation->revalidate($tagsToInvalidate);
Step 3: Create FrontendRevalidationFlushListener¶
Flush pending tags at end of HTTP requests:
#[AsEventListener(event: KernelEvents::TERMINATE, priority: -100)]
final readonly class FrontendRevalidationFlushListener
{
public function __construct(
private FrontendRevalidationService $revalidationService,
) {}
public function onKernelTerminate(): void
{
$this->revalidationService->flush();
}
}
Step 4: Handle Messenger Workers¶
For async message processing, flush after each message. Options:
Option A: Messenger middleware (preferred)
final class FrontendRevalidationMiddleware implements MiddlewareInterface
{
public function __construct(
private FrontendRevalidationService $revalidationService,
) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
try {
return $stack->next()->handle($envelope, $stack);
} finally {
$this->revalidationService->flush();
}
}
}
Register in config/packages/messenger.php:
'buses' => [
'messenger.bus.default' => [
'middleware' => [
FrontendRevalidationMiddleware::class,
],
],
],
Option B: Event listener on WorkerMessageHandledEvent
#[AsEventListener(event: WorkerMessageHandledEvent::class)]
public function onMessageHandled(): void
{
$this->revalidationService->flush();
}
Step 5: Optional - Time-based batching for workers¶
For high-throughput scenarios, add time-based windowing:
private ?float $windowStart = null;
private const BATCH_WINDOW_SECONDS = 1.0;
public function queueTags(array $backendTags): void
{
if ($this->windowStart === null) {
$this->windowStart = microtime(true);
}
$frontendTags = $this->mapToFrontendTags($backendTags);
$this->pendingTags = array_unique(array_merge($this->pendingTags, $frontendTags));
// Auto-flush if window exceeded
if ((microtime(true) - $this->windowStart) >= self::BATCH_WINDOW_SECONDS) {
$this->flush();
}
}
public function flush(): void
{
$this->windowStart = null;
// ... rest of flush logic
}
Files to Modify¶
src/Service/Api/FrontendRevalidationService.php- Add batching logicsrc/EventSubscriber/CacheInvalidationSubscriber.php- Change toqueueTags()src/EventListener/FrontendRevalidationFlushListener.php- New file for HTTP request flushconfig/packages/messenger.php- Add middleware (if using Option A)src/Messenger/Middleware/FrontendRevalidationMiddleware.php- New file for Messenger flush
Testing¶
- Unit test:
FrontendRevalidationServicecollects and deduplicates tags - Unit test:
flush()sends one request with all collected tags - Integration test: Multiple entity updates result in single revalidation call
- Manual test: Verify no more 429 errors in Sentry after deployment
Rollback¶
If issues arise, revert to immediate dispatch by:
1. Changing queueTags() back to revalidate() with immediate HTTP call
2. Removing the flush listener and middleware