diff --git a/core/lib/Drupal/Core/Cache/VariationCache.php b/core/lib/Drupal/Core/Cache/VariationCache.php index 3e3080b3230123ddcdb8007b227bfcdff7237615..1d26eea8c7ee436b4f7d59f9f7228a98e6165c14 100644 --- a/core/lib/Drupal/Core/Cache/VariationCache.php +++ b/core/lib/Drupal/Core/Cache/VariationCache.php @@ -12,6 +12,25 @@ */ class VariationCache implements VariationCacheInterface { + /** + * Stores redirect chain lookups until the next set, invalidate or delete. + * + * Array keys are the cache IDs constructed from the cache keys and initial + * cacheability and values are arrays where each step of a redirect chain is + * recorded. + * + * These arrays are indexed by the cache IDs being followed during the chain + * and the CacheRedirect objects that construct the chain. At the end there + * should always be a value of FALSE for a cache miss, or a CacheRedirect for + * a cache hit because we cannot store the cache hit itself into a property + * that does not support invalidation based on cache metadata. By storing the + * last CacheRedirect that led to the hit, we can at least avoid having to + * retrieve the entire chain again to get to the actual cached data. + * + * @var array + */ + protected array $redirectChainCache = []; + /** * Constructs a new VariationCache object. * @@ -33,7 +52,7 @@ public function __construct( */ public function get(array $keys, CacheableDependencyInterface $initial_cacheability) { $chain = $this->getRedirectChain($keys, $initial_cacheability); - return array_pop($chain); + return end($chain); } /** @@ -45,13 +64,39 @@ public function getMultiple(array $items): array { // following of redirect chains by calling ::getMultiple() on the underlying // cache backend. // + // However, ::getRedirectChain() has an internal cache that we could both + // benefit from and contribute to whenever we call this function. So any use + // or manipulation of $this->redirectChainCache below is for optimization + // purposes. A description of the internal cache structure is on the + // property documentation of $this->redirectChainCache. + // // Create a map of CIDs with their associated $items index and cache keys. $cid_map = []; foreach ($items as $index => [$keys, $cacheability]) { - $cid = $this->createCacheIdFast($keys, $cacheability); + // Try to optimize based on the cached redirect chain. + if ($chain = $this->getValidatedCachedRedirectChain($keys, $cacheability)) { + $last_item = end($chain); + + // Immediately skip processing the CID for cache misses. + if ($last_item === FALSE) { + continue; + } + + // We do not need to calculate the initial CID as its part of the chain. + $initial_cid = array_key_first($chain); + + // Prime the CID map with the last known redirect for the initial CID. + assert($last_item->data instanceof CacheRedirect); + $cid = $this->createCacheIdFast($keys, $last_item->data); + } + else { + $cid = $initial_cid = $this->createCacheIdFast($keys, $cacheability); + } + $cid_map[$cid] = [ 'index' => $index, 'keys' => $keys, + 'initial' => $initial_cid, ]; } @@ -71,12 +116,20 @@ public function getMultiple(array $items): array { if ($result->data instanceof CacheRedirect) { $redirect_cid = $this->createCacheIdFast($info['keys'], $result->data); $new_cid_map[$redirect_cid] = $info; + $this->redirectChainCache[$info['initial']][$cid] = $result; continue; } $results[$info['index']] = $result; } + // Any CID that did not get a cache hit is still in $fetch_cids. Add them + // to the internal redirect chain cache as a miss. + foreach ($fetch_cids as $fetch_cid) { + $info = $cid_map[$fetch_cid]; + $this->redirectChainCache[$info['initial']][$fetch_cid] = FALSE; + } + $cid_map = $new_cid_map; } @@ -224,6 +277,7 @@ public function set(array $keys, $data, CacheableDependencyInterface $cacheabili $this->cacheBackend->set($chain_cid, new CacheRedirect($cacheability)); } + unset($this->redirectChainCache[$this->createCacheIdFast($keys, $initial_cacheability)]); $this->cacheBackend->set($cid, $data, $this->maxAgeToExpire($cacheability->getCacheMaxAge()), $optimized_cacheability->getCacheTags()); } @@ -232,6 +286,13 @@ public function set(array $keys, $data, CacheableDependencyInterface $cacheabili */ public function delete(array $keys, CacheableDependencyInterface $initial_cacheability): void { $chain = $this->getRedirectChain($keys, $initial_cacheability); + + // Don't need to delete what could not be found. + if (end($chain) === FALSE) { + return; + } + + unset($this->redirectChainCache[$this->createCacheIdFast($keys, $initial_cacheability)]); $this->cacheBackend->delete(array_key_last($chain)); } @@ -240,6 +301,13 @@ public function delete(array $keys, CacheableDependencyInterface $initial_cachea */ public function invalidate(array $keys, CacheableDependencyInterface $initial_cacheability): void { $chain = $this->getRedirectChain($keys, $initial_cacheability); + + // Don't need to invalidate what could not be found. + if (end($chain) === FALSE) { + return; + } + + unset($this->redirectChainCache[$this->createCacheIdFast($keys, $initial_cacheability)]); $this->cacheBackend->invalidate(array_key_last($chain)); } @@ -261,17 +329,82 @@ public function invalidate(array $keys, CacheableDependencyInterface $initial_ca * 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); + $chain = $this->getValidatedCachedRedirectChain($keys, $initial_cacheability); + + // Initiate the chain if we couldn't retrieve (a partial) one from memory. + if (empty($chain)) { + $cid = $initial_cid = $this->createCacheIdFast($keys, $initial_cacheability); + $chain[$cid] = $result = $this->cacheBackend->get($cid); + } + // If we did find one, we continue our search from the last valid redirect + // in the chain or bypass the while loop below in case the chain ends in + // FALSE, indicating a previous cache miss. + else { + $initial_cid = array_key_first($chain); + $result = end($chain); + } while ($result && $result->data instanceof CacheRedirect) { $cid = $this->createCacheIdFast($keys, $result->data); $chain[$cid] = $result = $this->cacheBackend->get($cid); } + // When storing the redirect chain in memory we must take care to not store + // a cache hit as they can be invalidated, unlike CacheRedirect objects. We + // do store the rest of the chain because redirects can be reused safely. + $chain_to_cache = $chain; + if ($result !== FALSE) { + array_pop($chain_to_cache); + } + $this->redirectChainCache[$initial_cid] = $chain_to_cache; + return $chain; } + /** + * Retrieved the redirect chain from cache, validating each part. + * + * @param string[] $keys + * The cache keys to retrieve the redirect chain for. + * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability + * The initial cacheability for the redirect chain. + * + * @return array + * The part of the cached redirect chain, if any, that is still valid. + */ + protected function getValidatedCachedRedirectChain(array $keys, CacheableDependencyInterface $initial_cacheability): array { + $cid = $this->createCacheIdFast($keys, $initial_cacheability); + if (!isset($this->redirectChainCache[$cid])) { + return []; + } + $chain = $this->redirectChainCache[$cid]; + + // Only use that part of the redirect chain that is still valid. Even though + // we do not store cache hits in the internal redirect chain cache, we can + // still reuse the whole chain up until what would have been a cache hit. + // + // If part of a redirect chain no longer matches because cache contexts + // changed values, we could perhaps still reuse part of the chain until we + // encounter a redirect for the changed cache context value. + // + // There is one special case: If the very last item of the chain is a cache + // redirect, and we cannot find anything for it, we still add the redirect + // to the validated chain because the only way a cached chain ends in a + // redirect is if it led to a cache hit in ::getRedirectChain(). + $valid_parts = []; + $last_key = array_key_last($chain); + foreach ($chain as $key => $result) { + if ($result && $result->data instanceof CacheRedirect) { + $cid = $this->createCacheIdFast($keys, $result->data); + if (!isset($chain[$cid]) && $last_key !== $key) { + break; + } + } + $valid_parts[$key] = $result; + } + return $valid_parts; + } + /** * Maps a max-age value to an "expire" value for the Cache API. * @@ -336,4 +469,15 @@ protected function createCacheIdFast(array $keys, CacheableDependencyInterface $ return implode(':', $keys); } + /** + * Reset statically cached variables. + * + * This is only used by tests. + * + * @internal + */ + public function reset(): void { + $this->redirectChainCache = []; + } + } diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php index aa40c2446c5c8f55890d9ab56923839cbc55d9d7..33e96003d6ade78641380340b4b529bd7c83eb0d 100644 --- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php +++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php @@ -399,7 +399,13 @@ protected function assertCacheableNormalizations(): void { * @internal */ protected function assertNormalizedFieldsAreCached(array $field_names): void { - $cache = \Drupal::service('variation_cache.jsonapi_normalizations')->get(['node--camelids', $this->entity->uuid(), $this->entity->language()->getId()], new CacheableMetadata()); + $variation_cache = \Drupal::service('variation_cache.jsonapi_normalizations'); + + // Because we warm caches in different requests, we do not properly populate + // the internal properties of our variation cache. Reset it. + $variation_cache->reset(); + + $cache = $variation_cache->get(['node--camelids', $this->entity->uuid(), $this->entity->language()->getId()], new CacheableMetadata()); $cached_fields = $cache->data['fields']; $this->assertSameSize($field_names, $cached_fields); array_walk($field_names, function ($field_name) use ($cached_fields) { diff --git a/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php b/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php index 055b009a0c5e1134b3a98ecb6bf0380c724ba82a..b1b0934b5da3a13c5abf844bc73742a13f144375 100644 --- a/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php +++ b/core/modules/jsonapi/tests/src/FunctionalJavascript/JsonApiPerformanceTest.php @@ -87,7 +87,7 @@ public function testGetIndividual(): void { $expected = [ 'QueryCount' => 26, - 'CacheGetCount' => 44, + 'CacheGetCount' => 42, 'CacheGetCountByBin' => [ 'config' => 8, 'data' => 8, @@ -95,8 +95,8 @@ public function testGetIndividual(): void { 'discovery' => 13, 'entity' => 2, 'default' => 4, - 'dynamic_page_cache' => 2, - 'jsonapi_normalizations' => 2, + 'dynamic_page_cache' => 1, + 'jsonapi_normalizations' => 1, ], 'CacheSetCount' => 16, 'CacheSetCountByBin' => [ @@ -206,7 +206,7 @@ public function testGetIndividual(): void { $expected = [ 'QueryCount' => 15, - 'CacheGetCount' => 47, + 'CacheGetCount' => 43, 'CacheGetCountByBin' => [ 'config' => 8, 'data' => 8, @@ -214,8 +214,8 @@ public function testGetIndividual(): void { 'entity' => 2, 'default' => 4, 'bootstrap' => 4, - 'dynamic_page_cache' => 4, - 'jsonapi_normalizations' => 4, + 'dynamic_page_cache' => 2, + 'jsonapi_normalizations' => 2, ], 'CacheSetCount' => 3, 'CacheDeleteCount' => 0, diff --git a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php index aeb053719090028951e590aae6bb7a66a546af73..3264d769c195b30c90ac75c17cfc939ff97ce040 100644 --- a/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php +++ b/core/modules/navigation/tests/src/FunctionalJavascript/PerformanceTest.php @@ -73,13 +73,13 @@ public function testLogin(): void { $expected = [ 'QueryCount' => 4, - 'CacheGetCount' => 50, + 'CacheGetCount' => 49, 'CacheGetCountByBin' => [ 'config' => 11, 'data' => 4, 'discovery' => 10, 'bootstrap' => 6, - 'dynamic_page_cache' => 2, + 'dynamic_page_cache' => 1, 'render' => 16, 'menu' => 1, ], diff --git a/core/modules/rest/tests/src/Functional/Views/StyleSerializerEntityTest.php b/core/modules/rest/tests/src/Functional/Views/StyleSerializerEntityTest.php index 9175d6f5da496f1ac75d1eb7b6bd8a9b70517b6f..af91d16342729c24f14a9c102825fabbc2750eb4 100644 --- a/core/modules/rest/tests/src/Functional/Views/StyleSerializerEntityTest.php +++ b/core/modules/rest/tests/src/Functional/Views/StyleSerializerEntityTest.php @@ -220,6 +220,11 @@ protected function addRequestWithFormat($format): void { */ public function testRestRenderCaching(): void { $this->drupalLogin($this->adminUser); + + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $vc_factory */ + $variation_cache_factory = \Drupal::service('variation_cache_factory'); + $variation_cache = $variation_cache_factory->get('render'); + /** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */ $render_cache = \Drupal::service('render_cache'); @@ -268,6 +273,10 @@ public function testRestRenderCaching(): void { $this->assertSession()->responseHeaderEquals('content-type', 'application/json'); $this->assertCacheContexts($cache_contexts); $this->assertCacheTags($cache_tags); + + // Because we warm caches in different requests, we do not properly populate + // the internal properties of our variation cache. Reset it. + $variation_cache->reset(); $this->assertNotEmpty($render_cache->get($original)); $result_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]); diff --git a/core/modules/views/tests/src/Functional/Plugin/CacheWebTest.php b/core/modules/views/tests/src/Functional/Plugin/CacheWebTest.php index 29902c6aee4d51389eaaabeea5eb6a32a7981ba5..e1b403eb53ff7f814191175d28400a19af9da4ba 100644 --- a/core/modules/views/tests/src/Functional/Plugin/CacheWebTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/CacheWebTest.php @@ -62,6 +62,10 @@ public function testCacheOutputOnPage(): void { $view->save(); $this->container->get('router.builder')->rebuildIfNeeded(); + /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $vc_factory */ + $variation_cache_factory = \Drupal::service('variation_cache_factory'); + $variation_cache = $variation_cache_factory->get('render'); + /** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */ $render_cache = \Drupal::service('render_cache'); $cache_element = DisplayPluginBase::buildBasicRenderable('test_display', 'page_1'); @@ -70,6 +74,11 @@ public function testCacheOutputOnPage(): void { $this->drupalGet('test-display'); $this->assertSession()->statusCodeEquals(200); + + // Because we warm caches in different requests, we do not properly populate + // the internal properties of our variation cache. Reset it. + $variation_cache->reset(); + $this->assertNotEmpty($render_cache->get($cache_element)); $cache_tags = [ 'config:user.role.anonymous', diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php index c18710cd8151423a01916d87a92b377079d20298..ee3a60bd012c71f30d0d52a4260f43d8f94cd9be 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php @@ -287,7 +287,7 @@ protected function testNodePageWarmCache(): void { $expected = [ 'QueryCount' => 171, - 'CacheGetCount' => 225, + 'CacheGetCount' => 202, 'CacheGetCountByBin' => [ 'page' => 1, 'config' => 66, @@ -295,8 +295,8 @@ protected function testNodePageWarmCache(): void { 'discovery' => 67, 'data' => 13, 'entity' => 18, - 'dynamic_page_cache' => 2, - 'render' => 43, + 'dynamic_page_cache' => 1, + 'render' => 21, 'default' => 3, 'menu' => 2, ], diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php index 920c2f524d99fcfc7b56e681937d9865688c5ad0..4894d20f85c551cebb3fbc690aa88282b62f8f52 100644 --- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php @@ -127,15 +127,15 @@ protected function testAnonymous(): void { $this->assertSame($expected_queries, $recorded_queries); $expected = [ 'QueryCount' => 36, - 'CacheGetCount' => 121, + 'CacheGetCount' => 100, 'CacheGetCountByBin' => [ 'page' => 1, 'config' => 21, 'data' => 8, 'discovery' => 38, 'bootstrap' => 8, - 'dynamic_page_cache' => 2, - 'render' => 34, + 'dynamic_page_cache' => 1, + 'render' => 14, 'default' => 5, 'entity' => 2, 'menu' => 2, @@ -227,7 +227,7 @@ protected function testAnonymous(): void { $this->assertSame($expected_queries, $recorded_queries); $expected = [ 'QueryCount' => 10, - 'CacheGetCount' => 91, + 'CacheGetCount' => 72, 'CacheSetCount' => 16, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, @@ -309,7 +309,7 @@ protected function testAnonymous(): void { $this->assertSame($expected_queries, $recorded_queries); $expected = [ 'QueryCount' => 14, - 'CacheGetCount' => 76, + 'CacheGetCount' => 57, 'CacheSetCount' => 17, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0,