diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php index 3d9a90f7620032ae015cbfa9e61fb1f7c13fff88..5eef28f45c43d9ad1dee8cc26127b0afded32348 100644 --- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php @@ -89,6 +89,13 @@ abstract class EntityStorageBase extends EntityHandlerBase implements EntityStor */ protected $memoryCacheTag; + /** + * Entity IDs awaiting loading. + * + * @var list<int|string> + */ + protected array $entityIdsToLoad = []; + /** * Constructs an EntityStorageBase instance. * @@ -285,6 +292,33 @@ public function loadMultiple(?array $ids = NULL) { $ids = array_keys(array_diff_key($flipped_ids, $entities)); } + // @todo Find out why file entities cause time and space to collapse. + if ($ids && \Fiber::getCurrent() !== NULL && $this->entityType->isStaticallyCacheable() && $this->entityTypeId !== 'file') { + // Before suspending the fiber, add the IDs passed in to the full list + // of entities to load, so that another call can load everything at + // once. + $this->entityIdsToLoad = array_unique(array_merge($this->entityIdsToLoad, $ids)); + \Fiber::suspend(); + // Another call to this method may have reset the entityIdsToLoad + // property after loading entities. Combine it with the passed in IDs + // again in case this has happened. + $this->entityIdsToLoad = array_unique(array_merge($this->entityIdsToLoad, $ids)); + $entities += $this->getFromStaticCache($this->entityIdsToLoad); + if ($entities) { + // Remove any entities found in the static cache from the IDs to load. + $ids = array_keys(array_diff_key(array_flip($this->entityIdsToLoad), $entities)); + } + else { + // If nothing was found in the static cache, load every entity ID + // requested so far. + $ids = $this->entityIdsToLoad; + } + // Now that we've reached this point, unset the list of entity IDs to + // load so that further calls start with a blank slate (apart from the + // entity static cache itself). + $this->entityIdsToLoad = []; + } + // Try to gather any remaining entities from a 'preload' method. This method // can invoke a hook to be used by modules that need, for example, to swap // the default revision of an entity with a different one. Even though the @@ -300,7 +334,7 @@ public function loadMultiple(?array $ids = NULL) { // If any entities were pre-loaded, remove them from the IDs still to // load. - $ids = array_keys(array_diff_key($flipped_ids, $entities)); + $ids = array_keys(array_diff_key(array_flip($ids), $entities)); // Add pre-loaded entities to the cache. $this->setStaticCache($preloaded_entities); diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php index 5e91daf6e494bef85a544bdfdeddb46f9bf9b2c0..70505d0a65057b756c2fbb32b7bf2a60727a2206 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceFormatterBase.php @@ -145,7 +145,6 @@ public function prepareView(array $entities_items) { $target_type = $this->getFieldSetting('target_type'); $target_entities = \Drupal::entityTypeManager()->getStorage($target_type)->loadMultiple($ids); } - // For each item, pre-populate the loaded entity in $item->entity, and set // the 'loaded' flag. foreach ($entities_items as $items) { diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index 1f26381bb181891f6736f2e5ed64b7b21df9e1b7..e470b355f288bb71852f3f223c892d532900019f 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -615,12 +615,38 @@ public function hasRenderContext() { * {@inheritdoc} */ public function executeInRenderContext(RenderContext $context, callable $callable) { - // Store the current render context. + // When executing in a render context, we need to isolate any bubbled + // context within this method. To allow for async rendering, it's necessary + // to detect if a fiber suspends within a render context. When this happens, + // we swap the previous render context in before suspending upwards, then + // back out again before resuming. $previous_context = $this->getCurrentRenderContext(); - // Set the provided context and call the callable, it will use that context. $this->setCurrentRenderContext($context); - $result = $callable(); + + $fiber = new \Fiber(static fn () => $callable()); + $fiber->start(); + $result = NULL; + while (!$fiber->isTerminated()) { + if ($fiber->isSuspended()) { + // When ::executeInRenderContext() is executed within a Fiber, which is + // always the case when rendering placeholders, if the callback results + // in this fiber being suspended, we need to suspend again up to the + // parent Fiber. Doing so allows other placeholders to be rendered + // before returning here. + if (\Fiber::getCurrent() !== NULL) { + $this->setCurrentRenderContext($previous_context); + \Fiber::suspend(); + $this->setCurrentRenderContext($context); + } + $fiber->resume(); + } + // If we've reached this point, then the fiber has already been started + // and resumed at least once, so may be suspending repeatedly. Avoid + // a spin-lock by waiting for 0.5ms prior to continuing the while loop. + usleep(500); + } + $result = $fiber->getReturn(); assert($context->count() <= 1, 'Bubbling failed.'); // Restore the original render context. @@ -742,9 +768,8 @@ protected function replacePlaceholders(array &$elements) { // still not finished, then start to allow code higher up the stack to // get on with something else. if ($iterations) { - $fiber = \Fiber::getCurrent(); - if ($fiber !== NULL) { - $fiber->suspend(); + if (\Fiber::getCurrent() !== NULL) { + \Fiber::suspend(); } } continue; diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php index eaedcda438350001a45b1360b9cc7eae54017968..dabf552d9083aa9c4e421f777da2675eb1b25647 100644 --- a/core/modules/big_pipe/src/Render/BigPipe.php +++ b/core/modules/big_pipe/src/Render/BigPipe.php @@ -516,9 +516,8 @@ protected function sendPlaceholders(array $placeholders, array $placeholder_orde // still not finished, then start to allow code higher up the stack // to get on with something else. if ($iterations) { - $fiber = \Fiber::getCurrent(); - if ($fiber !== NULL) { - $fiber->suspend(); + if (\Fiber::getCurrent() !== NULL) { + \Fiber::suspend(); } } continue; diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php index 071202256fbcc044cc8ea4d0c31f6f5e7556d8c7..3c59e4ee6c02bcfcabd073f6a0d0b4f1000d6e99 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryFrontPagePerformanceTest.php @@ -40,8 +40,9 @@ protected function testFrontPageColdCache(): void { // a non-deterministic test since they happen in parallel and therefore post // response tasks run in different orders each time. $this->drupalGet('<front>'); + sleep(1); $this->drupalGet('<front>'); - sleep(2); + sleep(1); $this->clearCaches(); $performance_data = $this->collectPerformanceData(function () { $this->drupalGet('<front>'); @@ -49,9 +50,9 @@ protected function testFrontPageColdCache(): void { $this->assertSession()->pageTextContains('Umami'); $expected = [ - 'QueryCount' => 381, - 'CacheGetCount' => 471, - 'CacheSetCount' => 467, + 'QueryCount' => 366, + 'CacheGetCount' => 468, + 'CacheSetCount' => 463, 'CacheDeleteCount' => 0, 'CacheTagLookupQueryCount' => 49, 'CacheTagInvalidationCount' => 0, @@ -119,9 +120,9 @@ protected function testFrontPageCoolCache(): void { }, 'umamiFrontPageCoolCache'); $expected = [ - 'QueryCount' => 112, - 'CacheGetCount' => 239, - 'CacheSetCount' => 93, + 'QueryCount' => 104, + 'CacheGetCount' => 237, + 'CacheSetCount' => 91, 'CacheDeleteCount' => 0, 'CacheTagInvalidationCount' => 0, 'CacheTagLookupQueryCount' => 31, diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php index dd26c2f12638a7a62dc9c07c2a7d69f11e7662f7..3045b218bc41ebd2e5013d81f1b7f3721db320b3 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/OpenTelemetryNodePagePerformanceTest.php @@ -136,9 +136,12 @@ protected function testNodePageCoolCache(): void { protected function testNodePageWarmCache(): void { // First of all visit the node page to ensure the image style exists. $this->drupalGet('node/1'); + // Allow time for the image style and asset aggregate requests to finish. + sleep(1); $this->clearCaches(); // Now visit a different node page to warm non-path-specific caches. $this->drupalGet('node/2'); + sleep(1); $performance_data = $this->collectPerformanceData(function () { $this->drupalGet('node/1'); }, 'umamiNodePageWarmCache'); diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityApiTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityApiTest.php index f4f1b70eac526170297b1d7df8191a674da3b837..35eaf6b4c151484dc2643e8e1eab6a8a618200b9 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityApiTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityApiTest.php @@ -143,6 +143,52 @@ protected function assertCRUD(string $entity_type, UserInterface $user1): void { } } + /** + * Test lazy preloading. + */ + public function testLazyPreLoading(): void { + $storage = $this->container->get('entity_type.manager')->getStorage('entity_test'); + $ids = []; + $entity = $storage->create(['name' => 'test']); + $entity->save(); + $ids[] = $entity->id(); + + $entity = $storage->create(['name' => 'test2']); + $entity->save(); + $ids[] = $entity->id(); + + $fiber1 = new \Fiber(fn () => $storage->load($ids[0])); + $fiber2 = new \Fiber(fn () => $storage->load($ids[1])); + + // Make sure the entity cache is empty. + $this->container->get('entity.memory_cache')->reset(); + + // Start Fiber 1, this should set the first entity to be loaded, without + // actually loading it, and then suspend. + $fiber1->start(); + $this->assertTrue($fiber1->isSuspended()); + $this->assertFalse($this->container->get('entity.memory_cache')->get('values:entity_test:' . $ids[0])); + + // Start Fiber 2, this should set the first entity to be loaded, without + // actually loading it, and then suspend. + $fiber2->start(); + $this->assertTrue($fiber2->isSuspended()); + $this->assertFalse($this->container->get('entity.memory_cache')->get('values:entity_test:' . $ids[1])); + + $fiber2->resume(); + + $this->assertTrue($fiber2->isTerminated()); + + $this->assertSame($fiber2->getReturn()->id(), $ids[1]); + + // Now both entities should be loaded. + $this->assertNotFalse($this->container->get('entity.memory_cache')->get('values:entity_test:' . $ids[0])); + $this->assertNotFalse($this->container->get('entity.memory_cache')->get('values:entity_test:' . $ids[1])); + $fiber1->resume(); + $this->assertTrue($fiber1->isTerminated()); + $this->assertSame($fiber1->getReturn()->id(), $ids[0]); + } + /** * Tests that the Entity storage loads the entities in the correct order. * diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php index 9c68273365b3aefd5d369d843e88cf2b7225dad7..e4fbfa60caf70346b09f91dcc9f0d47a95b7370f 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php @@ -1126,6 +1126,68 @@ public function testHasRenderContext(): void { $this->assertFalse($this->renderer->hasRenderContext()); } + /** + * @covers ::executeInRenderContext + */ + public function testExecuteInRenderContext(): void { + $return = $this->renderer->executeInRenderContext(new RenderContext(), function () { + $fiber_callback = function () { + + // Create a #pre_render callback that renders a render array in + // isolation. This has its own #pre_render callback that calls + // Fiber::suspend(). This ensures that suspending a Fiber within + // multiple nested calls to ::executeInRenderContext() doesn't + // allow render context to get out of sync. This simulates similar + // conditions to BigPipe placeholder rendering. + $fiber_suspend_pre_render = function ($elements) { + $fiber_suspend = function ($elements) { + \Fiber::suspend(); + return $elements; + }; + $build = [ + 'foo' => [ + '#markup' => 'foo', + '#pre_render' => [$fiber_suspend], + ], + ]; + $markup = $this->renderer->renderInIsolation($build); + $elements['#markup'] = $markup; + return $elements; + }; + $build = [ + 'foo' => [ + '#pre_render' => [$fiber_suspend_pre_render], + ], + ]; + return $this->renderer->render($build); + }; + + // Build an array of two fibers that executes the code defined above. This + // ensures that Fiber::suspend() is called from within two + // ::renderInIsolation() calls without either having been completed. + $fibers = []; + foreach ([0, 1] as $key) { + $fibers[] = new \Fiber(static fn () => $fiber_callback()); + } + while ($fibers) { + foreach ($fibers as $key => $fiber) { + if ($fiber->isTerminated()) { + unset($fibers[$key]); + continue; + } + if ($fiber->isSuspended()) { + $fiber->resume(); + } + else { + $fiber->start(); + } + } + } + return $fiber->getReturn(); + }); + $this->assertEquals(Markup::create('foo'), $return); + } + } /**