How to Reduce Content Entity Cache Tags
- 9 minute read
-
Drupal's implementation of cache tags is a powerful tool for cache invalidation; however, it can result in a large number of cache tags being rendered in the response header. Acquia's load balancers have a soft limit of 23kb for headers. While the Acquia Purge module does hash cache tags to reduce the header size, it's still possible to exceed this limit. See Varnish header limits on Acquia Cloud for more details.
One common cause is content entity cache tags.
Scenario: Taxonomy Term Cache Tags
While a number of scenarios can result in too many cache tags, this post will examine a scenario where taxonomy terms are the culprit. For example, a site has an Article content type with a Tags taxonomy term entity reference field. Collectively, the site's Article nodes reference over 1,000 taxonomy terms.
A view returning Article nodes with the content type's Tags field will generate a large cache tag header. Likewise, calling the JSON:API endpoint for the Article content type with an include
parameter for the Tags field will generate a large cache tag header.
One solution is to consolidate individual taxonomy_term
cache tags into taxonomy_term_list
cache tags with an event subscriber.
Step 1: Register the Event Subscriber
The first step is to register an event subscriber.
term_cache_tag_consolidator.services.yml:
services:
term_cache_tag_consolidator.response_subscriber:
class: Drupal\term_cache_tag_consolidator\EventSubscriber\ResponseSubscriber
arguments: ['@database', '@current_route_match']
tags:
- { name: event_subscriber }
src/EventSubscriber/ResponseSubscriber:
<?php
namespace Drupal\term_cache_tag_consolidator\EventSubscriber;
use Drupal\Core\Database\Connection;
use Drupal\Core\Routing\CurrentRouteMatch;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Replace individual taxonomy term cache tags with vocabulary tags.
*/
class ResponseSubscriber implements EventSubscriberInterface {
/**
* Construct a ResponseSubscriber object.
*/
public function __construct(
private readonly Connection $database,
private readonly CurrentRouteMatch $currenntRouteMatch,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Execute just prior to
// Drupal\Core\EventSubscriber\FinishResponseSubscriber.
$events[KernelEvents::RESPONSE][] = ['onRespond', 17];
return $events;
}
/**
* Modify cache tags on designated cacheable responses.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The event to process.
*/
public function onRespond(ResponseEvent $event) {
if (!$event->isMainRequest()) {
return;
}
}
Step 2: Replace Individual Term Cache Tags
Next, get the cache tags from the Response
object:
$response = $event->getResponse();
// Only modify cache tags when this is a cacheable response.
if ($response instanceof CacheableResponseInterface) {
$tags = $response->getCacheableMetadata()->getCacheTags();
}
Then, collect the term IDs and remove the individual taxonomy_term
tags:
// Loop through cache tags, remove taxonomy terms, and build a list of
// term IDs from removed cache tags.
$tids = [];
foreach ($tags as $key => $tag) {
if (str_starts_with($tag, 'taxonomy_term:')) {
$tag_parts = explode(':', $tag);
$tids[] = $tag_parts[1];
unset($tags[$key]);
}
}
Next, check if any term IDs were collected, and, if so, retrieve the the corresponding vocabulary IDs:
if ($tids) {
// Reindex tags.
$tags = array_values($tags);
// Load vocabularies by term IDs.
$vocabs = $this->database->select('taxonomy_term_data', 't')
->fields('t', ['vid'])
->condition('tid', $tids, 'IN')
->distinct()
->execute()
->fetchAll();
}
Now, add taxonomy_term_list
cache tags to the $tags
array:
// Add vocabulary cache tags.
foreach ($vocabs as $vocab) {
$tags[] = 'taxonomy_term_list:' . $vocab->vid;
}
Finally, update the Response
's cacheable metadata:
// Update cacheable metadata with modified set of cache tags.
$response->getCacheableMetadata()->setCacheTags($tags);
$event->setResponse($response);
Step 3: Limit the Scope of the Event Subscriber
With the code above implemented, taxonomy term cache tags will be consolidated for all responses, not just the two examples from above. It would be prudent to scope the event subscriber's behavior. The example code below limits the event subscriber to update the response by route: 1) the Article content type JSON:API collection endpoint, 2) the Page 1 display for a "Test View" view, and 3) the canonical Node route, but only for Article nodes:
public function onRespond(ResponseEvent $event) {
if (!$event->isMainRequest()) {
return;
}
$route_name = $this->currenntRouteMatch->getRouteName();
// Check route name before proceeding with cache tag replacement.
switch ($route_name) {
// The Article content type JSON:API collection endpoint.
case 'jsonapi.node--article.collection':
// Contine to replace cache tags.
break;
// The Page 1 display for the Test View.
case 'view.test_view.page_1':
// Contine to replace cache tags.
break;
// The canonical route for Article nodes.
case 'entity.node.canonical':
$node = $this->currenntRouteMatch->getParameter('node');
if ($node instanceof NodeInterface) {
if ($node->bundle() == 'article') {
// Contine to replace cache tags.
break;
}
}
// If none match, then return early and skip cache tag replacement.
default:
return;
}
Complete Event Subscriber
Below is the complete Event Subscriber:
<?php
namespace Drupal\term_cache_tag_consolidator\EventSubscriber;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Routing\CurrentRouteMatch;
use Drupal\node\NodeInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Replace individual taxonomy term cache tags with vocabulary tags.
*/
class ResponseSubscriber implements EventSubscriberInterface {
/**
* Construct a ResponseSubscriber object.
*/
public function __construct(
private readonly Connection $database,
private readonly CurrentRouteMatch $currenntRouteMatch,
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Execute just prior to
// Drupal\Core\EventSubscriber\FinishResponseSubscriber.
$events[KernelEvents::RESPONSE][] = ['onRespond', 17];
return $events;
}
/**
* Modify cache tags on designated cacheable responses.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The event to process.
*/
public function onRespond(ResponseEvent $event) {
if (!$event->isMainRequest()) {
return;
}
$route_name = $this->currenntRouteMatch->getRouteName();
// Check route name before proceeding with cache tag replacement.
switch ($route_name) {
// The Article content type JSON:API collection endpoint.
case 'jsonapi.node--article.collection':
// Contine to replace cache tags.
break;
// The Page 1 display for the Test View.
case 'view.test_view.page_1':
// Contine to replace cache tags.
break;
// The canonical route for Article nodes.
case 'entity.node.canonical':
$node = $this->currenntRouteMatch->getParameter('node');
if ($node instanceof NodeInterface) {
if ($node->bundle() == 'article') {
// Contine to replace cache tags.
break;
}
}
// If none match, then return early and skip cache tag replacement.
default:
return;
}
$response = $event->getResponse();
// Only modify cache tags when this is a cacheable response.
if ($response instanceof CacheableResponseInterface) {
$tags = $response->getCacheableMetadata()->getCacheTags();
// Loop through cache tags, remove taxonomy terms, and build a list of
// term IDs from removed cache tags.
$tids = [];
foreach ($tags as $key => $tag) {
if (str_starts_with($tag, 'taxonomy_term:')) {
$tag_parts = explode(':', $tag);
$tids[] = $tag_parts[1];
unset($tags[$key]);
}
}
if ($tids) {
// Reindex tags.
$tags = array_values($tags);
// Load vocabularies by term IDs.
$vocabs = $this->database->select('taxonomy_term_data', 't')
->fields('t', ['vid'])
->condition('tid', $tids, 'IN')
->distinct()
->execute()
->fetchAll();
// Add vocabulary cache tags.
foreach ($vocabs as $vocab) {
$tags[] = 'taxonomy_term_list:' . $vocab->vid;
}
// Update cacheable metadata with modified set of cache tags.
$response->getCacheableMetadata()->setCacheTags($tags);
$event->setResponse($response);
}
}
}
}
Additional Considerations
Please read below for additional considerations:
Cacheability Debugging
By default, Drupal doesn't output cache tag headers. The Acquia Purge module does output an X-Acquia-Purge-Tags
header, which is populated with cache tags. This header is used for cache invalidation at the load balancer layer and is removed before serving the response.
To see cache tags in development, enable http.response.debug_cacheability_headers
in services.yml:
# Cacheability debugging:
#
# Responses with cacheability metadata (CacheableResponseInterface instances)
# get X-Drupal-Cache-Tags, X-Drupal-Cache-Contexts and X-Drupal-Cache-Max-Age
# headers.
#
# For more information about debugging cacheable responses, see
# https://www.drupal.org/developing/api/8/response/cacheable-response-interface
#
# Enabling cacheability debugging is not recommended in production
# environments.
# @default false
http.response.debug_cacheability_headers: true
Drupal will now render a X-Drupal-Cache-Tags
header.
Opportunities for Enhancements
The example code above hardcodes the behavior of the event subscriber. Further enhancements could include the following:
- Exposing route matching conditions as configuration editable via a
SettingsForm
- Exposing route matching conditions as configurable plugins (more extensible than option 1)
- Consolidating other content entity cache tags (e.g. nodes)
Opportunity for Contribution
At present, there isn't a contrib module on Drupal.org that provides this functionality. This contrib gap represents an opportunity for members of the Drupal open source community to contribute back.