diff --git a/core/lib/Drupal/Core/Cache/MemoryCache/LruMemoryCache.php b/core/lib/Drupal/Core/Cache/MemoryCache/LruMemoryCache.php new file mode 100644 index 0000000000000000000000000000000000000000..8794df8175ffe36e2f3e42ed25fc71854efe8da8 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/MemoryCache/LruMemoryCache.php @@ -0,0 +1,141 @@ +<?php + +namespace Drupal\Core\Cache\MemoryCache; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Cache\Cache; + +/** + * Defines a least recently used (LRU) static cache implementation. + * + * Stores cache items in memory using a PHP array. The number of cache items is + * limited to a fixed number of slots. When the all slots are full, older items + * are purged based on least recent usage. + * + * @ingroup cache + */ +class LruMemoryCache extends MemoryCache { + + /** + * Constructs an LruMemoryCache object. + * + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param int $allowedSlots + * (optional) The number of slots to allocate for items in the cache. + * Defaults to 300. + */ + public function __construct( + TimeInterface $time, + protected readonly int $allowedSlots = 300, + ) { + parent::__construct($time); + } + + /** + * {@inheritdoc} + */ + public function get($cid, $allow_invalid = FALSE) { + if ($cached = parent::get($cid, $allow_invalid)) { + $this->handleCacheHits([$cid => $cached]); + } + return $cached; + } + + /** + * {@inheritdoc} + */ + public function getMultiple(&$cids, $allow_invalid = FALSE) { + $ret = parent::getMultiple($cids, $allow_invalid); + $this->handleCacheHits($ret); + return $ret; + } + + /** + * Moves an array of cache items to the most recently used positions. + * + * @param array $items + * An array of cache items keyed by cid. + */ + private function handleCacheHits(array $items): void { + $last_key = array_key_last($this->cache); + foreach ($items as $cid => $cached) { + if ($cached->valid && $cid !== $last_key) { + // Move valid items to the end of the array, so they will be removed + // last. + unset($this->cache[$cid]); + $this->cache[$cid] = $cached; + $last_key = $cid; + } + } + } + + /** + * {@inheritdoc} + */ + public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = []): void { + if (isset($this->cache[$cid])) { + // If the item is already in the cache, move it to end of the array. + unset($this->cache[$cid]); + } + elseif (count($this->cache) > $this->allowedSlots - 1) { + // Remove one item from the cache to ensure we remain within the allowed + // number of slots. Avoid using array_slice() because it makes a copy of the + // array, and avoid using array_splice() or array_shift() because they + // re-index numeric keys. + unset($this->cache[array_key_first($this->cache)]); + } + + parent::set($cid, $data, $expire, $tags); + } + + /** + * {@inheritdoc} + */ + public function invalidate($cid): void { + $this->invalidateMultiple([$cid]); + } + + /** + * {@inheritdoc} + */ + public function invalidateMultiple(array $cids): void { + $items = []; + foreach ($cids as $cid) { + if (isset($this->cache[$cid])) { + $items[$cid] = $this->cache[$cid]; + parent::invalidate($cid); + } + } + $this->moveItemsToLeastRecentlyUsed($items); + } + + /** + * {@inheritdoc} + */ + public function invalidateTags(array $tags): void { + $items = []; + foreach ($this->cache as $cid => $item) { + if (array_intersect($tags, $item->tags)) { + parent::invalidate($cid); + $items[$cid] = $this->cache[$cid]; + } + } + $this->moveItemsToLeastRecentlyUsed($items); + } + + /** + * Moves items to the least recently used positions. + * + * @param array $items + * An array of items to move to the least recently used positions. + */ + private function moveItemsToLeastRecentlyUsed(array $items): void { + // This cannot use array_unshift() because it would reindex an array with + // numeric cache IDs. + if (!empty($items)) { + $this->cache = $items + $this->cache; + } + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Cache/GenericCacheBackendUnitTestBase.php b/core/tests/Drupal/KernelTests/Core/Cache/GenericCacheBackendUnitTestBase.php index f30df8b054c79fd8694fd63990736f3596be63c9..4b6c8f9c95855627e477f050277f160e07755d51 100644 --- a/core/tests/Drupal/KernelTests/Core/Cache/GenericCacheBackendUnitTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Cache/GenericCacheBackendUnitTestBase.php @@ -41,6 +41,16 @@ abstract class GenericCacheBackendUnitTestBase extends KernelTestBase { */ protected $defaultValue; + /** + * Most cache backends ensure changes to objects do not affect the cache. + * + * Some caches explicitly allow this, for example, + * \Drupal\Core\Cache\MemoryCache\MemoryCache. + * + * @var bool + */ + protected bool $testObjectProperties = TRUE; + /** * Gets the testing bin. * @@ -208,13 +218,19 @@ public function testSetGet(): void { $data->this_should_not_be_in_the_cache = TRUE; $cached = $backend->get('test7'); $this->assertIsObject($cached); - $this->assertEquals($expected_data, $cached->data); - $this->assertFalse(isset($cached->data->this_should_not_be_in_the_cache)); - // Add a property to the cache data. It should not appear when we fetch - // the data from cache again. - $cached->data->this_should_not_be_in_the_cache = TRUE; - $fresh_cached = $backend->get('test7'); - $this->assertFalse(isset($fresh_cached->data->this_should_not_be_in_the_cache)); + if ($this->testObjectProperties) { + $this->assertEquals($expected_data, $cached->data); + $this->assertFalse(isset($cached->data->this_should_not_be_in_the_cache)); + + // Add a property to the cache data. It should not appear when we fetch + // the data from cache again. + $cached->data->this_should_not_be_in_the_cache = TRUE; + $fresh_cached = $backend->get('test7'); + $this->assertFalse(isset($fresh_cached->data->this_should_not_be_in_the_cache)); + } + else { + $this->assertSame($data, $cached->data); + } // Check with a long key. $cid = str_repeat('a', 300); diff --git a/core/tests/Drupal/KernelTests/Core/Cache/LruCacheGenericTest.php b/core/tests/Drupal/KernelTests/Core/Cache/LruCacheGenericTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7859592b25fd4bd79322bc37d645c74b68188f55 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Cache/LruCacheGenericTest.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Cache; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Cache\MemoryCache\LruMemoryCache; + +/** + * Unit test of the LRU memory cache using the generic cache unit test base. + * + * @group Cache + */ +class LruCacheGenericTest extends GenericCacheBackendUnitTestBase { + + protected bool $testObjectProperties = FALSE; + + /** + * Creates a new instance of LruMemoryCache. + * + * @return \Drupal\Core\Cache\CacheBackendInterface + * A new MemoryBackend object. + */ + protected function createCacheBackend($bin) { + $backend = new LruMemoryCache(\Drupal::service(TimeInterface::class)); + \Drupal::service('cache_tags.invalidator')->addInvalidator($backend); + return $backend; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Cache/MemoryCacheGenericTest.php b/core/tests/Drupal/KernelTests/Core/Cache/MemoryCacheGenericTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7d1a0ca29ec2fdc3c4798edc18c463ecd28e0697 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Cache/MemoryCacheGenericTest.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\KernelTests\Core\Cache; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Cache\MemoryCache\MemoryCache; + +/** + * Unit test of the memory cache using the generic cache unit test base. + * + * @group Cache + */ +class MemoryCacheGenericTest extends GenericCacheBackendUnitTestBase { + + /** + * {@inheritdoc} + */ + protected bool $testObjectProperties = FALSE; + + /** + * Creates a new instance of MemoryCache. + * + * @return \Drupal\Core\Cache\CacheBackendInterface + * A new MemoryBackend object. + */ + protected function createCacheBackend($bin) { + $backend = new MemoryCache(\Drupal::service(TimeInterface::class)); + \Drupal::service('cache_tags.invalidator')->addInvalidator($backend); + return $backend; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Cache/LruMemoryCacheTest.php b/core/tests/Drupal/Tests/Core/Cache/LruMemoryCacheTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4ea3b26b47944095db19ada57f466cb8abd5e34d --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Cache/LruMemoryCacheTest.php @@ -0,0 +1,396 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Cache; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Cache\MemoryCache\LruMemoryCache; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Cache\MemoryCache\LruMemoryCache + * @group Cache + */ +class LruMemoryCacheTest extends UnitTestCase { + + /** + * Tests getting, setting and deleting items from the LRU memory cache. + * + * @covers ::get + * @covers ::set + * @covers ::delete + * @covers ::getMultiple + */ + public function testGetSetDelete(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cache_data = [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]; + foreach ($cache_data as $items) { + $lru_cache->set($items[0], $items[1]); + } + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]); + + $lru_cache->set('cuckoo', 'cuckoo'); + $this->assertCacheData($lru_cache, [ + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ['cuckoo', 'cuckoo'], + ]); + + // Now bring pigeon to the most recently used spot. + $lru_cache->get('pigeon'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $this->assertCacheData($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ]); + + // Confirm that setting the same item multiple times only uses one slot. + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $this->assertCacheData($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ]); + + // Confirm that deleting the same item multiple times only frees up one + // slot. + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->delete('bigger_cuckoo'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $this->assertCacheData($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ]); + $lru_cache->set('crow', 'crow'); + + $this->assertCacheData($lru_cache, [ + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ['crow', 'crow'], + ]); + + // Ensure nothing changes on cache miss for ::get(). + $this->assertFalse($lru_cache->get('dodo')); + $this->assertCacheData($lru_cache, [ + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ['crow', 'crow'], + ]); + + // Ensure nothing changes on cache miss for ::getMultiple(). + $cids = ['dodo', 'great_auk']; + $this->assertEmpty($lru_cache->getMultiple($cids)); + $this->assertCacheData($lru_cache, [ + ['pigeon', 'pigeon'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ['crow', 'crow'], + ]); + $this->assertSame(['dodo', 'great_auk'], $cids); + + $cids = ['crow', 'pigeon']; + $lru_cache->getMultiple($cids); + // @todo This result suggests the order of the arguments in the + // \Drupal\Core\Cache\MemoryBackend::getMultiple() array_intersect_key() + // should be swapped as this order of the cache items returned should + // probably be in the same order as the passed in $cache_data. I.e. pigeon + // should be at the ends of the array and not crow. + $this->assertCacheData($lru_cache, [ + ['bigger_cuckoo', 'bigger_cuckoo'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]); + $this->assertEmpty($cids); + } + + /** + * Tests setting items with numeric keys in the LRU memory cache. + * + * @covers ::set + */ + public function testSetNumericKeys(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cache_data = [ + [4, 'sparrow'], + [10, 'pigeon'], + [7, 'crow'], + ]; + foreach ($cache_data as $item) { + $lru_cache->set($item[0], $item[1]); + } + $this->assertCacheData($lru_cache, $cache_data); + + $lru_cache->set(1, 'cuckoo'); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [7, 'crow'], + [1, 'cuckoo'], + ]); + + $lru_cache->set(7, 'crow'); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [1, 'cuckoo'], + [7, 'crow'], + ]); + } + + /** + * Tests setting multiple items in the LRU memory cache. + * + * @covers ::setMultiple + */ + public function testSetMultiple(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $lru_cache->setMultiple([ + 'sparrow' => ['data' => 'sparrow'], + 'pigeon' => ['data' => 'pigeon'], + 'crow' => ['data' => 'crow'], + ]); + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]); + + $lru_cache->setMultiple([ + 'sparrow' => ['data' => 'sparrow2'], + 'bluejay' => ['data' => 'bluejay'], + ]); + $this->assertCacheData($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow2'], + ['bluejay', 'bluejay'], + ]); + + $lru_cache->setMultiple([ + 3 => ['data' => 'pigeon'], + 2 => ['data' => 'eagle'], + 1 => ['data' => 'wren'], + ]); + $this->assertCacheData($lru_cache, [ + [3, 'pigeon'], + [2, 'eagle'], + [1, 'wren'], + ]); + + $lru_cache->setMultiple([ + 2 => ['data' => 'eagle2'], + 4 => ['data' => 'cuckoo'], + ]); + $this->assertCacheData($lru_cache, [ + [1, 'wren'], + [2, 'eagle2'], + [4, 'cuckoo'], + ]); + } + + /** + * Tests invalidation from the LRU memory cache. + * + * @covers ::invalidate + * @covers ::invalidateMultiple + * @covers ::invalidateTags + */ + public function testInvalidate(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cache_data = [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]; + foreach ($cache_data as $items) { + $lru_cache->set($items[0], $items[1]); + } + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]); + $lru_cache->invalidate('crow'); + $this->assertCacheData($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ]); + $this->assertFalse($lru_cache->get('crow')); + // Ensure that getting an invalid cache does not move it to the end of the + // array. + $this->assertSame('crow', $lru_cache->get('crow', TRUE)->data); + $this->assertCacheData($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ]); + $lru_cache->set('cuckoo', 'cuckoo', LruMemoryCache::CACHE_PERMANENT, ['cuckoo']); + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['cuckoo', 'cuckoo'], + ]); + $lru_cache->invalidateTags(['cuckoo']); + $this->assertFalse($lru_cache->get('cuckoo')); + $this->assertSame('cuckoo', $lru_cache->get('cuckoo', TRUE)->data); + $lru_cache->set('crow', 'crow'); + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ]); + + $lru_cache->invalidateMultiple(['pigeon', 'crow']); + $cids = ['pigeon', 'crow']; + $this->assertEmpty($lru_cache->getMultiple($cids)); + $this->assertSame(['pigeon', 'crow'], $cids); + $this->assertCount(2, $lru_cache->getMultiple($cids, TRUE)); + $this->assertSame([], $cids); + $this->assertCacheData($lru_cache, [ + ['pigeon', 'pigeon'], + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ]); + $lru_cache->set('duck', 'duck'); + $lru_cache->set('chicken', 'chicken'); + $this->assertCacheData($lru_cache, [ + ['sparrow', 'sparrow'], + ['duck', 'duck'], + ['chicken', 'chicken'], + ]); + } + + /** + * Tests invalidation with numeric keys from the LRU memory cache. + * + * @covers ::invalidate + * @covers ::invalidateMultiple + * @covers ::invalidateTags + */ + public function testInvalidateNumeric(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cache_data = [ + [3, 'sparrow'], + [10, 'pigeon'], + [5, 'crow'], + ]; + foreach ($cache_data as $items) { + $lru_cache->set($items[0], $items[1], tags: ['bird']); + } + $this->assertCacheData($lru_cache, [ + [3, 'sparrow'], + [10, 'pigeon'], + [5, 'crow'], + ]); + + // Invalidate something not in the cache and ensure nothing changes. + $lru_cache->invalidate(0); + $this->assertCacheData($lru_cache, [ + [3, 'sparrow'], + [10, 'pigeon'], + [5, 'crow'], + ]); + + $lru_cache->invalidate(10); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [3, 'sparrow'], + [5, 'crow'], + ]); + $this->assertFalse($lru_cache->get(10)); + $this->assertSame('pigeon', $lru_cache->get(10, TRUE)->data); + + $lru_cache->invalidateTags(['mammal']); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [3, 'sparrow'], + [5, 'crow'], + ]); + $this->assertSame('sparrow', $lru_cache->get(3)->data); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [5, 'crow'], + [3, 'sparrow'], + ]); + + $lru_cache->invalidateTags(['mammal', 'bird']); + $this->assertFalse($lru_cache->get(3)); + $this->assertFalse($lru_cache->get(10)); + $this->assertFalse($lru_cache->get(5)); + $this->assertCacheData($lru_cache, [ + [10, 'pigeon'], + [5, 'crow'], + [3, 'sparrow'], + ]); + } + + /** + * Asserts that the given cache data matches the data in the memory cache. + * + * @param \Drupal\Core\Cache\MemoryCache\LruMemoryCache $lru_cache + * The LRU cache under test. + * @param array $cache_data + * Array whose first element is the cache ID and whose second element is + * the value to check. This should contain all the keys in the cache and in + * the expected order. + */ + protected function assertCacheData(LruMemoryCache $lru_cache, array $cache_data): void { + // Use reflection to access data because using ::get() affects the LRU + // cache. + $reflectedClass = new \ReflectionClass($lru_cache); + $reflection = $reflectedClass->getProperty('cache'); + $cache = $reflection->getValue($lru_cache); + + $keys = []; + foreach ($cache_data as $item) { + $keys[] = $item[0]; + $this->assertSame($item[1], $cache[$item[0]]->data, "$item[0] found in cache."); + } + + // Ensure the cache only contains the supply keys and the order is as + // expected. + $this->assertSame($keys, array_keys($cache)); + } + + /** + * Creates a LRU cache for testing. + * + * @param int $slots + * The number of slots in the LRU cache. + * + * @return \Drupal\Core\Cache\MemoryCache\LruMemoryCache + * The LRU cache. + */ + private function getLruMemoryCache(int $slots): LruMemoryCache { + $time_mock = $this->createMock(TimeInterface::class); + $time_mock->expects($this->any()) + ->method('getRequestTime') + ->willReturnCallback('time'); + return new LruMemoryCache( + $time_mock, + $slots, + ); + } + +}