Verified Commit 160ab21c authored by Dave Long's avatar Dave Long
Browse files

perf: #3457818 Optimize CacheContextsManager:::convertTokensToKeys()/optimizeTokens()

By: kristiaanvandeneynde
By: berdir
By: tibezh
By: klausi
By: longwave
By: catch
parent 06d89e94
Loading
Loading
Loading
Loading
Loading
+0 −6
Original line number Diff line number Diff line
@@ -45421,12 +45421,6 @@
	'count' => 1,
	'path' => __DIR__ . '/tests/Drupal/Tests/Core/Batch/BatchBuilderTest.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Tests\\\\Core\\\\Cache\\\\Context\\\\CacheContextsManagerTest\\:\\:getMockContainer\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
	'count' => 1,
	'path' => __DIR__ . '/tests/Drupal/Tests/Core/Cache/Context/CacheContextsManagerTest.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Tests\\\\Core\\\\Condition\\\\ConditionAccessResolverTraitTest\\:\\:providerTestResolveConditions\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
+54 −33
Original line number Diff line number Diff line
@@ -162,48 +162,69 @@ public function convertTokensToKeys(array $context_tokens) {
   *   A representative subset of the given set of cache context tokens.
   */
  public function optimizeTokens(array $context_tokens) {
    // A single token (or empty array) cannot be optimized.
    if (count($context_tokens) <= 1) {
      return $context_tokens;
    }

    // Use hash table for O(1) ancestor lookups instead of O(n) in_array().
    $context_tokens_lookup = array_flip($context_tokens);

    $optimized_content_tokens = [];
    foreach ($context_tokens as $context_token) {
      $has_period = str_contains($context_token, '.');
      $has_colon = str_contains($context_token, ':');

      // Extract the parameter if available.
      $parameter = NULL;
      $context_id = $context_token;
      if (str_contains($context_token, ':')) {
        [$context_id, $parameter] = explode(':', $context_token);
      }

      // Context tokens without:
      // - a period means they don't have a parent
      // - a colon means they're not a specific value of a cache context
      // Context tokens without a period or colon have no parent,
      // hence no optimizations are possible.
      if (!str_contains($context_token, '.') && !str_contains($context_token, ':')) {
        $optimized_content_tokens[] = $context_token;
      }
      // Check cacheability. If the context defines a max-age of 0, then it
      // can not be optimized away. Pass the parameter along if we have one.
      elseif ($this->getService($context_id)->getCacheableMetadata($parameter)->getCacheMaxAge() === 0) {
      if (!$has_period && !$has_colon) {
        $optimized_content_tokens[] = $context_token;
        continue;
      }
      // The context token has a period or a colon. Iterate over all ancestor
      // cache contexts. If one exists, omit the context token.
      else {

      // Check for ancestors first (cheap string operations) before calling
      // getService() which is more expensive.
      $ancestor_found = FALSE;

      // Treat a colon like a period, that allows us to consider 'a' the
      // ancestor of 'a:foo', without any additional code for the colon.
        $ancestor = str_replace(':', '.', $context_token);
      $ancestor = $has_colon ? str_replace(':', '.', $context_token) : $context_token;

      do {
        $ancestor = substr($ancestor, 0, strrpos($ancestor, '.'));
          if (in_array($ancestor, $context_tokens)) {
        if (isset($context_tokens_lookup[$ancestor])) {
          // An ancestor cache context is in $context_tokens, hence this cache
          // context is implied.
          $ancestor_found = TRUE;
          break;
        }
      } while (str_contains($ancestor, '.'));

      if ($ancestor_found) {
        // Ancestor found. Check if this token can be optimized away by
        // verifying its max-age is not 0.
        // Note: We intentionally do NOT cache getCacheableMetadata() results
        // because the metadata can change during a request (e.g., when user
        // permissions or roles change). Caching could lead to incorrect
        // optimization decisions.
        $parameter = NULL;
        $context_id = $context_token;
        if ($has_colon) {
          [$context_id, $parameter] = explode(':', $context_token, 2);
        }

        } while (!$ancestor_found && str_contains($ancestor, '.'));
        if (!$ancestor_found) {
        // If max-age is 0, the token cannot be optimized away.
        $max_age = $this->getService($context_id)
          ->getCacheableMetadata($parameter)
          ->getCacheMaxAge();
        if ($max_age === 0) {
          $optimized_content_tokens[] = $context_token;
        }
      }
      else {
        // No ancestor exists, keep this token.
        $optimized_content_tokens[] = $context_token;
      }
    }
    return $optimized_content_tokens;
  }
+3 −3
Original line number Diff line number Diff line
@@ -212,7 +212,7 @@ protected function testAnonymous(): void {
    $this->assertSame($expected_queries, $recorded_queries);
    $expected = [
      'QueryCount' => 11,
      'CacheGetCount' => 75,
      'CacheGetCount' => 72,
      'CacheSetCount' => 18,
      'CacheDeleteCount' => 0,
      'CacheTagInvalidationCount' => 0,
@@ -269,7 +269,7 @@ protected function testAnonymous(): void {
    $this->assertSame($expected_queries, $recorded_queries);
    $expected = [
      'QueryCount' => 11,
      'CacheGetCount' => 61,
      'CacheGetCount' => 58,
      'CacheSetCount' => 15,
      'CacheDeleteCount' => 0,
      'CacheTagInvalidationCount' => 0,
@@ -427,7 +427,7 @@ protected function testLogin(): void {
      'StylesheetBytes' => 1429,
      'StylesheetCount' => 1,
      'QueryCount' => 17,
      'CacheGetCount' => 77,
      'CacheGetCount' => 74,
      'CacheSetCount' => 1,
      'CacheDeleteCount' => 1,
      'CacheTagInvalidationCount' => 0,
+48 −69
Original line number Diff line number Diff line
@@ -8,11 +8,13 @@
use Drupal\Core\Cache\Context\CacheContextInterface;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\Cache\Context\CalculatedCacheContextInterface;
use Drupal\Core\DependencyInjection\Container as DrupalContainer;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\Stub;
use Symfony\Component\DependencyInjection\Container;

// cspell:ignore cnenzrgre
@@ -27,39 +29,17 @@ class CacheContextsManagerTest extends UnitTestCase {
   * Tests optimize tokens.
   */
  #[DataProvider('providerTestOptimizeTokens')]
  public function testOptimizeTokens(array $context_tokens, array $optimized_context_tokens): void {
    $container = $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
      ->disableOriginalConstructor()
      ->getMock();
    $container->expects($this->any())
  public function testOptimizeTokens(array $context_tokens, array $optimized_context_tokens, int $expected_container_calls): void {
    $container = $this->createMock(DrupalContainer::class);
    $container->expects($this->exactly($expected_container_calls))
      ->method('get')
      ->willReturnMap([
        [
          'cache_context.a',
          Container::EXCEPTION_ON_INVALID_REFERENCE,
          new FooCacheContext(),
        ],
        [
          'cache_context.a.b',
          Container::EXCEPTION_ON_INVALID_REFERENCE,
          new FooCacheContext(),
        ],
        [
          'cache_context.a.b.c',
          Container::EXCEPTION_ON_INVALID_REFERENCE,
          new BazCacheContext(),
        ],
        [
          'cache_context.x',
          Container::EXCEPTION_ON_INVALID_REFERENCE,
          new BazCacheContext(),
        ],
        [
          'cache_context.a.b.no-optimize',
          Container::EXCEPTION_ON_INVALID_REFERENCE,
          new NoOptimizeCacheContext(),
        ],
      ]);
      ->willReturnCallback(fn($service_id) => match ($service_id) {
        'cache_context.a' => new FooCacheContext(),
        'cache_context.a.b' => new FooCacheContext(),
        'cache_context.a.b.c' => new BazCacheContext(),
        'cache_context.x' => new BazCacheContext(),
        'cache_context.a.b.no-optimize' => new NoOptimizeCacheContext(),
      });
    $cache_contexts_manager = new CacheContextsManager($container, $this->getContextsFixture());

    $this->assertSame($optimized_context_tokens, $cache_contexts_manager->optimizeTokens($context_tokens));
@@ -70,36 +50,37 @@ public function testOptimizeTokens(array $context_tokens, array $optimized_conte
   */
  public static function providerTestOptimizeTokens(): array {
    return [
      [['a', 'x'], ['a', 'x']],
      [['a.b', 'x'], ['a.b', 'x']],

      // Direct ancestor, single-level hierarchy.
      [['a', 'a.b'], ['a']],
      [['a.b', 'a'], ['a']],

      // Direct ancestor, multi-level hierarchy.
      [['a.b', 'a.b.c'], ['a.b']],
      [['a.b.c', 'a.b'], ['a.b']],

      // Indirect ancestor.
      [['a', 'a.b.c'], ['a']],
      [['a.b.c', 'a'], ['a']],

      // Direct & indirect ancestors.
      [['a', 'a.b', 'a.b.c'], ['a']],
      [['a', 'a.b.c', 'a.b'], ['a']],
      [['a.b', 'a', 'a.b.c'], ['a']],
      [['a.b', 'a.b.c', 'a'], ['a']],
      [['a.b.c', 'a.b', 'a'], ['a']],
      [['a.b.c', 'a', 'a.b'], ['a']],

      // Using parameters.
      [['a', 'a.b.c:foo'], ['a']],
      [['a.b.c:foo', 'a'], ['a']],
      [['a.b.c:foo', 'a.b.c'], ['a.b.c']],

      // max-age 0 is treated as non-optimizable.
      [['a.b.no-optimize', 'a.b', 'a'], ['a.b.no-optimize', 'a']],
      // No ancestors found, 0 container calls needed.
      [['a', 'x'], ['a', 'x'], 0],
      [['a.b', 'x'], ['a.b', 'x'], 0],

      // Direct ancestor, single-level hierarchy: 1 call to check max-age.
      [['a', 'a.b'], ['a'], 1],
      [['a.b', 'a'], ['a'], 1],

      // Direct ancestor, multi-level hierarchy: 1 call to check max-age.
      [['a.b', 'a.b.c'], ['a.b'], 1],
      [['a.b.c', 'a.b'], ['a.b'], 1],

      // Indirect ancestor: 1 call to check max-age.
      [['a', 'a.b.c'], ['a'], 1],
      [['a.b.c', 'a'], ['a'], 1],

      // Direct & indirect ancestors: 2 calls (one for each descendant).
      [['a', 'a.b', 'a.b.c'], ['a'], 2],
      [['a', 'a.b.c', 'a.b'], ['a'], 2],
      [['a.b', 'a', 'a.b.c'], ['a'], 2],
      [['a.b', 'a.b.c', 'a'], ['a'], 2],
      [['a.b.c', 'a.b', 'a'], ['a'], 2],
      [['a.b.c', 'a', 'a.b'], ['a'], 2],

      // Using parameters: 1 call to check max-age.
      [['a', 'a.b.c:foo'], ['a'], 1],
      [['a.b.c:foo', 'a'], ['a'], 1],
      [['a.b.c:foo', 'a.b.c'], ['a.b.c'], 1],

      // max-age 0 is treated as non-optimizable: 2 calls (both have ancestors).
      [['a.b.no-optimize', 'a.b', 'a'], ['a.b.no-optimize', 'a'], 2],
    ];
  }

@@ -143,7 +124,7 @@ public function testInvalidContext(): void {
   * @legacy-covers ::convertTokensToKeys
   */
  #[DataProvider('providerTestInvalidCalculatedContext')]
  public function testInvalidCalculatedContext($context_token): void {
  public function testInvalidCalculatedContext(string $context_token): void {
    $container = $this->getMockContainer();
    $cache_contexts_manager = new CacheContextsManager($container, $this->getContextsFixture());

@@ -179,11 +160,9 @@ protected function getContextsFixture(): array {
    return ['foo', 'baz'];
  }

  protected function getMockContainer() {
    $container = $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
      ->disableOriginalConstructor()
      ->getMock();
    $container->expects($this->any())
  protected function getMockContainer(): Stub {
    $container = $this->createStub(DrupalContainer::class);
    $container
      ->method('get')
      ->willReturnMap([
        [
@@ -239,7 +218,7 @@ public static function validateTokensProvider(): array {
   * @legacy-covers ::validateTokens
   */
  #[DataProvider('validateTokensProvider')]
  public function testValidateContexts(array $contexts, $expected_exception_message): void {
  public function testValidateContexts(array $contexts, bool|string $expected_exception_message): void {
    $container = new ContainerBuilder();
    $cache_contexts_manager = new CacheContextsManager($container, ['foo', 'foo.bar', 'baz']);
    if ($expected_exception_message !== FALSE) {