From 339643bebb00964b581567f6f5518b8c8682ecf4 Mon Sep 17 00:00:00 2001 From: catch <catch@35733.no-reply.drupal.org> Date: Wed, 7 Jun 2023 16:56:20 +0100 Subject: [PATCH] Issue #2551419 by kristiaanvandeneynde, catch, dawehner, Wim Leers, borisson_, bradjones1, tstoeckler, andypost: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way --- core/core.services.yml | 8 +- core/lib/Drupal/Core/Cache/CacheRedirect.php | 31 ++ core/lib/Drupal/Core/Cache/VariationCache.php | 250 +++++++++ .../Core/Cache/VariationCacheFactory.php | 48 ++ .../Cache/VariationCacheFactoryInterface.php | 21 + .../Core/Cache/VariationCacheInterface.php | 85 +++ .../Core/Render/PlaceholderGenerator.php | 26 +- .../Core/Render/PlaceholderingRenderCache.php | 10 +- core/lib/Drupal/Core/Render/RenderCache.php | 295 ++-------- .../tests/src/Kernel/BlockViewBuilderTest.php | 26 +- .../Functional/BlockContentCacheTagsTest.php | 8 +- .../dynamic_page_cache.services.yml | 6 +- .../DynamicPageCacheSubscriber.php | 136 ++--- .../tests/src/Functional/ResourceTestBase.php | 11 +- .../EntityResource/EntityResourceTestBase.php | 6 +- .../src/Functional/ShortcutCacheTagsTest.php | 15 +- .../Entity/EntityCacheTagsTestBase.php | 62 +-- .../Entity/EntityWithUriCacheTagsTestBase.php | 9 +- .../tests/src/Functional/TrackerTest.php | 12 - .../tests/src/Functional/UserBlocksTest.php | 5 + .../Functional/WorkspaceCacheContextTest.php | 21 +- core/phpstan-baseline.neon | 5 - .../Tests/Core/Cache/VariationCacheTest.php | 506 ++++++++++++++++++ .../Core/Render/RendererBubblingTest.php | 229 +++----- .../Core/Render/RendererPlaceholdersTest.php | 64 +-- .../Drupal/Tests/Core/Render/RendererTest.php | 10 +- .../Tests/Core/Render/RendererTestBase.php | 33 +- 27 files changed, 1271 insertions(+), 667 deletions(-) create mode 100644 core/lib/Drupal/Core/Cache/CacheRedirect.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCache.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactory.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheInterface.php create mode 100644 core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php diff --git a/core/core.services.yml b/core/core.services.yml index 0817e41cd5ac..3243691b26a7 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -194,6 +194,10 @@ services: calls: - [setContainer, ['@service_container']] Drupal\Core\Cache\CacheFactoryInterface: '@cache_factory' + variation_cache_factory: + class: Drupal\Core\Cache\VariationCacheFactory + arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager'] + Drupal\Core\Cache\VariationCacheFactory: '@variation_cache_factory' cache_contexts_manager: class: Drupal\Core\Cache\Context\CacheContextsManager arguments: ['@service_container', '%cache_contexts%' ] @@ -1780,11 +1784,11 @@ services: # https://www.drupal.org/node/2367555 lands. render_placeholder_generator: class: Drupal\Core\Render\PlaceholderGenerator - arguments: ['%renderer.config%'] + arguments: ['@cache_contexts_manager', '%renderer.config%'] Drupal\Core\Render\PlaceholderGeneratorInterface: '@render_placeholder_generator' render_cache: class: Drupal\Core\Render\PlaceholderingRenderCache - arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager', '@render_placeholder_generator'] + arguments: ['@request_stack', '@variation_cache_factory', '@cache_contexts_manager', '@render_placeholder_generator'] Drupal\Core\Render\RenderCacheInterface: '@render_cache' renderer: class: Drupal\Core\Render\Renderer diff --git a/core/lib/Drupal/Core/Cache/CacheRedirect.php b/core/lib/Drupal/Core/Cache/CacheRedirect.php new file mode 100644 index 000000000000..c39c61bfb899 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/CacheRedirect.php @@ -0,0 +1,31 @@ +<?php + +namespace Drupal\Core\Cache; + +/** + * Defines a value object to represent a cache redirect. + * + * @see \Drupal\Core\Cache\VariationCache::get() + * @see \Drupal\Core\Cache\VariationCache::set() + * + * @ingroup cache + * @internal + */ +class CacheRedirect implements CacheableDependencyInterface { + + use CacheableDependencyTrait; + + /** + * Constructs a CacheRedirect object. + * + * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability + * The cacheability to redirect to. + * + * @see \Drupal\Core\Cache\VariationCache::createCacheIdFast() + */ + public function __construct(CacheableDependencyInterface $cacheability) { + // Cache redirects only care about cache contexts. + $this->cacheContexts = $cacheability->getCacheContexts(); + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCache.php b/core/lib/Drupal/Core/Cache/VariationCache.php new file mode 100644 index 000000000000..bec2d3b910e0 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCache.php @@ -0,0 +1,250 @@ +<?php + +namespace Drupal\Core\Cache; + +use Drupal\Core\Cache\Context\CacheContextsManager; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Wraps a regular cache backend to make it support cache contexts. + * + * @ingroup cache + */ +class VariationCache implements VariationCacheInterface { + + /** + * Constructs a new VariationCache object. + * + * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack + * The request stack. + * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend + * The cache backend to wrap. + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cacheContextsManager + * The cache contexts manager. + */ + public function __construct( + protected RequestStack $requestStack, + protected CacheBackendInterface $cacheBackend, + protected CacheContextsManager $cacheContextsManager + ) {} + + /** + * {@inheritdoc} + */ + public function get(array $keys, CacheableDependencyInterface $initial_cacheability) { + $chain = $this->getRedirectChain($keys, $initial_cacheability); + return array_pop($chain); + } + + /** + * {@inheritdoc} + */ + public function set(array $keys, $data, CacheableDependencyInterface $cacheability, CacheableDependencyInterface $initial_cacheability): void { + $initial_contexts = $initial_cacheability->getCacheContexts(); + $contexts = $cacheability->getCacheContexts(); + + if ($missing_contexts = array_diff($initial_contexts, $contexts)) { + throw new \LogicException(sprintf('The complete set of cache contexts for a variation cache item must contain all of the initial cache contexts, missing: %s.', implode(', ', $missing_contexts))); + } + + // Don't store uncacheable items. + if ($cacheability->getCacheMaxAge() === 0) { + return; + } + + // Track the potential effect of cache context optimization on cache tags. + $optimized_cacheability = CacheableMetadata::createFromObject($cacheability); + $cid = $this->createCacheId($keys, $optimized_cacheability); + + // Check whether we had any cache redirects leading to the cache ID already. + // If there are none, we know that there is no proper redirect path to the + // cache ID we're trying to store the data at. This may be because there is + // either no full redirect path yet or there is one that is too specific at + // a given step of the way. In case of the former, we simply need to store a + // redirect. In case of the latter, we need to replace the overly specific + // step with a simpler one. + $chain = $this->getRedirectChain($keys, $initial_cacheability); + if (!array_key_exists($cid, $chain)) { + // We can easily find overly specific redirects by comparing their cache + // contexts to the ones we have here. If a redirect has more or different + // contexts, it needs to be replaced with a simplified version. + // + // Simplifying overly specific redirects can be done in two ways: + // + // ------- + // + // Problem: The redirect is a superset of the current cache contexts. + // Solution: We replace the redirect with the current contexts. + // + // Example: Suppose we try to store an object with context A, whereas we + // already have a redirect that uses A and B. In this case we simply store + // the object at the address designated by context A and next time someone + // tries to load the initial AB object, it will restore its redirect path + // by adding an AB redirect step after A. + // + // ------- + // + // Problem: The redirect overlaps, with both options having unique values. + // Solution: Find the common contexts and use those for a new redirect. + // + // Example: Suppose we try to store an object with contexts A and C, but + // we already have a redirect that uses A and B. In this case we find A to + // be the common cache context and replace the redirect with one only + // using A, immediately followed by one for AC so there is a full path to + // the data we're trying to set. Next time someone tries to load the + // initial AB object, it will restore its redirect path by adding an AB + // redirect step after A. + foreach ($chain as $chain_cid => $result) { + if ($result && $result->data instanceof CacheRedirect) { + $result_contexts = $result->data->getCacheContexts(); + if (array_diff($result_contexts, $contexts)) { + // Check whether we have an overlap scenario as we need to manually + // create an extra redirect in that case. + $common_contexts = array_intersect($result_contexts, $contexts); + // != is the most appropriate comparison operator here, since we + // only want to know if any keys or values don't match. + if ($common_contexts != $contexts) { + // Set the redirect to the common contexts at the current address. + // In the above example this is essentially overwriting the + // redirect to AB with a redirect to A. + $common_cacheability = (new CacheableMetadata())->setCacheContexts($common_contexts); + $this->cacheBackend->set($chain_cid, new CacheRedirect($common_cacheability)); + + // Before breaking the loop, set the current address to the next + // one in line so that we can store the full redirect as well. In + // the above example, this is the part where we immediately also + // store a redirect to AC at the CID that A pointed to. + $chain_cid = $this->createCacheIdFast($keys, $common_cacheability); + } + break; + } + } + } + + // The loop above either broke at an overly specific step or completed + // without any problem. In both cases, $chain_cid ended up with the value + // that we should store the new redirect at. + // + // Cache redirects are stored indefinitely and without tags as they never + // need to be cleared. If they ever end up leading to a stale cache item + // that now uses different contexts then said item will either follow an + // existing path of redirects or carve its own over the old one. + /** @phpstan-ignore-next-line */ + $this->cacheBackend->set($chain_cid, new CacheRedirect($cacheability)); + } + + $this->cacheBackend->set($cid, $data, $this->maxAgeToExpire($cacheability->getCacheMaxAge()), $optimized_cacheability->getCacheTags()); + } + + /** + * {@inheritdoc} + */ + public function delete(array $keys, CacheableDependencyInterface $initial_cacheability): void { + $chain = $this->getRedirectChain($keys, $initial_cacheability); + end($chain); + $this->cacheBackend->delete(key($chain)); + } + + /** + * {@inheritdoc} + */ + public function invalidate(array $keys, CacheableDependencyInterface $initial_cacheability): void { + $chain = $this->getRedirectChain($keys, $initial_cacheability); + end($chain); + $this->cacheBackend->invalidate(key($chain)); + } + + /** + * Performs a full get, returning every step of the way. + * + * This will check whether there is a cache redirect and follow it if so. It + * will keep following redirects until it gets to a cache miss or the actual + * cache object. + * + * @param string[] $keys + * The cache keys to retrieve the cache entry for. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The cache metadata of the data to store before other systems had a chance + * to adjust it. This is also commonly known as "pre-bubbling" cacheability. + * + * @return array + * Every cache get that lead to the final result, keyed by the cache ID used + * to query the cache for that result. + */ + protected function getRedirectChain(array $keys, CacheableDependencyInterface $initial_cacheability): array { + $cid = $this->createCacheIdFast($keys, $initial_cacheability); + $chain[$cid] = $result = $this->cacheBackend->get($cid); + + while ($result && $result->data instanceof CacheRedirect) { + $cid = $this->createCacheIdFast($keys, $result->data); + $chain[$cid] = $result = $this->cacheBackend->get($cid); + } + + return $chain; + } + + /** + * Maps a max-age value to an "expire" value for the Cache API. + * + * @param int $max_age + * A max-age value. + * + * @return int + * A corresponding "expire" value. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::set() + */ + protected function maxAgeToExpire($max_age) { + if ($max_age !== Cache::PERMANENT) { + return (int) $this->requestStack->getMainRequest()->server->get('REQUEST_TIME') + $max_age; + } + return $max_age; + } + + /** + * Creates a cache ID based on cache keys and cacheable metadata. + * + * If cache contexts are optimized during the creating of the cache ID, then + * the effect of said optimization on the cache contexts will be reflected in + * the provided cacheable metadata. + * + * @param string[] $keys + * The cache keys of the data to store. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheable_metadata + * The cacheable metadata of the data to store. + * + * @return string + * The cache ID. + */ + protected function createCacheId(array $keys, CacheableMetadata &$cacheable_metadata) { + if ($contexts = $cacheable_metadata->getCacheContexts()) { + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($contexts); + $keys = array_merge($keys, $context_cache_keys->getKeys()); + $cacheable_metadata = $cacheable_metadata->merge($context_cache_keys); + } + return implode(':', $keys); + } + + /** + * Creates a cache ID based on cache keys and cacheable metadata. + * + * This is a simpler, faster version of ::createCacheID() to be used when you + * do not care about how cache context optimization affects the cache tags. + * + * @param string[] $keys + * The cache keys of the data to store. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability + * The cache metadata of the data to store. + * + * @return string + * The cache ID for the redirect. + */ + protected function createCacheIdFast(array $keys, CacheableDependencyInterface $cacheability) { + if ($contexts = $cacheability->getCacheContexts()) { + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($contexts); + $keys = array_merge($keys, $context_cache_keys->getKeys()); + } + return implode(':', $keys); + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactory.php b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php new file mode 100644 index 000000000000..d870ed5972f1 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php @@ -0,0 +1,48 @@ +<?php + +namespace Drupal\Core\Cache; + +use Drupal\Core\Cache\Context\CacheContextsManager; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Defines the variation cache factory. + * + * @ingroup cache + */ +class VariationCacheFactory implements VariationCacheFactoryInterface { + + /** + * Instantiated variation cache bins. + * + * @var \Drupal\Core\Cache\VariationCacheInterface[] + */ + protected $bins = []; + + /** + * Constructs a new VariationCacheFactory object. + * + * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack + * The request stack. + * @param \Drupal\Core\Cache\CacheFactoryInterface $cacheFactory + * The cache factory. + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cacheContextsManager + * The cache contexts manager. + */ + public function __construct( + protected RequestStack $requestStack, + protected CacheFactoryInterface $cacheFactory, + protected CacheContextsManager $cacheContextsManager + ) {} + + /** + * {@inheritdoc} + */ + public function get($bin) { + if (!isset($this->bins[$bin])) { + $this->bins[$bin] = new VariationCache($this->requestStack, $this->cacheFactory->get($bin), $this->cacheContextsManager); + } + return $this->bins[$bin]; + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php new file mode 100644 index 000000000000..e9c948813db3 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php @@ -0,0 +1,21 @@ +<?php + +namespace Drupal\Core\Cache; + +/** + * An interface defining variation cache factory classes. + */ +interface VariationCacheFactoryInterface { + + /** + * Gets a variation cache backend for a given cache bin. + * + * @param string $bin + * The cache bin for which a variation cache backend should be returned. + * + * @return \Drupal\Core\Cache\VariationCacheInterface + * The variation cache backend associated with the specified bin. + */ + public function get($bin); + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCacheInterface.php b/core/lib/Drupal/Core/Cache/VariationCacheInterface.php new file mode 100644 index 000000000000..0c6f464dbd7b --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCacheInterface.php @@ -0,0 +1,85 @@ +<?php + +namespace Drupal\Core\Cache; + +/** + * Defines an interface for variation cache implementations. + * + * A variation cache wraps any provided cache backend and adds support for cache + * contexts to it. The actual caching still happens in the original cache + * backend. + * + * @ingroup cache + */ +interface VariationCacheInterface { + + /** + * Gets a cache entry based on cache keys. + * + * @param string[] $keys + * The cache keys to retrieve the cache entry for. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The cache metadata of the data to store before other systems had a chance + * to adjust it. This is also commonly known as "pre-bubbling" cacheability. + * + * @return object|false + * The cache item or FALSE on failure. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::get() + */ + public function get(array $keys, CacheableDependencyInterface $initial_cacheability); + + /** + * Stores data in the cache. + * + * @param string[] $keys + * The cache keys of the data to store. + * @param mixed $data + * The data to store in the cache. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability + * The cache metadata of the data to store. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The cache metadata of the data to store before other systems had a chance + * to adjust it. This is also commonly known as "pre-bubbling" cacheability. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::set() + * + * @throws \LogicException + * Thrown when cacheability is provided that does not contain a cache + * context or does not completely contain the initial cacheability. + */ + public function set(array $keys, $data, CacheableDependencyInterface $cacheability, CacheableDependencyInterface $initial_cacheability): void; + + /** + * Deletes an item from the cache. + * + * To stay consistent with ::get(), this only affects the active variation, + * not all possible variations for the associated cache contexts. + * + * @param string[] $keys + * The cache keys of the data to delete. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The cache metadata of the data to store before other systems had a chance + * to adjust it. This is also commonly known as "pre-bubbling" cacheability. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::delete() + */ + public function delete(array $keys, CacheableDependencyInterface $initial_cacheability): void; + + /** + * Marks a cache item as invalid. + * + * To stay consistent with ::get(), this only affects the active variation, + * not all possible variations for the associated cache contexts. + * + * @param string[] $keys + * The cache keys of the data to invalidate. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The cache metadata of the data to store before other systems had a chance + * to adjust it. This is also commonly known as "pre-bubbling" cacheability. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::invalidate() + */ + public function invalidate(array $keys, CacheableDependencyInterface $initial_cacheability): void; + +} diff --git a/core/lib/Drupal/Core/Render/PlaceholderGenerator.php b/core/lib/Drupal/Core/Render/PlaceholderGenerator.php index c00da6ca5321..f8906c449318 100644 --- a/core/lib/Drupal/Core/Render/PlaceholderGenerator.php +++ b/core/lib/Drupal/Core/Render/PlaceholderGenerator.php @@ -6,12 +6,21 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\Context\CacheContextsManager; /** * Turns a render array into a placeholder. */ class PlaceholderGenerator implements PlaceholderGeneratorInterface { + /** + * The cache contexts manager service. + * + * @var \Drupal\Core\Cache\Context\CacheContextsManager + */ + protected $cacheContextsManager; + /** * The renderer configuration array. * @@ -22,10 +31,13 @@ class PlaceholderGenerator implements PlaceholderGeneratorInterface { /** * Constructs a new Placeholder service. * + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager + * The cache contexts manager service. * @param array $renderer_config * The renderer configuration array. */ - public function __construct(array $renderer_config) { + public function __construct(CacheContextsManager $cache_contexts_manager, array $renderer_config) { + $this->cacheContextsManager = $cache_contexts_manager; $this->rendererConfig = $renderer_config; } @@ -48,15 +60,21 @@ public function shouldAutomaticallyPlaceholder(array $element) { // parameter. $conditions = $this->rendererConfig['auto_placeholder_conditions']; - if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] !== Cache::PERMANENT && $element['#cache']['max-age'] <= $conditions['max-age']) { + $cacheability = CacheableMetadata::createFromRenderArray($element); + if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) { return TRUE; } - if (isset($element['#cache']['contexts']) && array_intersect($element['#cache']['contexts'], $conditions['contexts'])) { + // Optimize the contexts and let them affect the cache tags to mimic what + // happens to the cacheability in the variation cache (RenderCache backend). + $cacheability->addCacheableDependency($this->cacheContextsManager->convertTokensToKeys($cacheability->getCacheContexts())); + $cacheability->setCacheContexts($this->cacheContextsManager->optimizeTokens($cacheability->getCacheContexts())); + + if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) { return TRUE; } - if (isset($element['#cache']['tags']) && array_intersect($element['#cache']['tags'], $conditions['tags'])) { + if (array_intersect($cacheability->getCacheTags(), $conditions['tags'])) { return TRUE; } diff --git a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php index 0422ecf5eb52..2f0ce18ee4a4 100644 --- a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php +++ b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php @@ -76,14 +76,18 @@ class PlaceholderingRenderCache extends RenderCache { * * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack * The request stack. - * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory - * The cache factory. + * @param \Drupal\Core\Cache\VariationCacheFactoryInterface $cache_factory + * The variation cache factory. * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager * The cache contexts manager. * @param \Drupal\Core\Render\PlaceholderGeneratorInterface $placeholder_generator * The placeholder generator. */ - public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager, PlaceholderGeneratorInterface $placeholder_generator) { + public function __construct(RequestStack $request_stack, $cache_factory, CacheContextsManager $cache_contexts_manager, PlaceholderGeneratorInterface $placeholder_generator) { + if ($cache_factory instanceof CacheFactoryInterface) { + @trigger_error('Injecting ' . __CLASS__ . ' with the "cache_factory" service is deprecated in drupal:10.1.0, use "variation_cache_factory" instead.', E_USER_DEPRECATED); + $cache_factory = \Drupal::service('variation_cache_factory'); + } parent::__construct($request_stack, $cache_factory, $cache_contexts_manager); $this->placeholderGenerator = $placeholder_generator; } diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php index 674c00e28f2a..90558b4a02d2 100644 --- a/core/lib/Drupal/Core/Render/RenderCache.php +++ b/core/lib/Drupal/Core/Render/RenderCache.php @@ -2,19 +2,15 @@ namespace Drupal\Core\Render; -use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableMetadata; -use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\Cache\CacheFactoryInterface; +use Drupal\Core\Cache\Context\CacheContextsManager; use Symfony\Component\HttpFoundation\RequestStack; /** * Wraps the caching logic for the render caching system. * * @internal - * - * @todo Refactor this out into a generic service capable of cache redirects, - * and let RenderCache use that. https://www.drupal.org/node/2551419 */ class RenderCache implements RenderCacheInterface { @@ -26,9 +22,9 @@ class RenderCache implements RenderCacheInterface { protected $requestStack; /** - * The cache factory. + * The variation cache factory. * - * @var \Drupal\Core\Cache\CacheFactoryInterface + * @var \Drupal\Core\Cache\VariationCacheFactoryInterface */ protected $cacheFactory; @@ -44,12 +40,16 @@ class RenderCache implements RenderCacheInterface { * * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack * The request stack. - * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory - * The cache factory. + * @param \Drupal\Core\Cache\VariationCacheFactoryInterface $cache_factory + * The variation cache factory. * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager * The cache contexts manager. */ - public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager) { + public function __construct(RequestStack $request_stack, $cache_factory, CacheContextsManager $cache_contexts_manager) { + if ($cache_factory instanceof CacheFactoryInterface) { + @trigger_error('Injecting ' . __CLASS__ . ' with the "cache_factory" service is deprecated in drupal:10.1.0, use "variation_cache_factory" instead.', E_USER_DEPRECATED); + $cache_factory = \Drupal::service('variation_cache_factory'); + } $this->requestStack = $request_stack; $this->cacheFactory = $cache_factory; $this->cacheContextsManager = $cache_contexts_manager; @@ -63,21 +63,13 @@ public function get(array $elements) { // and render caching of forms prevents this from happening. // @todo remove the isMethodCacheable() check when // https://www.drupal.org/node/2367555 lands. - if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) { + if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$this->isElementCacheable($elements)) { return FALSE; } - $bin = $elements['#cache']['bin'] ?? 'render'; - if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) { - $cached_element = $cache->data; - // Two-tier caching: redirect to actual (post-bubbling) cache item. - // @see \Drupal\Core\Render\RendererInterface::render() - // @see ::set() - if (isset($cached_element['#cache_redirect'])) { - return $this->get($cached_element); - } - // Return the cached element. - return $cached_element; + $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render'; + if (($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($elements['#cache']['keys'], CacheableMetadata::createFromRenderArray($elements))) { + return $cache->data; } return FALSE; } @@ -90,239 +82,19 @@ public function set(array &$elements, array $pre_bubbling_elements) { // and render caching of forms prevents this from happening. // @todo remove the isMethodCacheable() check when // https://www.drupal.org/node/2367555 lands. - if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) { + if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$this->isElementCacheable($elements)) { return FALSE; } - $data = $this->getCacheableRenderArray($elements); - $bin = $elements['#cache']['bin'] ?? 'render'; - $cache = $this->cacheFactory->get($bin); - - // Calculate the pre-bubbling CID. - $pre_bubbling_cid = $this->createCacheID($pre_bubbling_elements); - - // Two-tier caching: detect different CID post-bubbling, create redirect, - // update redirect if different set of cache contexts. - // @see \Drupal\Core\Render\RendererInterface::render() - // @see ::get() - if ($pre_bubbling_cid && $pre_bubbling_cid !== $cid) { - // The cache redirection strategy we're implementing here is pretty - // simple in concept. Suppose we have the following render structure: - // - A (pre-bubbling, specifies #cache['keys'] = ['foo']) - // -- B (specifies #cache['contexts'] = ['b']) - // - // At the time that we're evaluating whether A's rendering can be - // retrieved from cache, we won't know the contexts required by its - // children (the children might not even be built yet), so cacheGet() - // will only be able to get what is cached for a $cid of 'foo'. But at - // the time we're writing to that cache, we do know all the contexts that - // were specified by all children, so what we need is a way to - // persist that information between the cache write and the next cache - // read. So, what we can do is store the following into 'foo': - // @code - // [ - // '#cache_redirect' => TRUE, - // '#cache' => [ - // ... - // 'contexts' => ['b'], - // ], - // ] - // @endcode - // - // This efficiently lets cacheGet() redirect to a $cid that includes all - // of the required contexts. The strategy is on-demand: in the case where - // there aren't any additional contexts required by children that aren't - // already included in the parent's pre-bubbled #cache information, no - // cache redirection is needed. - // - // When implementing this redirection strategy, special care is needed to - // resolve potential cache ping-pong problems. For example, consider the - // following render structure: - // - A (pre-bubbling, specifies #cache['keys'] = ['foo']) - // -- B (pre-bubbling, specifies #cache['contexts'] = ['b']) - // --- C (pre-bubbling, specifies #cache['contexts'] = ['c']) - // --- D (pre-bubbling, specifies #cache['contexts'] = ['d']) - // - // Additionally, suppose that: - // - C only exists for a 'b' context value of 'b1' - // - D only exists for a 'b' context value of 'b2' - // This is an acceptable variation, since B specifies that its contents - // vary on context 'b'. - // - // A naive implementation of cache redirection would result in the - // following: - // - When a request is processed where context 'b' = 'b1', what would be - // cached for a $pre_bubbling_cid of 'foo' is: - // @code - // [ - // '#cache_redirect' => TRUE, - // '#cache' => [ - // ... - // 'contexts' => ['b', 'c'], - // ], - // ] - // @endcode - // - When a request is processed where context 'b' = 'b2', we would - // retrieve the above from cache, but when following that redirection, - // get a cache miss, since we're processing a 'b' context value that - // has not yet been cached. Given the cache miss, we would continue - // with rendering the structure, perform the required context bubbling - // and then overwrite the above item with: - // @code - // [ - // '#cache_redirect' => TRUE, - // '#cache' => [ - // ... - // 'contexts' => ['b', 'd'], - // ], - // ] - // @endcode - // - Now, if a request comes in where context 'b' = 'b1' again, the above - // would redirect to a cache key that doesn't exist, since we have not - // yet cached an item that includes 'b'='b1' and something for 'd'. So - // we would process this request as a cache miss, at the end of which, - // we would overwrite the above item back to: - // @code - // [ - // '#cache_redirect' => TRUE, - // '#cache' => [ - // ... - // 'contexts' => ['b', 'c'], - // ], - // ] - // @endcode - // - The above would always result in accurate renderings, but would - // result in poor performance as we keep processing requests as cache - // misses even though the target of the redirection is cached, and - // it's only the redirection element itself that is creating the - // ping-pong problem. - // - // A way to resolve the ping-pong problem is to eventually reach a cache - // state where the redirection element includes all of the contexts used - // throughout all requests: - // @code - // [ - // '#cache_redirect' => TRUE, - // '#cache' => [ - // ... - // 'contexts' => ['b', 'c', 'd'], - // ], - // ] - // @endcode - // - // We can't reach that state right away, since we don't know what the - // result of future requests will be, but we can incrementally move - // towards that state by progressively merging the 'contexts' value - // across requests. That's the strategy employed below and tested in - // \Drupal\Tests\Core\Render\RendererBubblingTest::testConditionalCacheContextBubblingSelfHealing(). - - // Get the cacheability of this element according to the current (stored) - // redirecting cache item, if any. - $redirect_cacheability = new CacheableMetadata(); - if ($stored_cache_redirect = $cache->get($pre_bubbling_cid)) { - $redirect_cacheability = CacheableMetadata::createFromRenderArray($stored_cache_redirect->data); - } - - // Calculate the union of the cacheability for this request and the - // current (stored) redirecting cache item. We need: - // - the union of cache contexts, because that is how we know which cache - // item to redirect to; - // - the union of cache tags, because that is how we know when the cache - // redirect cache item itself is invalidated; - // - the union of max ages, because that is how we know when the cache - // redirect cache item itself becomes stale. (Without this, we might end - // up toggling between a permanently and a briefly cacheable cache - // redirect, because the last update's max-age would always "win".) - $redirect_cacheability_updated = CacheableMetadata::createFromRenderArray($data)->merge($redirect_cacheability); - - // Stored cache contexts incomplete: this request causes cache contexts to - // be added to the redirecting cache item. - if (array_diff($redirect_cacheability_updated->getCacheContexts(), $redirect_cacheability->getCacheContexts())) { - $redirect_data = [ - '#cache_redirect' => TRUE, - '#cache' => [ - // The cache keys of the current element; this remains the same - // across requests. - 'keys' => $elements['#cache']['keys'], - // The union of the current element's and stored cache contexts. - 'contexts' => $redirect_cacheability_updated->getCacheContexts(), - // The union of the current element's and stored cache tags. - 'tags' => $redirect_cacheability_updated->getCacheTags(), - // The union of the current element's and stored cache max-ages. - 'max-age' => $redirect_cacheability_updated->getCacheMaxAge(), - // The same cache bin as the one for the actual render cache items. - 'bin' => $bin, - ], - ]; - $cache->set($pre_bubbling_cid, $redirect_data, $this->maxAgeToExpire($redirect_cacheability_updated->getCacheMaxAge()), Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered'])); - } - - // Current cache contexts incomplete: this request only uses a subset of - // the cache contexts stored in the redirecting cache item. Vary by these - // additional (conditional) cache contexts as well, otherwise the - // redirecting cache item would be pointing to a cache item that can never - // exist. - if (array_diff($redirect_cacheability_updated->getCacheContexts(), $data['#cache']['contexts'])) { - // Recalculate the cache ID. - $recalculated_cid_pseudo_element = [ - '#cache' => [ - 'keys' => $elements['#cache']['keys'], - 'contexts' => $redirect_cacheability_updated->getCacheContexts(), - ], - ]; - $cid = $this->createCacheID($recalculated_cid_pseudo_element); - // Ensure the about-to-be-cached data uses the merged cache contexts. - $data['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts(); - } - } - $cache->set($cid, $data, $this->maxAgeToExpire($elements['#cache']['max-age']), Cache::mergeTags($data['#cache']['tags'], ['rendered'])); - } - - /** - * Maps a #cache[max-age] value to an "expire" value for the Cache API. - * - * @param int $max_age - * A #cache[max-age] value. - * - * @return int - * A corresponding "expire" value. - * - * @see \Drupal\Core\Cache\CacheBackendInterface::set() - */ - protected function maxAgeToExpire($max_age) { - return ($max_age === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMainRequest()->server->get('REQUEST_TIME') + $max_age; - } - - /** - * Creates the cache ID for a renderable element. - * - * Creates the cache ID string based on #cache['keys'] + #cache['contexts']. - * - * @param array &$elements - * A renderable array. - * - * @return string - * The cache ID string, or FALSE if the element may not be cached. - */ - protected function createCacheID(array &$elements) { - // If the maximum age is zero, then caching is effectively prohibited. - if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) { - return FALSE; - } - - if (isset($elements['#cache']['keys'])) { - $cid_parts = $elements['#cache']['keys']; - if (!empty($elements['#cache']['contexts'])) { - $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($elements['#cache']['contexts']); - $cid_parts = array_merge($cid_parts, $context_cache_keys->getKeys()); - CacheableMetadata::createFromRenderArray($elements) - ->merge($context_cache_keys) - ->applyTo($elements); - } - return implode(':', $cid_parts); - } - return FALSE; + $cache_bin = $this->cacheFactory->get($bin); + $data = $this->getCacheableRenderArray($elements); + $cache_bin->set( + $elements['#cache']['keys'], + $data, + CacheableMetadata::createFromRenderArray($data)->addCacheTags(['rendered']), + CacheableMetadata::createFromRenderArray($pre_bubbling_elements) + ); } /** @@ -365,4 +137,25 @@ public function getCacheableRenderArray(array $elements) { return $data; } + /** + * Checks whether a renderable array can be cached. + * + * This allows us to not even have to instantiate the cache backend if a + * renderable array does not have any cache keys or specifies a zero cache + * max age. + * + * @param array $element + * A renderable array. + * + * @return bool + * Whether the renderable array is cacheable. + */ + protected function isElementCacheable(array $element) { + // If the maximum age is zero, then caching is effectively prohibited. + if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] === 0) { + return FALSE; + } + return isset($element['#cache']['keys']); + } + } diff --git a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php index 1504077dfd34..6e401e24807f 100644 --- a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php +++ b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php @@ -4,6 +4,7 @@ use Drupal\Component\Utility\Html; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Language\LanguageInterface; use Drupal\KernelTests\KernelTestBase; use Drupal\block\Entity\Block; @@ -147,6 +148,10 @@ public function testBlockViewBuilderCache() { * @see ::testBlockViewBuilderCache() */ protected function verifyRenderCacheHandling() { + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */ + $variation_cache_factory = $this->container->get('variation_cache_factory'); + $cache_bin = $variation_cache_factory->get('render'); + // Force a request via GET so we can test the render cache. $request = \Drupal::request(); $request_method = $request->server->get('REQUEST_METHOD'); @@ -154,13 +159,13 @@ protected function verifyRenderCacheHandling() { // Test that a cache entry is created. $build = $this->getBlockRenderArray(); - $cid = 'entity_view:block:test_block:' . implode(':', \Drupal::service('cache_contexts_manager')->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions'])->getKeys()); + $cache_keys = ['entity_view', 'block', 'test_block']; $this->renderer->renderRoot($build); - $this->assertNotEmpty($this->container->get('cache.render')->get($cid), 'The block render element has been cached.'); + $this->assertNotEmpty($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render element has been cached.'); // Re-save the block and check that the cache entry has been deleted. $this->block->save(); - $this->assertFalse($this->container->get('cache.render')->get($cid), 'The block render cache entry has been cleared when the block was saved.'); + $this->assertFalse($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render cache entry has been cleared when the block was saved.'); // Rebuild the render array (creating a new cache entry in the process) and // delete the block to check the cache entry is deleted. @@ -170,9 +175,9 @@ protected function verifyRenderCacheHandling() { $build['#block'] = $this->block; $this->renderer->renderRoot($build); - $this->assertNotEmpty($this->container->get('cache.render')->get($cid), 'The block render element has been cached.'); + $this->assertNotEmpty($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render element has been cached.'); $this->block->delete(); - $this->assertFalse($this->container->get('cache.render')->get($cid), 'The block render cache entry has been cleared when the block was deleted.'); + $this->assertFalse($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render cache entry has been cleared when the block was deleted.'); // Restore the previous request method. $request->setMethod($request_method); @@ -292,6 +297,10 @@ public function testBlockViewBuilderBuildAlter() { * @internal */ protected function assertBlockRenderedWithExpectedCacheability(array $expected_keys, array $expected_contexts, array $expected_tags, int $expected_max_age): void { + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */ + $variation_cache_factory = $this->container->get('variation_cache_factory'); + $cache_bin = $variation_cache_factory->get('render'); + $required_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions']; // Check that the expected cacheability metadata is present in: @@ -306,15 +315,14 @@ protected function assertBlockRenderedWithExpectedCacheability(array $expected_k $this->renderer->renderRoot($build); // - the render cache item. $final_cache_contexts = Cache::mergeContexts($expected_contexts, $required_cache_contexts); - $cid = implode(':', $expected_keys) . ':' . implode(':', \Drupal::service('cache_contexts_manager')->convertTokensToKeys($final_cache_contexts)->getKeys()); - $cache_item = $this->container->get('cache.render')->get($cid); - $this->assertNotEmpty($cache_item, 'The block render element has been cached with the expected cache ID.'); + $cache_item = $cache_bin->get($expected_keys, CacheableMetadata::createFromRenderArray($build)); + $this->assertNotEmpty($cache_item, 'The block render element has been cached with the expected cache keys.'); $this->assertEqualsCanonicalizing(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags); $this->assertEqualsCanonicalizing($final_cache_contexts, $cache_item->data['#cache']['contexts']); $this->assertEqualsCanonicalizing($expected_tags, $cache_item->data['#cache']['tags']); $this->assertSame($expected_max_age, $cache_item->data['#cache']['max-age']); - $this->container->get('cache.render')->delete($cid); + $cache_bin->delete($expected_keys, CacheableMetadata::createFromRenderArray($build)); } /** diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php index fac55fc71075..aa51c9581412 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php @@ -5,8 +5,8 @@ use Drupal\block_content\Entity\BlockContent; use Drupal\block_content\Entity\BlockContentType; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Language\LanguageInterface; use Drupal\Tests\system\Functional\Entity\EntityCacheTagsTestBase; use Symfony\Component\HttpFoundation\Request; @@ -91,20 +91,16 @@ public function testBlock() { // Expected keys, contexts, and tags for the block. // @see \Drupal\block\BlockViewBuilder::viewMultiple() $expected_block_cache_keys = ['entity_view', 'block', $block->id()]; - $expected_block_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions']; $expected_block_cache_tags = Cache::mergeTags(['block_view', 'rendered'], $block->getCacheTags()); $expected_block_cache_tags = Cache::mergeTags($expected_block_cache_tags, $block->getPlugin()->getCacheTags()); // Expected contexts and tags for the BlockContent entity. // @see \Drupal\Core\Entity\EntityViewBuilder::getBuildDefaults(). - $expected_entity_cache_contexts = ['theme']; $expected_entity_cache_tags = Cache::mergeTags(['block_content_view'], $this->entity->getCacheTags()); $expected_entity_cache_tags = Cache::mergeTags($expected_entity_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity)); // Verify that what was render cached matches the above expectations. - $cid = $this->createCacheId($expected_block_cache_keys, $expected_block_cache_contexts); - $redirected_cid = $this->createCacheId($expected_block_cache_keys, Cache::mergeContexts($expected_block_cache_contexts, $expected_entity_cache_contexts)); - $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), ($cid !== $redirected_cid) ? $redirected_cid : NULL); + $this->verifyRenderCache($expected_block_cache_keys, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), CacheableMetadata::createFromRenderArray($build)); } } diff --git a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml index 2d1aea97c6c1..6f2b53d2c24b 100644 --- a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml +++ b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml @@ -5,9 +5,13 @@ services: - { name: cache.bin } factory: ['@cache_factory', 'get'] arguments: [dynamic_page_cache] + variation_cache.dynamic_page_cache: + class: Drupal\Core\Cache\VariationCacheInterface + factory: variation_cache_factory:get + arguments: [dynamic_page_cache] dynamic_page_cache_subscriber: class: Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber - arguments: ['@dynamic_page_cache_request_policy', '@dynamic_page_cache_response_policy', '@render_cache', '%renderer.config%'] + arguments: ['@dynamic_page_cache_request_policy', '@dynamic_page_cache_response_policy', '@variation_cache.dynamic_page_cache', '@cache_contexts_manager', '%renderer.config%'] tags: - { name: event_subscriber } diff --git a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php index 2f75ccfaff1b..4d6a9d81a2a5 100644 --- a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php +++ b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php @@ -5,9 +5,10 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheableResponseInterface; +use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Cache\VariationCacheInterface; use Drupal\Core\PageCache\RequestPolicyInterface; use Drupal\Core\PageCache\ResponsePolicyInterface; -use Drupal\Core\Render\RenderCacheInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -54,40 +55,41 @@ class DynamicPageCacheSubscriber implements EventSubscriberInterface { protected $responsePolicy; /** - * The render cache. + * The variation cache. * - * @var \Drupal\Core\Render\RenderCacheInterface + * @var \Drupal\Core\Cache\VariationCacheInterface */ - protected $renderCache; + protected $cache; /** - * The renderer configuration array. + * The default cache contexts to vary every cache item by. * - * @var array + * @var string[] */ - protected $rendererConfig; + protected $cacheContexts = [ + 'route', + // Some routes' controllers rely on the request format (they don't have + // a separate route for each request format). Additionally, a controller + // may be returning a domain object that a KernelEvents::VIEW subscriber + // must turn into an actual response, but perhaps a format is being + // requested that the subscriber does not support. + // @see \Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber::onResponse() + 'request_format', + ]; + + /** + * The cache contexts manager service. + * + * @var \Drupal\Core\Cache\Context\CacheContextsManager + */ + protected $cacheContextsManager; /** - * Dynamic Page Cache's redirect render array. + * The renderer configuration array. * * @var array */ - protected $dynamicPageCacheRedirectRenderArray = [ - '#cache' => [ - 'keys' => ['response'], - 'contexts' => [ - 'route', - // Some routes' controllers rely on the request format (they don't have - // a separate route for each request format). Additionally, a controller - // may be returning a domain object that a KernelEvents::VIEW subscriber - // must turn into an actual response, but perhaps a format is being - // requested that the subscriber does not support. - // @see \Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber::onResponse() - 'request_format', - ], - 'bin' => 'dynamic_page_cache', - ], - ]; + protected $rendererConfig; /** * Internal cache of request policy results. @@ -103,15 +105,18 @@ class DynamicPageCacheSubscriber implements EventSubscriberInterface { * A policy rule determining the cacheability of a request. * @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy * A policy rule determining the cacheability of the response. - * @param \Drupal\Core\Render\RenderCacheInterface $render_cache - * The render cache. + * @param \Drupal\Core\Cache\VariationCacheInterface $cache + * The variation cache. + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager + * The cache contexts manager service. * @param array $renderer_config * The renderer configuration array. */ - public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, RenderCacheInterface $render_cache, array $renderer_config) { + public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, VariationCacheInterface $cache, CacheContextsManager $cache_contexts_manager, array $renderer_config) { $this->requestPolicy = $request_policy; $this->responsePolicy = $response_policy; - $this->renderCache = $render_cache; + $this->cache = $cache; + $this->cacheContextsManager = $cache_contexts_manager; $this->rendererConfig = $renderer_config; $this->requestPolicyResults = new \SplObjectStorage(); } @@ -134,9 +139,9 @@ public function onRequest(RequestEvent $event) { } // Sets the response for the current route, if cached. - $cached = $this->renderCache->get($this->dynamicPageCacheRedirectRenderArray); + $cached = $this->cache->get(['response'], (new CacheableMetadata())->setCacheContexts($this->cacheContexts)); if ($cached) { - $response = $this->renderArrayToResponse($cached); + $response = $cached->data; $response->headers->set(self::HEADER, 'HIT'); $event->setResponse($response); } @@ -194,10 +199,13 @@ public function onResponse(ResponseEvent $event) { return; } - // Embed the response object in a render array so that RenderCache is able - // to cache it, handling cache redirection for us. - $response_as_render_array = $this->responseToRenderArray($response); - $this->renderCache->set($response_as_render_array, $this->dynamicPageCacheRedirectRenderArray); + $cacheable_metadata = CacheableMetadata::createFromObject($response->getCacheableMetadata()); + $this->cache->set( + ['response'], + $response, + $cacheable_metadata->addCacheContexts($this->cacheContexts), + (new CacheableMetadata())->setCacheContexts($this->cacheContexts) + ); // The response was generated, mark the response as a cache miss. The next // time, it will be a cache hit. @@ -229,13 +237,19 @@ public function onResponse(ResponseEvent $event) { protected function shouldCacheResponse(CacheableResponseInterface $response) { $conditions = $this->rendererConfig['auto_placeholder_conditions']; - $cacheability = $response->getCacheableMetadata(); + // Create a new CacheableMetadata to avoid changing the response itself. + $cacheability = CacheableMetadata::createFromObject($response->getCacheableMetadata()); // Response's max-age is at or below the configured threshold. if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) { return FALSE; } + // Optimize the contexts and let them affect the cache tags to mimic what + // happens to the cacheability in the variation cache. + $cacheability->addCacheableDependency($this->cacheContextsManager->convertTokensToKeys($cacheability->getCacheContexts())); + $cacheability->setCacheContexts($this->cacheContextsManager->optimizeTokens($cacheability->getCacheContexts())); + // Response has a high-cardinality cache context. if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) { return FALSE; @@ -249,58 +263,6 @@ protected function shouldCacheResponse(CacheableResponseInterface $response) { return TRUE; } - /** - * Embeds a Response object in a render array so that RenderCache can cache it. - * - * @param \Drupal\Core\Cache\CacheableResponseInterface $response - * A cacheable response. - * - * @return array - * A render array that embeds the given cacheable response object, with the - * cacheability metadata of the response object present in the #cache - * property of the render array. - * - * @see renderArrayToResponse() - * - * @todo Refactor/remove once https://www.drupal.org/node/2551419 lands. - */ - protected function responseToRenderArray(CacheableResponseInterface $response) { - $response_as_render_array = $this->dynamicPageCacheRedirectRenderArray + [ - // The data we actually care about. - '#response' => $response, - // Tell RenderCache to cache the #response property: the data we actually - // care about. - '#cache_properties' => ['#response'], - // These exist only to fulfill the requirements of the RenderCache, which - // is designed to work with render arrays only. We don't care about these. - '#markup' => '', - '#attached' => '', - ]; - - // Merge the response's cacheability metadata, so that RenderCache can take - // care of cache redirects for us. - CacheableMetadata::createFromObject($response->getCacheableMetadata()) - ->merge(CacheableMetadata::createFromRenderArray($response_as_render_array)) - ->applyTo($response_as_render_array); - - return $response_as_render_array; - } - - /** - * Gets the embedded Response object in a render array. - * - * @param array $render_array - * A render array with a #response property. - * - * @return \Drupal\Core\Cache\CacheableResponseInterface - * The cacheable response object. - * - * @see responseToRenderArray() - */ - protected function renderArrayToResponse(array $render_array) { - return $render_array['#response']; - } - /** * {@inheritdoc} */ diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php index b4b83c7ebc61..2818fe4f4e55 100644 --- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @@ -10,6 +10,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\CacheableResponseInterface; +use Drupal\Core\Cache\CacheRedirect; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityNullStorage; @@ -985,16 +986,12 @@ public function testGetIndividual() { ->condition('cid', '%[route]=jsonapi.%', 'LIKE') ->execute() ->fetchAll(); - $this->assertLessThanOrEqual(4, count($cache_items)); + $this->assertLessThanOrEqual(5, count($cache_items)); $found_cached_200_response = FALSE; $other_cached_responses_are_4xx = TRUE; foreach ($cache_items as $cache_item) { - $cached_data = unserialize($cache_item->data); - - // We might be finding cache redirects when querying like this, so ensure - // we only inspect the actual cached response to see if it got flattened. - if (!isset($cached_data['#cache_redirect'])) { - $cached_response = $cached_data['#response']; + $cached_response = unserialize($cache_item->data); + if (!$cached_response instanceof CacheRedirect) { if ($cached_response->getStatusCode() === 200) { $found_cached_200_response = TRUE; } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 5816b340e156..3ad10537b5ac 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -8,6 +8,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\CacheRedirect; use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\ContentEntityNullStorage; use Drupal\Core\Entity\EntityInterface; @@ -535,9 +536,8 @@ public function testGet() { $found_cached_200_response = FALSE; $other_cached_responses_are_4xx = TRUE; foreach ($cache_items as $cache_item) { - $cached_data = unserialize($cache_item->data); - if (!isset($cached_data['#cache_redirect'])) { - $cached_response = $cached_data['#response']; + $cached_response = unserialize($cache_item->data); + if (!$cached_response instanceof CacheRedirect) { if ($cached_response->getStatusCode() === 200) { $found_cached_200_response = TRUE; } diff --git a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php index bcafe2c4b23d..a6626677ae6d 100644 --- a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php +++ b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\shortcut\Functional; -use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Url; use Drupal\shortcut\Entity\Shortcut; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; @@ -67,18 +67,23 @@ protected function createEntity() { * Tests that when creating a shortcut, the shortcut set tag is invalidated. */ public function testEntityCreation() { + $cache_bin = $this->getRenderCacheBackend(); + // Create a cache entry that is tagged with a shortcut set cache tag. $cache_tags = ['config:shortcut.set.default']; - \Drupal::cache('render')->set('foo', 'bar', CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + + $cacheability = new CacheableMetadata(); + $cacheability->addCacheTags($cache_tags); + $cache_bin->set(['foo'], 'bar', $cacheability, $cacheability); // Verify a cache hit. - $this->verifyRenderCache('foo', $cache_tags); + $this->verifyRenderCache(['foo'], $cache_tags, $cacheability); // Now create a shortcut entity in that shortcut set. $this->createEntity(); // Verify a cache miss. - $this->assertFalse(\Drupal::cache('render')->get('foo'), 'Creating a new shortcut invalidates the cache tag of the shortcut set.'); + $this->assertFalse($cache_bin->get(['foo'], $cacheability), 'Creating a new shortcut invalidates the cache tag of the shortcut set.'); } /** @@ -102,9 +107,7 @@ public function testToolbar() { 'config:shortcut.set.default', 'config:system.menu.admin', 'config:system.theme', - 'config:user.role.authenticated', 'rendered', - 'user:' . $this->rootUser->id(), ]; $this->assertCacheTags($expected_cache_tags); diff --git a/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php b/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php index 73fd3daf0778..3d4a670893a5 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php @@ -3,6 +3,8 @@ namespace Drupal\Tests\system\Functional\Entity; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\EventSubscriber\MainContentViewSubscriber; use Drupal\Core\Field\FieldStorageDefinitionInterface; @@ -372,26 +374,22 @@ public function testReferencedEntity() { // Also verify the existence of an entity render cache entry. $cache_keys = ['entity_view', 'entity_test', $this->referencingEntity->id(), 'full']; - $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); $access_cache_contexts = $this->getAccessCacheContextsForEntity($this->entity); $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencingEntity); - $redirected_cid = NULL; if (count($access_cache_contexts) || count($additional_cache_contexts)) { $cache_contexts = Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts); $cache_contexts = Cache::mergeContexts($cache_contexts, $access_cache_contexts); - $redirected_cid = $this->createCacheId($cache_keys, $cache_contexts); $context_metadata = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($cache_contexts); $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $context_metadata->getCacheTags()); } - $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid); + $this->verifyRenderCache($cache_keys, $referencing_entity_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts)); $this->verifyPageCache($non_referencing_entity_url, 'MISS'); // Verify a cache hit, but also the presence of the correct cache tags. $this->verifyPageCache($non_referencing_entity_url, 'HIT', Cache::mergeTags($non_referencing_entity_cache_tags, $page_cache_tags)); // Also verify the existence of an entity render cache entry. $cache_keys = ['entity_view', 'entity_test', $this->nonReferencingEntity->id(), 'full']; - $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $this->verifyRenderCache($cid, $non_referencing_entity_cache_tags); + $this->verifyRenderCache($cache_keys, $non_referencing_entity_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts)); // Prime the page cache for the listing of referencing entities. $this->verifyPageCache($listing_url, 'MISS'); @@ -620,8 +618,14 @@ public function testReferencedEntity() { * * @return string * The cache ID string. + * + * @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3354596 */ protected function createCacheId(array $keys, array $contexts) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See: https://www.drupal.org/project/drupal/issues/2551419.', E_USER_DEPRECATED); $cid_parts = $keys; $contexts = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($contexts); @@ -633,38 +637,36 @@ protected function createCacheId(array $keys, array $contexts) { /** * Verify that a given render cache entry exists, with the correct cache tags. * - * @param string $cid - * The render cache item ID. + * @param string[] $keys + * The render cache item keys. * @param array $tags * An array of expected cache tags. - * @param string|null $redirected_cid - * (optional) The redirected render cache item ID. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability + * The initial cacheability the item was rendered with. */ - protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) { + protected function verifyRenderCache(array $keys, array $tags, CacheableDependencyInterface $cacheability) { + $cache_bin = $this->getRenderCacheBackend(); + // Also verify the existence of an entity render cache entry. - $cache_entry = \Drupal::cache('render')->get($cid); + $cache_entry = $cache_bin->get($keys, $cacheability); $this->assertInstanceOf(\stdClass::class, $cache_entry); sort($cache_entry->tags); sort($tags); $this->assertSame($cache_entry->tags, $tags); - $is_redirecting_cache_item = isset($cache_entry->data['#cache_redirect']); - if ($redirected_cid === NULL) { - $this->assertFalse($is_redirecting_cache_item, 'Render cache entry is not a redirect.'); - } - else { - // Verify that $cid contains a cache redirect. - $this->assertTrue($is_redirecting_cache_item, 'Render cache entry is a redirect.'); - // Verify that the cache redirect points to the expected CID. - $redirect_cache_metadata = $cache_entry->data['#cache']; - $actual_redirection_cid = $this->createCacheId( - $redirect_cache_metadata['keys'], - $redirect_cache_metadata['contexts'] - ); - $this->assertSame($redirected_cid, $actual_redirection_cid); - // Finally, verify that the redirected CID exists and has the same cache - // tags. - $this->verifyRenderCache($redirected_cid, $tags); - } + } + + /** + * Retrieves the render cache backend as a variation cache. + * + * This is how Drupal\Core\Render\RenderCache uses the render cache backend. + * + * @return \Drupal\Core\Cache\VariationCacheInterface + * The render cache backend as a variation cache. + */ + protected function getRenderCacheBackend() { + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */ + $variation_cache_factory = \Drupal::service('variation_cache_factory'); + return $variation_cache_factory->get('render'); } } diff --git a/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php b/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php index 7b497a491ea5..4a04b7a764da 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\system\Functional\Entity; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Language\LanguageInterface; use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldConfig; @@ -43,16 +44,10 @@ public function testEntityUri() { // type supports render caching. if (\Drupal::entityTypeManager()->getDefinition($entity_type)->isRenderCacheable()) { $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode]; - $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); - $redirected_cid = NULL; - $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity); - if (count($additional_cache_contexts)) { - $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts)); - } $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag); $expected_cache_tags = Cache::mergeTags($expected_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity)); $expected_cache_tags = Cache::mergeTags($expected_cache_tags, [$render_cache_tag]); - $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid); + $this->verifyRenderCache($cache_keys, $expected_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts)); } // Verify that after modifying the entity, there is a cache miss. diff --git a/core/modules/tracker/tests/src/Functional/TrackerTest.php b/core/modules/tracker/tests/src/Functional/TrackerTest.php index 1cb6cd8a7913..8c2f82af4aed 100644 --- a/core/modules/tracker/tests/src/Functional/TrackerTest.php +++ b/core/modules/tracker/tests/src/Functional/TrackerTest.php @@ -101,12 +101,6 @@ public function testTrackerAll() { // Assert cache tags for the action/tabs blocks, visible node, and node list // cache tag. $expected_tags = Cache::mergeTags($published->getCacheTags(), $published->getOwner()->getCacheTags()); - // Because the 'user.permissions' cache context is being optimized away. - $role_tags = []; - foreach ($this->user->getRoles() as $rid) { - $role_tags[] = "config:user.role.$rid"; - } - $expected_tags = Cache::mergeTags($expected_tags, $role_tags); $block_tags = [ 'block_view', 'local_task', @@ -189,12 +183,6 @@ public function testTrackerUser() { $expected_tags = Cache::mergeTags($my_published->getCacheTags(), $my_published->getOwner()->getCacheTags()); $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getCacheTags()); $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getOwner()->getCacheTags()); - // Because the 'user.permissions' cache context is being optimized away. - $role_tags = []; - foreach ($this->user->getRoles() as $rid) { - $role_tags[] = "config:user.role.$rid"; - } - $expected_tags = Cache::mergeTags($expected_tags, $role_tags); $block_tags = [ 'block_view', 'local_task', diff --git a/core/modules/user/tests/src/Functional/UserBlocksTest.php b/core/modules/user/tests/src/Functional/UserBlocksTest.php index dfc14fb0b196..be0d9cb440e7 100644 --- a/core/modules/user/tests/src/Functional/UserBlocksTest.php +++ b/core/modules/user/tests/src/Functional/UserBlocksTest.php @@ -96,6 +96,11 @@ public function testUserLoginBlock() { // Log out again and repeat with a non-403 page including query arguments. $this->drupalLogout(); + // @todo This test should not check for cache hits. Because it does and the + // cache has some clever redirect logic internally, we need to request the + // page twice to see the cache HIT in the headers. + // @see https://www.drupal.org/project/drupal/issues/2551419 #154 + $this->drupalGet('filter/tips', ['query' => ['cat' => 'dog']]); $this->drupalGet('filter/tips', ['query' => ['foo' => 'bar']]); $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT'); $this->submitForm($edit, 'Log in'); diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php index 1298093ccc21..bb2df2285f59 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\workspaces\Functional; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait; use Drupal\workspaces\Entity\Workspace; @@ -33,6 +34,8 @@ class WorkspaceCacheContextTest extends BrowserTestBase { public function testWorkspaceCacheContext() { $renderer = \Drupal::service('renderer'); $cache_contexts_manager = \Drupal::service("cache_contexts_manager"); + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */ + $variation_cache_factory = $this->container->get('variation_cache_factory'); // Check that the 'workspace' cache context is present when the module is // installed. @@ -54,13 +57,12 @@ public function testWorkspaceCacheContext() { $renderer->renderRoot($build); $this->assertContains('workspace', $build['#cache']['contexts']); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys()); - $this->assertContains('[workspace]=live', $cid_parts); + $context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys(); + $this->assertContains('[workspace]=live', $context_tokens); // Test that a cache entry is created. - $cid = implode(':', $cid_parts); - $bin = $build['#cache']['bin']; - $this->assertInstanceOf(\stdClass::class, $this->container->get('cache.' . $bin)->get($cid)); + $cache_bin = $variation_cache_factory->get($build['#cache']['bin']); + $this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build))); // Switch to the 'stage' workspace and check that the correct workspace // cache context is used. @@ -80,13 +82,12 @@ public function testWorkspaceCacheContext() { $renderer->renderRoot($build); $this->assertContains('workspace', $build['#cache']['contexts']); - $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys()); - $this->assertContains('[workspace]=stage', $cid_parts); + $context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys(); + $this->assertContains('[workspace]=stage', $context_tokens); // Test that a cache entry is created. - $cid = implode(':', $cid_parts); - $bin = $build['#cache']['bin']; - $this->assertInstanceOf(\stdClass::class, $this->container->get('cache.' . $bin)->get($cid)); + $cache_bin = $variation_cache_factory->get($build['#cache']['bin']); + $this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build))); } } diff --git a/core/phpstan-baseline.neon b/core/phpstan-baseline.neon index 9ea58324f48c..e11a82854ed0 100644 --- a/core/phpstan-baseline.neon +++ b/core/phpstan-baseline.neon @@ -635,11 +635,6 @@ parameters: count: 1 path: lib/Drupal/Core/Render/MainContent/HtmlRenderer.php - - - message: "#^Variable \\$cid in empty\\(\\) always exists and is not falsy\\.$#" - count: 1 - path: lib/Drupal/Core/Render/RenderCache.php - - message: "#^Variable \\$context in isset\\(\\) always exists and is not nullable\\.$#" count: 1 diff --git a/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php new file mode 100644 index 000000000000..60f50db41554 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php @@ -0,0 +1,506 @@ +<?php + +namespace Drupal\Tests\Core\Cache; + +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\CacheRedirect; +use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Cache\Context\ContextCacheKeys; +use Drupal\Core\Cache\MemoryBackend; +use Drupal\Core\Cache\VariationCache; +use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @coversDefaultClass \Drupal\Core\Cache\VariationCache + * @group Cache + */ +class VariationCacheTest extends UnitTestCase { + + /** + * The prophesized request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack|\Prophecy\Prophecy\ProphecyInterface + */ + protected $requestStack; + + /** + * The backend used by the variation cache. + * + * @var \Drupal\Core\Cache\MemoryBackend + */ + protected $memoryBackend; + + /** + * The prophesized cache contexts manager. + * + * @var \Drupal\Core\Cache\Context\CacheContextsManager|\Prophecy\Prophecy\ProphecyInterface + */ + protected $cacheContextsManager; + + /** + * The variation cache instance. + * + * @var \Drupal\Core\Cache\VariationCacheInterface + */ + protected $variationCache; + + /** + * The cache keys this test will store things under. + * + * @var string[] + */ + protected $cacheKeys = ['your', 'housing', 'situation']; + + /** + * The cache ID for the cache keys, without taking contexts into account. + * + * @var string + */ + protected $cacheIdBase = 'your:housing:situation'; + + /** + * The simulated current user's housing type. + * + * For use in tests with cache contexts. + * + * @var string + */ + protected $housingType; + + /** + * The cacheability for something that only varies per housing type. + * + * @var \Drupal\Core\Cache\CacheableMetadata + */ + protected $housingTypeCacheability; + + /** + * The simulated current user's garden type. + * + * For use in tests with cache contexts. + * + * @var string + */ + protected $gardenType; + + /** + * The cacheability for something that varies per housing and garden type. + * + * @var \Drupal\Core\Cache\CacheableMetadata + */ + protected $gardenTypeCacheability; + + /** + * The simulated current user's house's orientation. + * + * For use in tests with cache contexts. + * + * @var string + */ + protected $houseOrientation; + + /** + * The cacheability for varying per housing, garden and orientation. + * + * @var \Drupal\Core\Cache\CacheableMetadata + */ + protected $houseOrientationCacheability; + + /** + * The simulated current user's solar panel type. + * + * For use in tests with cache contexts. + * + * @var string + */ + protected $solarType; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->requestStack = $this->prophesize(RequestStack::class); + $this->memoryBackend = new MemoryBackend(); + $this->cacheContextsManager = $this->prophesize(CacheContextsManager::class); + + $housing_type = &$this->housingType; + $garden_type = &$this->gardenType; + $house_orientation = &$this->houseOrientation; + $solar_type = &$this->solarType; + $this->cacheContextsManager->convertTokensToKeys(Argument::any()) + ->will(function ($args) use (&$housing_type, &$garden_type, &$house_orientation, &$solar_type) { + $keys = []; + foreach ($args[0] as $context_id) { + switch ($context_id) { + case 'house.type': + $keys[] = "ht.$housing_type"; + break; + + case 'garden.type': + $keys[] = "gt.$garden_type"; + break; + + case 'house.orientation': + $keys[] = "ho.$house_orientation"; + break; + + case 'solar.type': + $keys[] = "st.$solar_type"; + break; + + default: + $keys[] = $context_id; + } + } + return new ContextCacheKeys($keys); + }); + + $this->variationCache = new VariationCache( + $this->requestStack->reveal(), + $this->memoryBackend, + $this->cacheContextsManager->reveal() + ); + + $this->housingTypeCacheability = (new CacheableMetadata()) + ->setCacheTags(['foo']) + ->setCacheContexts(['house.type']); + $this->gardenTypeCacheability = (new CacheableMetadata()) + ->setCacheTags(['bar']) + ->setCacheContexts(['house.type', 'garden.type']); + $this->houseOrientationCacheability = (new CacheableMetadata()) + ->setCacheTags(['baz']) + ->setCacheContexts(['house.type', 'garden.type', 'house.orientation']); + } + + /** + * Tests a cache item that has no variations. + * + * @covers ::get + * @covers ::set + */ + public function testNoVariations() { + $data = 'You have a nice house!'; + $cacheability = (new CacheableMetadata())->setCacheTags(['bar', 'foo']); + $initial_cacheability = (new CacheableMetadata())->setCacheTags(['foo']); + $this->setVariationCacheItem($data, $cacheability, $initial_cacheability); + $this->assertVariationCacheItem($data, $cacheability, $initial_cacheability); + } + + /** + * Tests a cache item that only ever varies by one context. + * + * @covers ::get + * @covers ::set + */ + public function testSingleVariation() { + $cacheability = $this->housingTypeCacheability; + + $house_data = [ + 'apartment' => 'You have a nice apartment', + 'house' => 'You have a nice house', + ]; + + foreach ($house_data as $housing_type => $data) { + $this->housingType = $housing_type; + $this->assertVariationCacheMiss($cacheability); + $this->setVariationCacheItem($data, $cacheability, $cacheability); + $this->assertVariationCacheItem($data, $cacheability, $cacheability); + $this->assertCacheBackendItem("$this->cacheIdBase:ht.$housing_type", $data, $cacheability); + } + } + + /** + * Tests a cache item that has nested variations. + * + * @covers ::get + * @covers ::set + */ + public function testNestedVariations() { + // We are running this scenario in the best possible outcome: The redirects + // are stored in expanding order, meaning the simplest one is stored first + // and the nested ones are stored in subsequent ::set() calls. This means no + // self-healing takes place where overly specific redirects are overwritten + // with simpler ones. + $possible_outcomes = [ + 'apartment' => 'You have a nice apartment!', + 'house|no-garden' => 'You have a nice house!', + 'house|garden|east' => 'You have a nice house with an east-facing garden!', + 'house|garden|south' => 'You have a nice house with a south-facing garden!', + 'house|garden|west' => 'You have a nice house with a west-facing garden!', + 'house|garden|north' => 'You have a nice house with a north-facing garden!', + ]; + + foreach ($possible_outcomes as $cache_context_values => $data) { + [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||'); + + $cacheability = $this->housingTypeCacheability; + if (!empty($this->houseOrientation)) { + $cacheability = $this->houseOrientationCacheability; + } + elseif (!empty($this->gardenType)) { + $cacheability = $this->gardenTypeCacheability; + } + + $this->assertVariationCacheMiss($this->housingTypeCacheability); + $this->setVariationCacheItem($data, $cacheability, $this->housingTypeCacheability); + $this->assertVariationCacheItem($data, $cacheability, $this->housingTypeCacheability); + + $cache_id_parts = ["ht.$this->housingType"]; + if (!empty($this->gardenType)) { + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->gardenTypeCacheability)); + $cache_id_parts[] = "gt.$this->gardenType"; + } + if (!empty($this->houseOrientation)) { + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability)); + $cache_id_parts[] = "ho.$this->houseOrientation"; + } + + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), $data, $cacheability); + } + } + + /** + * Tests a cache item that has nested variations that trigger self-healing. + * + * @covers ::get + * @covers ::set + * + * @depends testNestedVariations + */ + public function testNestedVariationsSelfHealing() { + // This is the worst possible scenario: A very specific item was stored + // first, followed by a less specific one. This means an overly specific + // cache redirect was stored that needs to be dumbed down. After this + // process, the first ::get() for the more specific item will fail as we + // have effectively destroyed the path to said item. Setting an item of the + // same specificity will restore the path for all items of said specificity. + $cache_id_parts = ['ht.house']; + $possible_outcomes = [ + 'house|garden|east' => 'You have a nice house with an east-facing garden!', + 'house|garden|south' => 'You have a nice house with a south-facing garden!', + 'house|garden|west' => 'You have a nice house with a west-facing garden!', + 'house|garden|north' => 'You have a nice house with a north-facing garden!', + ]; + + foreach ($possible_outcomes as $cache_context_values => $data) { + [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||'); + $this->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability); + } + + // Verify that the overly specific redirect is stored at the first possible + // redirect location, i.e.: The base cache ID. + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability)); + + // Store a simpler variation and verify that the first cache redirect is now + // the one redirecting to the simplest known outcome. + [$this->housingType, $this->gardenType, $this->houseOrientation] = ['house', 'no-garden', NULL]; + $this->setVariationCacheItem('You have a nice house', $this->gardenTypeCacheability, $this->housingTypeCacheability); + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->gardenTypeCacheability)); + + // Verify that the previously set outcomes are all inaccessible now. + foreach ($possible_outcomes as $cache_context_values => $data) { + [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||'); + $this->assertVariationCacheMiss($this->housingTypeCacheability); + } + + // Set at least one more specific item in the cache again. + $this->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability); + + // Verify that the previously set outcomes are all accessible again. + foreach ($possible_outcomes as $cache_context_values => $data) { + [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||'); + $this->assertVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability); + } + + // Verify that the more specific cache redirect is now stored one step after + // the less specific one. + $cache_id_parts[] = 'gt.garden'; + $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability)); + } + + /** + * Tests self-healing for a cache item that has split variations. + * + * @covers ::get + * @covers ::set + */ + public function testSplitVariationsSelfHealing() { + // This is an edge case. Something varies by AB where some values of B + // trigger the whole to vary by either C, D or nothing extra. But due to an + // unfortunate series of requests, only ABC and ABD variations were cached. + // + // In this case, the cache should be smart enough to generate a redirect for + // AB, followed by redirects for ABC and ABD. + // + // For the sake of this test, we'll vary by housing and orientation, but: + // - Only vary by garden type for south-facing houses. + // - Only vary by solar panel type for north-facing houses. + $this->housingType = 'house'; + $this->gardenType = 'garden'; + $this->solarType = 'solar'; + + $initial_cacheability = (new CacheableMetadata()) + ->setCacheTags(['foo']) + ->setCacheContexts(['house.type']); + + $south_cacheability = (new CacheableMetadata()) + ->setCacheTags(['foo']) + ->setCacheContexts(['house.type', 'house.orientation', 'garden.type']); + + $north_cacheability = (new CacheableMetadata()) + ->setCacheTags(['foo']) + ->setCacheContexts(['house.type', 'house.orientation', 'solar.type']); + + $common_cacheability = (new CacheableMetadata()) + ->setCacheContexts(['house.type', 'house.orientation']); + + // Calculate the cache IDs once beforehand for readability. + $cache_id = $this->getSortedCacheId(['ht.house']); + $cache_id_north = $this->getSortedCacheId(['ht.house', 'ho.north']); + $cache_id_south = $this->getSortedCacheId(['ht.house', 'ho.south']); + + // Set the first scenario. + $this->houseOrientation = 'south'; + $this->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability); + + // Verify that the overly specific redirect is stored at the first possible + // redirect location, i.e.: The base cache ID. + $this->assertCacheBackendItem($cache_id, new CacheRedirect($south_cacheability)); + + // Store a split variation, and verify that the common contexts are now used + // for the first cache redirect and the actual contexts for the next step of + // the redirect chain. + $this->houseOrientation = 'north'; + $this->setVariationCacheItem('You have a north-facing house with solar panels!', $north_cacheability, $initial_cacheability); + $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability)); + $this->assertCacheBackendItem($cache_id_north, new CacheRedirect($north_cacheability)); + + // Verify that the initially set scenario is inaccessible now. + $this->houseOrientation = 'south'; + $this->assertVariationCacheMiss($initial_cacheability); + + // Reset the initial scenario and verify that its redirects are accessible. + $this->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability); + $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability)); + $this->assertCacheBackendItem($cache_id_south, new CacheRedirect($south_cacheability)); + + // Double-check that the split scenario redirects are left untouched. + $this->houseOrientation = 'north'; + $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability)); + $this->assertCacheBackendItem($cache_id_north, new CacheRedirect($north_cacheability)); + } + + /** + * Tests exception for a cache item that has incompatible variations. + * + * @covers ::get + * @covers ::set + */ + public function testIncompatibleVariationsException() { + // This should never happen. When someone first stores something in the + // cache using context A and then tries to store something using context B, + // something is wrong. There should always be at least one shared context at + // the top level or else the cache cannot do its job. + $this->expectException(\LogicException::class); + $this->expectExceptionMessage("The complete set of cache contexts for a variation cache item must contain all of the initial cache contexts, missing: garden.type."); + + $this->housingType = 'house'; + $house_cacheability = (new CacheableMetadata()) + ->setCacheContexts(['house.type']); + + $this->gardenType = 'garden'; + $garden_cacheability = (new CacheableMetadata()) + ->setCacheContexts(['garden.type']); + + $this->setVariationCacheItem('You have a nice garden!', $garden_cacheability, $garden_cacheability); + $this->setVariationCacheItem('You have a nice house!', $house_cacheability, $garden_cacheability); + } + + /** + * Creates the sorted cache ID from cache ID parts. + * + * When core optimizes cache contexts it returns the keys alphabetically. To + * make testing easier, we replicate said sorting here. + * + * @param string[] $cache_id_parts + * The parts to add to the base cache ID, will be sorted. + * + * @return string + * The correct cache ID. + */ + protected function getSortedCacheId($cache_id_parts) { + sort($cache_id_parts); + array_unshift($cache_id_parts, $this->cacheIdBase); + return implode(':', $cache_id_parts); + } + + /** + * Stores an item in the variation cache. + * + * @param mixed $data + * The data that should be stored. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * The cacheability that should be used. + * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability + * The initial cacheability that should be used. + */ + protected function setVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) { + $this->variationCache->set($this->cacheKeys, $data, $cacheability, $initial_cacheability); + } + + /** + * Asserts that an item was properly stored in the variation cache. + * + * @param mixed $data + * The data that should have been stored. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * The cacheability that should have been used. + * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability + * The initial cacheability that should be used. + */ + protected function assertVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) { + $cache_item = $this->variationCache->get($this->cacheKeys, $initial_cacheability); + $this->assertNotFalse($cache_item, 'Variable data was stored and retrieved successfully.'); + $this->assertEquals($data, $cache_item->data, 'Variable cache item contains the right data.'); + $this->assertSame($cacheability->getCacheTags(), $cache_item->tags, 'Variable cache item uses the right cache tags.'); + } + + /** + * Asserts that an item could not be retrieved from the variation cache. + * + * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability + * The initial cacheability that should be used. + */ + protected function assertVariationCacheMiss(CacheableMetadata $initial_cacheability) { + $this->assertFalse($this->variationCache->get($this->cacheKeys, $initial_cacheability), 'Nothing could be retrieved for the active cache contexts.'); + } + + /** + * Asserts that an item was properly stored in the cache backend. + * + * @param string $cid + * The cache ID that should have been used. + * @param mixed $data + * The data that should have been stored. + * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheability + * (optional) The cacheability that should have been used. Does not apply + * when checking for cache redirects. + */ + protected function assertCacheBackendItem(string $cid, $data, CacheableMetadata $cacheability = NULL) { + $cache_backend_item = $this->memoryBackend->get($cid); + $this->assertNotFalse($cache_backend_item, 'The data was stored and retrieved successfully.'); + $this->assertEquals($data, $cache_backend_item->data, 'Cache item contains the right data.'); + + if ($data instanceof CacheRedirect) { + $this->assertSame([], $cache_backend_item->tags, 'A cache redirect does not use cache tags.'); + $this->assertSame(-1, $cache_backend_item->expire, 'A cache redirect is stored indefinitely.'); + } + else { + $this->assertSame($cacheability->getCacheTags(), $cache_backend_item->tags, 'Cache item uses the right cache tags.'); + } + } + +} diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php index e9041d9deaa4..88517acc6e77 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php @@ -7,7 +7,9 @@ namespace Drupal\Tests\Core\Render; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\MemoryBackend; +use Drupal\Core\Cache\VariationCache; use Drupal\Core\KeyValueStore\KeyValueMemoryFactory; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\State\State; @@ -81,8 +83,8 @@ public function testContextBubblingCustomCacheBin() { $bin = $this->randomMachineName(); $this->setUpRequest(); - $this->memoryCache = new MemoryBackend(); - $custom_cache = new MemoryBackend(); + $this->memoryCache = new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager); + $custom_cache = new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager); $this->cacheFactory->expects($this->atLeastOnce()) ->method('get') @@ -115,15 +117,14 @@ public function testContextBubblingCustomCacheBin() { ]; $this->renderer->renderRoot($build); - $this->assertRenderCacheItem('parent:foo', [ - '#cache_redirect' => TRUE, + $this->assertRenderCacheItem(['parent'], [ + '#attached' => [], '#cache' => [ - 'keys' => ['parent'], - 'contexts' => ['foo', 'bar'], + 'contexts' => ['bar', 'foo'], 'tags' => [], - 'bin' => $bin, 'max-age' => 3600, ], + '#markup' => 'parent', ], $bin); } @@ -134,7 +135,7 @@ public function testContextBubblingCustomCacheBin() { * * @dataProvider providerTestContextBubblingEdgeCases */ - public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, array $expected_cache_items) { + public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, $expected_cache_item) { $this->setUpRequest(); $this->setupMemoryCache(); $this->cacheContextsManager->expects($this->any()) @@ -144,9 +145,7 @@ public function testContextBubblingEdgeCases(array $element, array $expected_top $this->renderer->renderRoot($element); $this->assertEqualsCanonicalizing($expected_top_level_contexts, $element['#cache']['contexts'], 'Expected cache contexts found.'); - foreach ($expected_cache_items as $cid => $expected_cache_item) { - $this->assertRenderCacheItem($cid, $expected_cache_item); - } + $this->assertRenderCacheItem($element['#cache']['keys'], $expected_cache_item); } public function providerTestContextBubblingEdgeCases() { @@ -167,18 +166,16 @@ public function providerTestContextBubblingEdgeCases() { ], ], ]; - $expected_cache_items = [ - 'parent' => [ - '#attached' => [], - '#cache' => [ - 'contexts' => [], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - ], - '#markup' => 'parent', + $expected_cache_item = [ + '#attached' => [], + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => Cache::PERMANENT, ], + '#markup' => 'parent', ]; - $data[] = [$test_element, [], $expected_cache_items]; + $data[] = [$test_element, [], $expected_cache_item]; // Assert cache contexts are sorted when they are used to generate a CID. // (Necessary to ensure that different render arrays where the same keys + @@ -190,16 +187,14 @@ public function providerTestContextBubblingEdgeCases() { 'contexts' => [], ], ]; - $expected_cache_items = [ - 'set_test:bar:baz:foo' => [ - '#attached' => [], - '#cache' => [ - 'contexts' => [], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - ], - '#markup' => '', + $expected_cache_item = [ + '#attached' => [], + '#cache' => [ + 'contexts' => [], + 'tags' => [], + 'max-age' => Cache::PERMANENT, ], + '#markup' => '', ]; $context_orders = [ ['foo', 'bar', 'baz'], @@ -211,8 +206,8 @@ public function providerTestContextBubblingEdgeCases() { ]; foreach ($context_orders as $context_order) { $test_element['#cache']['contexts'] = $context_order; - $expected_cache_items['set_test:bar:baz:foo']['#cache']['contexts'] = $context_order; - $data[] = [$test_element, $context_order, $expected_cache_items]; + $expected_cache_item['#cache']['contexts'] = $context_order; + $data[] = [$test_element, $context_order, $expected_cache_item]; } // A parent with a certain set of cache contexts is unaffected by a child @@ -230,18 +225,16 @@ public function providerTestContextBubblingEdgeCases() { ], ], ]; - $expected_cache_items = [ - 'parent:bar:baz:foo' => [ - '#attached' => [], - '#cache' => [ - 'contexts' => ['foo', 'bar', 'baz'], - 'tags' => [], - 'max-age' => 3600, - ], - '#markup' => 'parent', + $expected_cache_item = [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo', 'bar', 'baz'], + 'tags' => [], + 'max-age' => 3600, ], + '#markup' => 'parent', ]; - $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_items]; + $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_item]; // A parent with a certain set of cache contexts that is a subset of the // cache contexts of a child gets a redirecting cache item for the cache ID @@ -267,29 +260,16 @@ public function providerTestContextBubblingEdgeCases() { '#markup' => '', ], ]; - $expected_cache_items = [ - 'parent:foo' => [ - '#cache_redirect' => TRUE, - '#cache' => [ - // The keys + contexts this redirects to. - 'keys' => ['parent'], - 'contexts' => ['foo', 'bar'], - 'tags' => ['yar', 'har', 'fiddle', 'dee'], - 'bin' => 'render', - 'max-age' => Cache::PERMANENT, - ], - ], - 'parent:bar:foo' => [ - '#attached' => [], - '#cache' => [ - 'contexts' => ['foo', 'bar'], - 'tags' => ['yar', 'har', 'fiddle', 'dee'], - 'max-age' => Cache::PERMANENT, - ], - '#markup' => 'parent', + $expected_cache_item = [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo', 'bar'], + 'tags' => ['yar', 'har', 'fiddle', 'dee'], + 'max-age' => Cache::PERMANENT, ], + '#markup' => 'parent', ]; - $data[] = [$test_element, ['bar', 'foo'], $expected_cache_items]; + $data[] = [$test_element, ['bar', 'foo'], $expected_cache_item]; // Ensure that bubbleable metadata has been collected from children and set // correctly to the main level of the render array. That ensures that correct @@ -314,24 +294,25 @@ public function providerTestContextBubblingEdgeCases() { ], ], ]; - $expected_cache_items = [ - 'parent:foo' => [ - '#attached' => ['library' => ['foo/bar']], - '#cache' => [ - 'contexts' => ['foo'], - 'tags' => ['yar', 'har', 'fiddle', 'dee'], - 'max-age' => Cache::PERMANENT, - ], - '#markup' => 'parent', + $expected_cache_item = [ + '#attached' => ['library' => ['foo/bar']], + '#cache' => [ + 'contexts' => ['foo'], + 'tags' => ['yar', 'har', 'fiddle', 'dee'], + 'max-age' => Cache::PERMANENT, ], + '#markup' => 'parent', ]; - $data[] = [$test_element, ['foo'], $expected_cache_items]; + $data[] = [$test_element, ['foo'], $expected_cache_item]; return $data; } /** * Tests the self-healing of the redirect with conditional cache contexts. + * + * @todo Revisit now that we have self-healing tests for VariationCache. This + * is essentially a clone of the other bubbling tests now. */ public function testConditionalCacheContextBubblingSelfHealing() { $current_user_role = &$this->currentUserRole; @@ -382,17 +363,7 @@ public function testConditionalCacheContextBubblingSelfHealing() { $element = $test_element; $current_user_role = 'A'; $this->renderer->renderRoot($element); - $this->assertRenderCacheItem('parent', [ - '#cache_redirect' => TRUE, - '#cache' => [ - 'keys' => ['parent'], - 'contexts' => ['user.roles'], - 'tags' => ['a', 'b'], - 'bin' => 'render', - 'max-age' => Cache::PERMANENT, - ], - ]); - $this->assertRenderCacheItem('parent:r.A', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ 'contexts' => ['user.roles'], @@ -407,17 +378,7 @@ public function testConditionalCacheContextBubblingSelfHealing() { $element = $test_element; $current_user_role = 'B'; $this->renderer->renderRoot($element); - $this->assertRenderCacheItem('parent', [ - '#cache_redirect' => TRUE, - '#cache' => [ - 'keys' => ['parent'], - 'contexts' => ['user.roles', 'foo'], - 'tags' => ['a', 'b', 'c'], - 'bin' => 'render', - 'max-age' => 1800, - ], - ]); - $this->assertRenderCacheItem('parent:foo:r.B', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ 'contexts' => ['user.roles', 'foo'], @@ -427,60 +388,25 @@ public function testConditionalCacheContextBubblingSelfHealing() { '#markup' => 'parent', ]); - // Request 3: role A again, the grandchild is inaccessible again => bubbled - // cache contexts: user.roles; but that's a subset of the already-bubbled - // cache contexts, so nothing is actually changed in the redirecting cache - // item. However, the cache item we were looking for in request 1 is - // technically the same one we're looking for now (it's the exact same - // request), but with one additional cache context. This is necessary to - // avoid "cache ping-pong". (Requests 1 and 3 are identical, but without the - // right merging logic to handle request 2, the redirecting cache item would - // toggle between only the 'user.roles' cache context and both the 'foo' - // and 'user.roles' cache contexts, resulting in a cache miss every time.) - $element = $test_element; + // Verify that request 1 is still cached and accessible. $current_user_role = 'A'; - $this->renderer->renderRoot($element); - $this->assertRenderCacheItem('parent', [ - '#cache_redirect' => TRUE, - '#cache' => [ - 'keys' => ['parent'], - 'contexts' => ['user.roles', 'foo'], - 'tags' => ['a', 'b', 'c'], - 'bin' => 'render', - 'max-age' => 1800, - ], - ]); - $this->assertRenderCacheItem('parent:foo:r.A', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ - 'contexts' => ['user.roles', 'foo'], + 'contexts' => ['user.roles'], 'tags' => ['a', 'b'], - // Note that the max-age here is unaffected. When role A, the grandchild - // is never rendered, so neither is its max-age of 1800 present here, - // despite 1800 being the max-age of the redirecting cache item. 'max-age' => Cache::PERMANENT, ], '#markup' => 'parent', ]); - // Request 4: role C, both the grandchild and the grandgrandchild are + // Request 3: role C, both the grandchild and the grandgrandchild are // accessible => bubbled cache contexts: foo, bar, user.roles + merged // max-age: 300. $element = $test_element; $current_user_role = 'C'; $this->renderer->renderRoot($element); - $final_parent_cache_item = [ - '#cache_redirect' => TRUE, - '#cache' => [ - 'keys' => ['parent'], - 'contexts' => ['user.roles', 'foo', 'bar'], - 'tags' => ['a', 'b', 'c', 'd'], - 'bin' => 'render', - 'max-age' => 300, - ], - ]; - $this->assertRenderCacheItem('parent', $final_parent_cache_item); - $this->assertRenderCacheItem('parent:bar:foo:r.C', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ 'contexts' => ['user.roles', 'foo', 'bar'], @@ -490,39 +416,24 @@ public function testConditionalCacheContextBubblingSelfHealing() { '#markup' => 'parent', ]); - // Request 5: role A again, verifying the merging like we did for request 3. - $element = $test_element; + // Verify that request 2 and 3 are still cached and accessible. $current_user_role = 'A'; - $this->renderer->renderRoot($element); - $this->assertRenderCacheItem('parent', $final_parent_cache_item); - $this->assertRenderCacheItem('parent:bar:foo:r.A', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ - 'contexts' => ['user.roles', 'foo', 'bar'], + 'contexts' => ['user.roles'], 'tags' => ['a', 'b'], - // Note that the max-age here is unaffected. When role A, the grandchild - // is never rendered, so neither is its max-age of 1800 present here, - // nor the grandgrandchild's max-age of 300, despite 300 being the - // max-age of the redirecting cache item. 'max-age' => Cache::PERMANENT, ], '#markup' => 'parent', ]); - // Request 6: role B again, verifying the merging like we did for request 3. - $element = $test_element; $current_user_role = 'B'; - $this->renderer->renderRoot($element); - $this->assertRenderCacheItem('parent', $final_parent_cache_item); - $this->assertRenderCacheItem('parent:bar:foo:r.B', [ + $this->assertRenderCacheItem(['parent'], [ '#attached' => [], '#cache' => [ - 'contexts' => ['user.roles', 'foo', 'bar'], + 'contexts' => ['user.roles', 'foo'], 'tags' => ['a', 'b', 'c'], - // Note that the max-age here is unaffected. When role B, the - // grandgrandchild is never rendered, so neither is its max-age of 300 - // present here, despite 300 being the max-age of the redirecting cache - // item. 'max-age' => 1800, ], '#markup' => 'parent', @@ -559,7 +470,13 @@ public function testBubblingWithPrerender($test_element) { // - … is not cached DOES get called. \Drupal::state()->set('bubbling_nested_pre_render_cached', FALSE); \Drupal::state()->set('bubbling_nested_pre_render_uncached', FALSE); - $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []]]); + $cacheability = new CacheableMetadata(); + $this->memoryCache->set( + ['cached_nested'], + ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []]], + $cacheability, + $cacheability + ); // Simulate the rendering of an entire response (i.e. a root call). $output = $this->renderer->renderRoot($test_element); diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php index 79fa3f51447f..6b531e85f526 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php @@ -10,6 +10,7 @@ use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\Html; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Render\Markup; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Render\RenderContext; @@ -299,15 +300,11 @@ public function providerPlaceholders() { $element_with_cache_keys = $base_element_a3; $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys; $expected_placeholder_render_array['#cache']['keys'] = $keys; - // The CID parts here consist of the cache keys plus the 'user' cache - // context, which in this unit test is simply the given cache context token, - // see \Drupal\Tests\Core\Render\RendererTestBase::setUp(). - $cid_parts = array_merge($keys, ['user']); $cases[] = [ $element_with_cache_keys, $args, $expected_placeholder_render_array, - $cid_parts, + $keys, [], [], [ @@ -547,33 +544,16 @@ protected function generatePlaceholderElement() { } /** - * @param false|array $cid_parts - * The cid parts. - * @param string[] $bubbled_cache_contexts - * Additional cache contexts that were bubbled when the placeholder was - * rendered. + * @param false|array $cache_keys + * The cache keys. * @param array $expected_data * A render array with the expected values. * * @internal */ - protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache_contexts, array $expected_data): void { - if ($cid_parts !== FALSE) { - if ($bubbled_cache_contexts) { - // Verify render cached placeholder. - $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data; - $expected_redirect_element = [ - '#cache_redirect' => TRUE, - '#cache' => $expected_data['#cache'] + [ - 'keys' => $cid_parts, - 'bin' => 'render', - ], - ]; - $this->assertEquals($expected_redirect_element, $cached_element, 'The correct cache redirect exists.'); - } - - // Verify render cached placeholder. - $cached = $this->memoryCache->get(implode(':', array_merge($cid_parts, $bubbled_cache_contexts))); + protected function assertPlaceholderRenderCache($cache_keys, array $expected_data) { + if ($cache_keys !== FALSE) { + $cached = $this->memoryCache->get($cache_keys, CacheableMetadata::createFromRenderArray($expected_data)); $cached_element = $cached->data; $this->assertEquals($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.'); } @@ -585,8 +565,8 @@ protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache * * @dataProvider providerPlaceholders */ - public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) { - if ($placeholder_cid_parts) { + public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) { + if ($placeholder_cache_keys) { $this->setupMemoryCache(); } else { @@ -605,7 +585,7 @@ public function testUncacheableParent($element, $args, array $expected_placehold 'dynamic_animal' => $args[0], ]; $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.'); - $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array); + $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array); } /** @@ -613,11 +593,10 @@ public function testUncacheableParent($element, $args, array $expected_placehold * @covers ::doRender * @covers \Drupal\Core\Render\RenderCache::get * @covers \Drupal\Core\Render\RenderCache::set - * @covers \Drupal\Core\Render\RenderCache::createCacheID * * @dataProvider providerPlaceholders */ - public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) { + public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) { $element = $test_element; $this->setupMemoryCache(); @@ -640,10 +619,10 @@ public function testCacheableParent($test_element, $args, array $expected_placeh 'dynamic_animal' => $args[0], ]; $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.'); - $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array); + $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array); // GET request: validate cached data. - $cached = $this->memoryCache->get('placeholder_test_GET'); + $cached = $this->memoryCache->get(['placeholder_test_GET'], CacheableMetadata::createFromRenderArray($test_element)); // There are three edge cases, where the shape of the render cache item for // the parent (with CID 'placeholder_test_GET') is vastly different. These // are the cases where: @@ -664,19 +643,6 @@ public function testCacheableParent($test_element, $args, array $expected_placeh // due to the bubbled cache contexts it creates a cache redirect. if ($edge_case_a6_uncacheable) { $cached_element = $cached->data; - $expected_redirect = [ - '#cache_redirect' => TRUE, - '#cache' => [ - 'keys' => ['placeholder_test_GET'], - 'contexts' => ['user'], - 'tags' => [], - 'max-age' => Cache::PERMANENT, - 'bin' => 'render', - ], - ]; - $this->assertEquals($expected_redirect, $cached_element); - // Follow the redirect. - $cached_element = $this->memoryCache->get('placeholder_test_GET:' . implode(':', $bubbled_cache_contexts))->data; $expected_element = [ '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', '#attached' => [ @@ -786,7 +752,7 @@ public function testCacheableParentWithPostRequest($test_element, $args) { // Even when the child element's placeholder is cacheable, it should not // generate a render cache item. - $this->assertPlaceholderRenderCache(FALSE, [], []); + $this->assertPlaceholderRenderCache(FALSE, []); } /** @@ -1025,7 +991,7 @@ public function testRenderChildrenPlaceholdersDifferentArguments() { $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.'); // GET request: validate cached data. - $cached_element = $this->memoryCache->get('test:renderer:children_placeholders')->data; + $cached_element = $this->memoryCache->get(['test', 'renderer', 'children_placeholders'], CacheableMetadata::createFromRenderArray($element))->data; $expected_element = [ '#attached' => [ 'drupalSettings' => [ diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 7a2a7eedfbb5..c94caa1a2caf 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -11,6 +11,7 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Core\Render\Element; use Drupal\Core\Render\Markup; @@ -795,7 +796,6 @@ public function testRenderWithThemeArguments() { * @covers ::doRender * @covers \Drupal\Core\Render\RenderCache::get * @covers \Drupal\Core\Render\RenderCache::set - * @covers \Drupal\Core\Render\RenderCache::createCacheID */ public function testRenderCache() { $this->setUpRequest(); @@ -839,7 +839,7 @@ public function testRenderCache() { $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.'); // The cache item also has a 'rendered' cache tag. - $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark'); + $cache_item = $this->cacheFactory->get('render')->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element)); $this->assertSame(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags); } @@ -848,7 +848,6 @@ public function testRenderCache() { * @covers ::doRender * @covers \Drupal\Core\Render\RenderCache::get * @covers \Drupal\Core\Render\RenderCache::set - * @covers \Drupal\Core\Render\RenderCache::createCacheID * * @dataProvider providerTestRenderCacheMaxAge */ @@ -865,7 +864,7 @@ public function testRenderCacheMaxAge($max_age, $is_render_cached, $render_cache ]; $this->renderer->renderRoot($element); - $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark'); + $cache_item = $this->cacheFactory->get('render')->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element)); if (!$is_render_cached) { $this->assertFalse($cache_item); } @@ -893,7 +892,6 @@ public function providerTestRenderCacheMaxAge() { * @covers ::doRender * @covers \Drupal\Core\Render\RenderCache::get * @covers \Drupal\Core\Render\RenderCache::set - * @covers \Drupal\Core\Render\RenderCache::createCacheID * @covers \Drupal\Core\Render\RenderCache::getCacheableRenderArray * * @dataProvider providerTestRenderCacheProperties @@ -918,7 +916,7 @@ public function testRenderCacheProperties(array $expected_results) { $this->renderer->renderRoot($element); $cache = $this->cacheFactory->get('render'); - $data = $cache->get('render_cache_test:en:stark')->data; + $data = $cache->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element))->data; // Check that parent markup is ignored when caching children's markup. $this->assertEquals($data['#markup'] === '', (bool) Element::children($data)); diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php index 4419f04ceb40..631fccc56110 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php @@ -8,12 +8,14 @@ namespace Drupal\Tests\Core\Render; use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\Context\ContextCacheKeys; use Drupal\Core\Cache\MemoryBackend; -use Drupal\Core\Security\TrustedCallbackInterface; +use Drupal\Core\Cache\VariationCache; use Drupal\Core\Render\PlaceholderGenerator; use Drupal\Core\Render\PlaceholderingRenderCache; use Drupal\Core\Render\Renderer; +use Drupal\Core\Security\TrustedCallbackInterface; use Drupal\Tests\UnitTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpFoundation\Request; @@ -51,7 +53,7 @@ abstract class RendererTestBase extends UnitTestCase { protected $requestStack; /** - * @var \Drupal\Core\Cache\CacheFactoryInterface|\PHPUnit\Framework\MockObject\MockObject + * @var \Drupal\Core\Cache\VariationCacheFactoryInterface|\PHPUnit\Framework\MockObject\MockObject */ protected $cacheFactory; @@ -82,7 +84,7 @@ abstract class RendererTestBase extends UnitTestCase { protected $elementInfo; /** - * @var \Drupal\Core\Cache\CacheBackendInterface + * @var \Drupal\Core\Cache\VariationCacheInterface */ protected $memoryCache; @@ -142,11 +144,16 @@ protected function setUp(): void { $request = new Request(); $request->server->set('REQUEST_TIME', $_SERVER['REQUEST_TIME']); $this->requestStack->push($request); - $this->cacheFactory = $this->createMock('Drupal\Core\Cache\CacheFactoryInterface'); + $this->cacheFactory = $this->createMock('Drupal\Core\Cache\VariationCacheFactoryInterface'); $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager') ->disableOriginalConstructor() ->getMock(); $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE); + $this->cacheContextsManager->expects($this->any()) + ->method('optimizeTokens') + ->willReturnCallback(function ($context_tokens) { + return $context_tokens; + }); $current_user_role = &$this->currentUserRole; $this->cacheContextsManager->expects($this->any()) ->method('convertTokensToKeys') @@ -172,7 +179,7 @@ protected function setUp(): void { } return new ContextCacheKeys($keys); }); - $this->placeholderGenerator = new PlaceholderGenerator($this->rendererConfig); + $this->placeholderGenerator = new PlaceholderGenerator($this->cacheContextsManager, $this->rendererConfig); $this->renderCache = new PlaceholderingRenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager, $this->placeholderGenerator); $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->placeholderGenerator, $this->renderCache, $this->requestStack, $this->rendererConfig); @@ -214,7 +221,7 @@ protected function setUpUnusedCache() { * Sets up a memory-based render cache back-end. */ protected function setupMemoryCache() { - $this->memoryCache = $this->memoryCache ?: new MemoryBackend(); + $this->memoryCache = $this->memoryCache ?: new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager); $this->cacheFactory->expects($this->atLeastOnce()) ->method('get') @@ -238,27 +245,27 @@ protected function setUpRequest($method = 'GET') { /** * Asserts a render cache item. * - * @param string $cid - * The expected cache ID. + * @param string[] $keys + * The expected cache keys. * @param mixed $data * The expected data for that cache ID. * @param string $bin * The expected cache bin. */ - protected function assertRenderCacheItem($cid, $data, $bin = 'render') { + protected function assertRenderCacheItem($keys, $data, $bin = 'render') { $cache_backend = $this->cacheFactory->get($bin); - $cached = $cache_backend->get($cid); - $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', $cid)); + $cached = $cache_backend->get($keys, CacheableMetadata::createFromRenderArray($data)); + $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', implode(':', $keys))); if ($cached !== FALSE) { $this->assertEqualsCanonicalizing(array_keys($data), array_keys($cached->data), 'The cache item contains the same parent array keys.'); foreach ($data as $key => $value) { // We do not want to assert on the order of cacheability information. // @see https://www.drupal.org/project/drupal/issues/3225328 if ($key === '#cache') { - $this->assertEqualsCanonicalizing($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', $cid)); + $this->assertEqualsCanonicalizing($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', implode(':', $keys))); } else { - $this->assertEquals($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', $cid)); + $this->assertEquals($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', implode(':', $keys))); } } $this->assertEqualsCanonicalizing(Cache::mergeTags($data['#cache']['tags'], ['rendered']), $cached->tags, "The cache item's cache tags also has the 'rendered' cache tag."); -- GitLab