diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 63d32d9dfd14..d59a4ebe1533 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: - 33306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 redis: - image: redis:5.0 + image: redis:7.0 ports: - 6379:6379 options: --entrypoint redis-server diff --git a/docker-compose.yml b/docker-compose.yml index ef0a87950983..b3dee5dc9df3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - "3306:3306" restart: always redis: - image: redis:5.0-alpine + image: redis:7.0-alpine ports: - "6379:6379" restart: always diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index 4896c9183d03..146d23389cc2 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -244,7 +244,7 @@ public function flush() public function tags($names) { return new RedisTaggedCache( - $this, new TagSet($this, is_array($names) ? $names : func_get_args()) + $this, new RedisTagSet($this, is_array($names) ? $names : func_get_args()) ); } diff --git a/src/Illuminate/Cache/RedisTagSet.php b/src/Illuminate/Cache/RedisTagSet.php new file mode 100644 index 000000000000..175694daed04 --- /dev/null +++ b/src/Illuminate/Cache/RedisTagSet.php @@ -0,0 +1,124 @@ + 0 ? now()->addSeconds($ttl)->getTimestamp() : -1; + + foreach ($this->tagIds() as $tagKey) { + if ($updateWhen) { + $this->store->connection()->zadd($this->store->getPrefix().$tagKey, $updateWhen, $ttl, $key); + } else { + $this->store->connection()->zadd($this->store->getPrefix().$tagKey, $ttl, $key); + } + } + } + + /** + * Get all of the cache entry keys for the tag set. + * + * @return \Illuminate\Support\LazyCollection + */ + public function entries() + { + return LazyCollection::make(function () { + foreach ($this->tagIds() as $tagKey) { + $cursor = $defaultCursorValue = '0'; + + do { + [$cursor, $entries] = $this->store->connection()->zscan( + $this->store->getPrefix().$tagKey, + $cursor, + ['match' => '*', 'count' => 1000] + ); + + if (! is_array($entries)) { + break; + } + + $entries = array_unique(array_keys($entries)); + + if (count($entries) === 0) { + continue; + } + + foreach ($entries as $entry) { + yield $entry; + } + } while (((string) $cursor) !== $defaultCursorValue); + } + }); + } + + /** + * Remove the stale entries from the tag set. + * + * @return void + */ + public function flushStaleEntries() + { + $this->store->connection()->pipeline(function ($pipe) { + foreach ($this->tagIds() as $tagKey) { + $pipe->zremrangebyscore($this->store->getPrefix().$tagKey, 0, now()->getTimestamp()); + } + }); + } + + /** + * Flush the tag from the cache. + * + * @param string $name + */ + public function flushTag($name) + { + return $this->resetTag($name); + } + + /** + * Reset the tag and return the new tag identifier. + * + * @param string $name + * @return string + */ + public function resetTag($name) + { + $this->store->forget($this->tagKey($name)); + + return $this->tagId($name); + } + + /** + * Get the unique tag identifier for a given tag. + * + * @param string $name + * @return string + */ + public function tagId($name) + { + return "tag:{$name}:entries"; + } + + /** + * Get the tag identifier key for a given tag. + * + * @param string $name + * @return string + */ + public function tagKey($name) + { + return "tag:{$name}:entries"; + } +} diff --git a/src/Illuminate/Cache/RedisTaggedCache.php b/src/Illuminate/Cache/RedisTaggedCache.php index 7863dbc0a60a..b8120be95c03 100644 --- a/src/Illuminate/Cache/RedisTaggedCache.php +++ b/src/Illuminate/Cache/RedisTaggedCache.php @@ -5,18 +5,22 @@ class RedisTaggedCache extends TaggedCache { /** - * Forever reference key. + * Store an item in the cache if the key does not exist. * - * @var string + * @param string $key + * @param mixed $value + * @param \DateTimeInterface|\DateInterval|int|null $ttl + * @return bool */ - const REFERENCE_KEY_FOREVER = 'forever_ref'; + public function add($key, $value, $ttl = null) + { + $this->tags->addEntry( + $this->itemKey($key), + ! is_null($ttl) ? $this->getSeconds($ttl) : 0 + ); - /** - * Standard reference key. - * - * @var string - */ - const REFERENCE_KEY_STANDARD = 'standard_ref'; + return parent::add($key, $value, $ttl); + } /** * Store an item in the cache. @@ -28,11 +32,14 @@ class RedisTaggedCache extends TaggedCache */ public function put($key, $value, $ttl = null) { - if ($ttl === null) { + if (is_null($ttl)) { return $this->forever($key, $value); } - $this->pushStandardKeys($this->tags->getNamespace(), $key); + $this->tags->addEntry( + $this->itemKey($key), + $this->getSeconds($ttl) + ); return parent::put($key, $value, $ttl); } @@ -46,7 +53,7 @@ public function put($key, $value, $ttl = null) */ public function increment($key, $value = 1) { - $this->pushStandardKeys($this->tags->getNamespace(), $key); + $this->tags->addEntry($this->itemKey($key), updateWhen: 'NX'); return parent::increment($key, $value); } @@ -60,7 +67,7 @@ public function increment($key, $value = 1) */ public function decrement($key, $value = 1) { - $this->pushStandardKeys($this->tags->getNamespace(), $key); + $this->tags->addEntry($this->itemKey($key), updateWhen: 'NX'); return parent::decrement($key, $value); } @@ -74,7 +81,7 @@ public function decrement($key, $value = 1) */ public function forever($key, $value) { - $this->pushForeverKeys($this->tags->getNamespace(), $key); + $this->tags->addEntry($this->itemKey($key)); return parent::forever($key, $value); } @@ -86,129 +93,37 @@ public function forever($key, $value) */ public function flush() { - $this->deleteForeverKeys(); - $this->deleteStandardKeys(); - + $this->flushValues(); $this->tags->flush(); return true; } /** - * Store standard key references into store. - * - * @param string $namespace - * @param string $key - * @return void - */ - protected function pushStandardKeys($namespace, $key) - { - $this->pushKeys($namespace, $key, self::REFERENCE_KEY_STANDARD); - } - - /** - * Store forever key references into store. - * - * @param string $namespace - * @param string $key - * @return void - */ - protected function pushForeverKeys($namespace, $key) - { - $this->pushKeys($namespace, $key, self::REFERENCE_KEY_FOREVER); - } - - /** - * Store a reference to the cache key against the reference key. - * - * @param string $namespace - * @param string $key - * @param string $reference - * @return void - */ - protected function pushKeys($namespace, $key, $reference) - { - $fullKey = $this->store->getPrefix().sha1($namespace).':'.$key; - - foreach (explode('|', $namespace) as $segment) { - $this->store->connection()->sadd($this->referenceKey($segment, $reference), $fullKey); - } - } - - /** - * Delete all of the items that were stored forever. + * Flush the individual cache entries for the tags. * * @return void */ - protected function deleteForeverKeys() + protected function flushValues() { - $this->deleteKeysByReference(self::REFERENCE_KEY_FOREVER); - } - - /** - * Delete all standard items. - * - * @return void - */ - protected function deleteStandardKeys() - { - $this->deleteKeysByReference(self::REFERENCE_KEY_STANDARD); - } + $entries = $this->tags->entries() + ->map(fn (string $key) => $this->store->getPrefix().$key) + ->chunk(1000); - /** - * Find and delete all of the items that were stored against a reference. - * - * @param string $reference - * @return void - */ - protected function deleteKeysByReference($reference) - { - foreach (explode('|', $this->tags->getNamespace()) as $segment) { - $this->deleteValues($segment = $this->referenceKey($segment, $reference)); - - $this->store->connection()->del($segment); + foreach ($entries as $cacheKeys) { + $this->store->connection()->del(...$cacheKeys); } } /** - * Delete item keys that have been stored against a reference. + * Remove all stale reference entries from the tag set. * - * @param string $referenceKey - * @return void + * @return bool */ - protected function deleteValues($referenceKey) + public function flushStale() { - $cursor = $defaultCursorValue = '0'; - - do { - [$cursor, $valuesChunk] = $this->store->connection()->sscan( - $referenceKey, $cursor, ['match' => '*', 'count' => 1000] - ); - - // PhpRedis client returns false if set does not exist or empty. Array destruction - // on false stores null in each variable. If valuesChunk is null, it means that - // there were not results from the previously executed "sscan" Redis command. - if (is_null($valuesChunk)) { - break; - } - - $valuesChunk = array_unique($valuesChunk); - - if (count($valuesChunk) > 0) { - $this->store->connection()->del(...$valuesChunk); - } - } while (((string) $cursor) !== $defaultCursorValue); - } + $this->tags->flushStaleEntries(); - /** - * Get the reference key for the segment. - * - * @param string $segment - * @param string $suffix - * @return string - */ - protected function referenceKey($segment, $suffix) - { - return $this->store->getPrefix().$segment.':'.$suffix; + return true; } } diff --git a/tests/Cache/CacheTaggedCacheTest.php b/tests/Cache/CacheTaggedCacheTest.php index b2493694d136..1834bce56d4f 100644 --- a/tests/Cache/CacheTaggedCacheTest.php +++ b/tests/Cache/CacheTaggedCacheTest.php @@ -5,12 +5,8 @@ use DateInterval; use DateTime; use Illuminate\Cache\ArrayStore; -use Illuminate\Cache\RedisTaggedCache; -use Illuminate\Cache\TagSet; -use Illuminate\Contracts\Cache\Store; use Mockery as m; use PHPUnit\Framework\TestCase; -use stdClass; class CacheTaggedCacheTest extends TestCase { @@ -207,86 +203,6 @@ public function testTagsCacheForever() $this->assertSame('bar', $store->tags($tags)->get('foo')); } - public function testRedisCacheTagsPushForeverKeysCorrectly() - { - $store = m::mock(Store::class); - $tagSet = m::mock(TagSet::class, [$store, ['foo', 'bar']]); - $tagSet->shouldReceive('getNamespace')->andReturn('foo|bar'); - $tagSet->shouldReceive('getNames')->andReturn(['foo', 'bar']); - $redis = new RedisTaggedCache($store, $tagSet); - $store->shouldReceive('getPrefix')->andReturn('prefix:'); - $store->shouldReceive('connection')->andReturn($conn = m::mock(stdClass::class)); - $conn->shouldReceive('sadd')->once()->with('prefix:foo:forever_ref', 'prefix:'.sha1('foo|bar').':key1'); - $conn->shouldReceive('sadd')->once()->with('prefix:bar:forever_ref', 'prefix:'.sha1('foo|bar').':key1'); - - $store->shouldReceive('forever')->with(sha1('foo|bar').':key1', 'key1:value'); - - $redis->forever('key1', 'key1:value'); - } - - public function testRedisCacheTagsPushStandardKeysCorrectly() - { - $store = m::mock(Store::class); - $tagSet = m::mock(TagSet::class, [$store, ['foo', 'bar']]); - $tagSet->shouldReceive('getNamespace')->andReturn('foo|bar'); - $tagSet->shouldReceive('getNames')->andReturn(['foo', 'bar']); - $redis = new RedisTaggedCache($store, $tagSet); - $store->shouldReceive('getPrefix')->andReturn('prefix:'); - $store->shouldReceive('connection')->andReturn($conn = m::mock(stdClass::class)); - $conn->shouldReceive('sadd')->once()->with('prefix:foo:standard_ref', 'prefix:'.sha1('foo|bar').':key1'); - $conn->shouldReceive('sadd')->once()->with('prefix:bar:standard_ref', 'prefix:'.sha1('foo|bar').':key1'); - $store->shouldReceive('push')->with(sha1('foo|bar').':key1', 'key1:value'); - $store->shouldReceive('put')->andReturn(true); - - $redis->put('key1', 'key1:value', 60); - } - - public function testRedisCacheTagsPushForeverKeysCorrectlyWithNullTTL() - { - $store = m::mock(Store::class); - $tagSet = m::mock(TagSet::class, [$store, ['foo', 'bar']]); - $tagSet->shouldReceive('getNamespace')->andReturn('foo|bar'); - $tagSet->shouldReceive('getNames')->andReturn(['foo', 'bar']); - $redis = new RedisTaggedCache($store, $tagSet); - $store->shouldReceive('getPrefix')->andReturn('prefix:'); - $store->shouldReceive('connection')->andReturn($conn = m::mock(stdClass::class)); - $conn->shouldReceive('sadd')->once()->with('prefix:foo:forever_ref', 'prefix:'.sha1('foo|bar').':key1'); - $conn->shouldReceive('sadd')->once()->with('prefix:bar:forever_ref', 'prefix:'.sha1('foo|bar').':key1'); - $store->shouldReceive('forever')->with(sha1('foo|bar').':key1', 'key1:value'); - - $redis->put('key1', 'key1:value'); - } - - public function testRedisCacheTagsCanBeFlushed() - { - $store = m::mock(Store::class); - $tagSet = m::mock(TagSet::class, [$store, ['foo', 'bar']]); - $tagSet->shouldReceive('getNamespace')->andReturn('foo|bar'); - $redis = new RedisTaggedCache($store, $tagSet); - $store->shouldReceive('getPrefix')->andReturn('prefix:'); - $store->shouldReceive('connection')->andReturn($conn = m::mock(stdClass::class)); - - // Forever tag keys - $conn->shouldReceive('sscan')->once()->with('prefix:foo:forever_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key1', 'key2']]); - $conn->shouldReceive('sscan')->once()->with('prefix:bar:forever_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key3']]); - $conn->shouldReceive('del')->once()->with('key1', 'key2'); - $conn->shouldReceive('del')->once()->with('key3'); - $conn->shouldReceive('del')->once()->with('prefix:foo:forever_ref'); - $conn->shouldReceive('del')->once()->with('prefix:bar:forever_ref'); - - // Standard tag keys - $conn->shouldReceive('sscan')->once()->with('prefix:foo:standard_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key4', 'key5']]); - $conn->shouldReceive('sscan')->once()->with('prefix:bar:standard_ref', '0', ['match' => '*', 'count' => 1000])->andReturn(['0', ['key6']]); - $conn->shouldReceive('del')->once()->with('key4', 'key5'); - $conn->shouldReceive('del')->once()->with('key6'); - $conn->shouldReceive('del')->once()->with('prefix:foo:standard_ref'); - $conn->shouldReceive('del')->once()->with('prefix:bar:standard_ref'); - - $tagSet->shouldReceive('flush')->once(); - - $redis->flush(); - } - private function getTestCacheStoreWithTagValues(): ArrayStore { $store = new ArrayStore; diff --git a/tests/Integration/Cache/RedisStoreTest.php b/tests/Integration/Cache/RedisStoreTest.php index ad89de93afd2..dac63c17c7eb 100644 --- a/tests/Integration/Cache/RedisStoreTest.php +++ b/tests/Integration/Cache/RedisStoreTest.php @@ -45,4 +45,102 @@ public function testItCanStoreNan() $this->assertTrue($result); $this->assertNan(Cache::store('redis')->get('foo')); } + + public function testTagsCanBeAccessed() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['people', 'author'])->put('name', 'Sally', 5); + Cache::store('redis')->tags(['people', 'author'])->put('age', 30, 5); + + $this->assertEquals('Sally', Cache::store('redis')->tags(['people', 'author'])->get('name')); + $this->assertEquals(30, Cache::store('redis')->tags(['people', 'author'])->get('age')); + + Cache::store('redis')->tags(['people', 'author'])->flush(); + + $keyCount = Cache::store('redis')->connection()->keys('*'); + $this->assertEquals(0, count($keyCount)); + } + + public function testTagEntriesCanBeStoredForever() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['people', 'author'])->forever('name', 'Sally'); + Cache::store('redis')->tags(['people', 'author'])->forever('age', 30); + + $this->assertEquals('Sally', Cache::store('redis')->tags(['people', 'author'])->get('name')); + $this->assertEquals(30, Cache::store('redis')->tags(['people', 'author'])->get('age')); + + Cache::store('redis')->tags(['people', 'author'])->flush(); + + $keyCount = Cache::store('redis')->connection()->keys('*'); + $this->assertEquals(0, count($keyCount)); + } + + public function testTagEntriesCanBeIncremented() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['votes'])->put('person-1', 0, 5); + Cache::store('redis')->tags(['votes'])->increment('person-1'); + Cache::store('redis')->tags(['votes'])->increment('person-1'); + + $this->assertEquals(2, Cache::store('redis')->tags(['votes'])->get('person-1')); + + Cache::store('redis')->tags(['votes'])->decrement('person-1'); + Cache::store('redis')->tags(['votes'])->decrement('person-1'); + + $this->assertEquals(0, Cache::store('redis')->tags(['votes'])->get('person-1')); + } + + public function testIncrementedTagEntriesProperlyTurnStale() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['votes'])->add('person-1', 0, $seconds = 1); + Cache::store('redis')->tags(['votes'])->increment('person-1'); + Cache::store('redis')->tags(['votes'])->increment('person-1'); + + sleep(2); + + Cache::store('redis')->tags(['votes'])->flushStale(); + + $keyCount = Cache::store('redis')->connection()->keys('*'); + $this->assertEquals(0, count($keyCount)); + } + + public function testTagsCanBeFlushedBySingleKey() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['people', 'author'])->put('person-1', 'Sally', 5); + Cache::store('redis')->tags(['people', 'artist'])->put('person-2', 'John', 5); + + Cache::store('redis')->tags(['artist'])->flush(); + + $this->assertEquals('Sally', Cache::store('redis')->tags(['people', 'author'])->get('person-1')); + $this->assertNull(Cache::store('redis')->tags(['people', 'artist'])->get('person-2')); + + $keyCount = Cache::store('redis')->connection()->keys('*'); + $this->assertEquals(3, count($keyCount)); // Sets for people, authors, and actual entry for Sally + } + + public function testStaleEntriesCanBeFlushed() + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['people', 'author'])->put('person-1', 'Sally', 1); + Cache::store('redis')->tags(['people', 'artist'])->put('person-2', 'John', 1); + + sleep(2); + + // Add a non-stale entry to people... + Cache::store('redis')->tags(['people', 'author'])->put('person-3', 'Jennifer', 5); + + Cache::store('redis')->tags(['people'])->flushStale(); + + $keyCount = Cache::store('redis')->connection()->keys('*'); + $this->assertEquals(4, count($keyCount)); // Sets for people, authors, and artists + individual entry for Jennifer + } }