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..1973015eed96c24f1ad924c725142d8b8173da92 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/MemoryCache/LruMemoryCache.php @@ -0,0 +1,134 @@ +<?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)) { + if ($cached->valid) { + // Move valid items to the end of the array, so they will be removed + // last. + unset($this->cache[$cid]); + $this->cache[$cid] = $cached; + } + } + return $cached; + } + + /** + * {@inheritdoc} + */ + public function getMultiple(&$cids, $allow_invalid = FALSE) { + $ret = parent::getMultiple($cids, $allow_invalid); + foreach ($ret as $cid => $cached) { + if ($cached->valid) { + // Move valid items to the end of the array, so they will be removed + // last. + unset($this->cache[$cid]); + $this->cache[$cid] = $cached; + } + } + return $ret; + } + + /** + * {@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 { + parent::invalidate($cid); + // Move the item to the least recently used position if it's not already + // there. This cannot use array_unshift() because it would reindex an array + // with numeric cache IDs. + if (isset($this->cache[$cid]) && $cid !== array_key_first($this->cache)) { + $this->cache = [$cid => $this->cache[$cid]] + $this->cache; + } + } + + /** + * {@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); + } + } + // Move the items to the least recently used positions. This cannot use + // array_unshift() because it would reindex an array with numeric cache IDs. + if (!empty($items)) { + $this->cache = $items + $this->cache; + } + } + + /** + * {@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]; + } + } + // Move the items to the least recently used positions. 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/Tests/Core/Cache/LruMemoryCacheTest.php b/core/tests/Drupal/Tests/Core/Cache/LruMemoryCacheTest.php new file mode 100644 index 0000000000000000000000000000000000000000..01d8aadb40034b31d6d499cd162ad69be6426d1c --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Cache/LruMemoryCacheTest.php @@ -0,0 +1,311 @@ +<?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); + + $cids = [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]; + foreach ($cids as $items) { + $lru_cache->set($items[0], $items[1]); + } + $this->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]); + + $lru_cache->set('cuckoo', 'cuckoo'); + $this->assertCids($lru_cache, [ + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ['cuckoo', 'cuckoo'], + ]); + + // Now bring pidgin to the most recently used spot. + $lru_cache->get('pidgin'); + $lru_cache->set('bigger_cuckoo', 'bigger_cuckoo'); + $this->assertCids($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pidgin', 'pidgin'], + ['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->assertCids($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pidgin', 'pidgin'], + ['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->assertCids($lru_cache, [ + ['cuckoo', 'cuckoo'], + ['pidgin', 'pidgin'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ]); + $lru_cache->set('crow', 'crow'); + + $this->assertCids($lru_cache, [ + ['pidgin', 'pidgin'], + ['bigger_cuckoo', 'bigger_cuckoo'], + ['crow', 'crow'], + ]); + + $cids = ['crow', 'pidgin']; + $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 $cids. I.e. pidgin + // should be at the ends of the array and not crow. + $this->assertCids($lru_cache, [ + ['bigger_cuckoo', 'bigger_cuckoo'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]); + } + + /** + * Tests setting items with numeric keys in the LRU memory cache. + * + * @covers ::set + */ + public function testSetNumericKeys(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cids = [ + [4, 'sparrow'], + [10, 'pidgin'], + [7, 'crow'], + ]; + foreach ($cids as $item) { + $lru_cache->set($item[0], $item[1]); + } + $this->assertCids($lru_cache, $cids); + + $lru_cache->set(1, 'cuckoo'); + $this->assertCids($lru_cache, [ + [10, 'pidgin'], + [7, 'crow'], + [1, 'cuckoo'], + ]); + + $lru_cache->set(7, 'crow'); + $this->assertCids($lru_cache, [ + [10, 'pidgin'], + [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'], + 'pidgin' => ['data' => 'pidgin'], + 'crow' => ['data' => 'crow'], + ]); + $this->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]); + + $lru_cache->setMultiple([ + 'sparrow' => ['data' => 'sparrow2'], + 'bluejay' => ['data' => 'bluejay'], + ]); + $this->assertCids($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow2'], + ['bluejay', 'bluejay'], + ]); + + $lru_cache->setMultiple([ + 3 => ['data' => 'pidgin'], + 2 => ['data' => 'eagle'], + 1 => ['data' => 'wren'], + ]); + $this->assertCids($lru_cache, [ + [3, 'pidgin'], + [2, 'eagle'], + [1, 'wren'], + ]); + + $lru_cache->setMultiple([ + 2 => ['data' => 'eagle2'], + 4 => ['data' => 'cuckoo'], + ]); + $this->assertCids($lru_cache, [ + [1, 'wren'], + [2, 'eagle2'], + [4, 'cuckoo'], + ]); + } + + /** + * Tests invalidation from the LRU memory cache. + * + * @covers ::invalidate + * @covers ::invalidateMultiple + */ + public function testInvalidate(): void { + $lru_cache = $this->getLruMemoryCache(3); + + $cids = [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]; + foreach ($cids as $items) { + $lru_cache->set($items[0], $items[1]); + } + $this->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]); + $lru_cache->invalidate('crow'); + $this->assertCids($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ]); + $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->assertCids($lru_cache, [ + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ]); + $lru_cache->set('cuckoo', 'cuckoo', LruMemoryCache::CACHE_PERMANENT, ['cuckoo']); + $this->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['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->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ]); + + $lru_cache->invalidateMultiple(['pidgin', 'crow']); + $cids = ['pidgin', 'crow']; + $this->assertEmpty($lru_cache->getMultiple($cids)); + $this->assertSame(['pidgin', 'crow'], $cids); + $this->assertCount(2, $lru_cache->getMultiple($cids, TRUE)); + $this->assertSame([], $cids); + $this->assertCids($lru_cache, [ + ['pidgin', 'pidgin'], + ['crow', 'crow'], + ['sparrow', 'sparrow'], + ]); + $lru_cache->set('duck', 'duck'); + $lru_cache->set('chicken', 'chicken'); + $this->assertCids($lru_cache, [ + ['sparrow', 'sparrow'], + ['duck', 'duck'], + ['chicken', 'chicken'], + ]); + } + + /** + * Assert that the given cache ID's match the given value in the memory cache. + * + * @param \Drupal\Core\Cache\MemoryCache\LruMemoryCache $lru_cache + * The LRU cache under test. + * @param array $cids + * 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 assertCids(LruMemoryCache $lru_cache, array $cids): 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 ($cids 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, + ); + } + +}