Skip to content

Commit

Permalink
feature #44015 [Cache] Decrease the probability of invalidation loss …
Browse files Browse the repository at this point in the history
…on tag eviction (nicolas-grekas)

This PR was merged into the 5.4 branch.

Discussion
----------

[Cache] Decrease the probability of invalidation loss on tag eviction

| Q             | A
| ------------- | ---
| Branch?       | 5.4
| Bug fix?      | no
| New feature?  | no
| Deprecations? | no
| Tickets       | Fix #43301
| License       | MIT
| Doc PR        | -

From `@sbelyshkin` in the linked PR:

> When using TagAwareAdapter with volatile caches such as Memcached and Redis with 'alkeys-lru' eviction policy, eviction of _any_ key may happen.
>
> Currently, tag-based invalidation is achieved by incrementing tag version numbers starting from 0. The 0th version is assumed by default and is not even stored in the cache, only non-zero versions are stored. Because of that, when a tag version key is evicted from cache, version counter is "reset" to 0, and if there are items still stored with tag version 0 (had not been accessed/updated by the moment of tag eviction), such items become valid again while must be deemed invalidated. Similarly invalidation may be lost when tag is invalidated particular number of times after its eviction (for items with non-zero tag version).
>
> This scenario is more likely for item-tag pairs which are accessed and invalidated less frequently than others (due to LRU policy and lower version numbers) but this measure is quite relative. LRU caches implement different algorithms (e.g. https://redis.io/topics/lru-cache, https://memcached.org/blog/modern-lru/) and free to evict any key of their choice so even a tag which is accessed or invalidated pretty frequently can be evicted when LRU cache is full and under havy loads.
>
> In order to prevent invalidation losses, any non-repetative numbers can be used as initial value for tag versions.

This PR goes one step further and borrows from #42997: instead of incrementing the version number on invalidation, we delete the value from the backend, and recreate it when needed with a random offset.

This PR also removes auto-commit on tag-invalidation: this behavior adds complexity, is not needed and is not consistent with Redis|FilesystemTagAwareAdapter.

/cc `@sbelyshkin` can you please give it a go in a review?

Commits
-------

4cfc727 [Cache] Decrease the probability of invalidation loss on tag eviction
  • Loading branch information
fabpot committed Nov 16, 2021
2 parents f7d70f1 + 4cfc727 commit 814dd27
Show file tree
Hide file tree
Showing 2 changed files with 55 additions and 78 deletions.
113 changes: 54 additions & 59 deletions src/Symfony/Component/Cache/Adapter/TagAwareAdapter.php
Expand Up @@ -41,7 +41,7 @@ class TagAwareAdapter implements TagAwareAdapterInterface, TagAwareCacheInterfac
private static $createCacheItem;
private static $setCacheItemTags;
private static $getTagsByKey;
private static $invalidateTags;
private static $saveTags;

public function __construct(AdapterInterface $itemsPool, AdapterInterface $tagsPool = null, float $knownTagVersionsTtl = 0.15)
{
Expand Down Expand Up @@ -95,8 +95,10 @@ static function ($deferred) {
null,
CacheItem::class
);
self::$invalidateTags ?? self::$invalidateTags = \Closure::bind(
self::$saveTags ?? self::$saveTags = \Closure::bind(
static function (AdapterInterface $tagsAdapter, array $tags) {
ksort($tags);

foreach ($tags as $v) {
$v->expiry = 0;
$tagsAdapter->saveDeferred($v);
Expand All @@ -114,40 +116,14 @@ static function (AdapterInterface $tagsAdapter, array $tags) {
*/
public function invalidateTags(array $tags)
{
$ok = true;
$tagsByKey = [];
$invalidatedTags = [];
$ids = [];
foreach ($tags as $tag) {
\assert('' !== CacheItem::validateKey($tag));
$invalidatedTags[$tag] = 0;
}

if ($this->deferred) {
$items = $this->deferred;
foreach ($items as $key => $item) {
if (!$this->pool->saveDeferred($item)) {
unset($this->deferred[$key]);
$ok = false;
}
}

$tagsByKey = (self::$getTagsByKey)($items);
$this->deferred = [];
}

$tagVersions = $this->getTagVersions($tagsByKey, $invalidatedTags);
$f = self::$createCacheItem;

foreach ($tagsByKey as $key => $tags) {
$this->pool->saveDeferred($f(static::TAGS_PREFIX.$key, array_intersect_key($tagVersions, $tags), $items[$key]));
}
$ok = $this->pool->commit() && $ok;

if ($invalidatedTags) {
$ok = (self::$invalidateTags)($this->tags, $invalidatedTags) && $ok;
unset($this->knownTagVersions[$tag]);
$ids[] = $tag.static::TAGS_PREFIX;
}

return $ok;
return !$tags || $this->tags->deleteItems($ids);
}

/**
Expand Down Expand Up @@ -176,7 +152,7 @@ public function hasItem($key)
}

foreach ($this->getTagVersions([$itemTags]) as $tag => $version) {
if ($itemTags[$tag] !== $version && 1 !== $itemTags[$tag] - $version) {
if ($itemTags[$tag] !== $version) {
return false;
}
}
Expand Down Expand Up @@ -314,7 +290,30 @@ public function saveDeferred(CacheItemInterface $item)
*/
public function commit()
{
return $this->invalidateTags([]);
if (!$this->deferred) {
return true;
}

$ok = true;
foreach ($this->deferred as $key => $item) {
if (!$this->pool->saveDeferred($item)) {
unset($this->deferred[$key]);
$ok = false;
}
}

$items = $this->deferred;
$tagsByKey = (self::$getTagsByKey)($items);
$this->deferred = [];

$tagVersions = $this->getTagVersions($tagsByKey);
$f = self::$createCacheItem;

foreach ($tagsByKey as $key => $tags) {
$this->pool->saveDeferred($f(static::TAGS_PREFIX.$key, array_intersect_key($tagVersions, $tags), $items[$key]));
}

return $this->pool->commit() && $ok;
}

/**
Expand Down Expand Up @@ -361,7 +360,7 @@ private function generateItems(iterable $items, array $tagKeys): \Generator

foreach ($itemTags as $key => $tags) {
foreach ($tags as $tag => $version) {
if ($tagVersions[$tag] !== $version && 1 !== $version - $tagVersions[$tag]) {
if ($tagVersions[$tag] !== $version) {
unset($itemTags[$key]);
continue 2;
}
Expand All @@ -377,57 +376,53 @@ private function generateItems(iterable $items, array $tagKeys): \Generator
}
}

private function getTagVersions(array $tagsByKey, array &$invalidatedTags = [])
private function getTagVersions(array $tagsByKey)
{
$tagVersions = $invalidatedTags;
$tagVersions = [];
$fetchTagVersions = false;

foreach ($tagsByKey as $tags) {
$tagVersions += $tags;

foreach ($tags as $tag => $version) {
if ($tagVersions[$tag] !== $version) {
unset($this->knownTagVersions[$tag]);
}
}
}

if (!$tagVersions) {
return [];
}

if (!$fetchTagVersions = 1 !== \func_num_args()) {
foreach ($tagsByKey as $tags) {
foreach ($tags as $tag => $version) {
if ($tagVersions[$tag] > $version) {
$tagVersions[$tag] = $version;
}
}
}
}

$now = microtime(true);
$tags = [];
foreach ($tagVersions as $tag => $version) {
$tags[$tag.static::TAGS_PREFIX] = $tag;
if ($fetchTagVersions || !isset($this->knownTagVersions[$tag])) {
if ($fetchTagVersions || ($this->knownTagVersions[$tag][1] ?? null) !== $version || $now - $this->knownTagVersions[$tag][0] >= $this->knownTagVersionsTtl) {
// reuse previously fetched tag versions up to the ttl
$fetchTagVersions = true;
continue;
}
$version -= $this->knownTagVersions[$tag][1];
if ((0 !== $version && 1 !== $version) || $now - $this->knownTagVersions[$tag][0] >= $this->knownTagVersionsTtl) {
// reuse previously fetched tag versions up to the ttl, unless we are storing items or a potential miss arises
$fetchTagVersions = true;
} else {
$this->knownTagVersions[$tag][1] += $version;
}
}

if (!$fetchTagVersions) {
return $tagVersions;
}

$newTags = [];
$newVersion = null;
foreach ($this->tags->getItems(array_keys($tags)) as $tag => $version) {
$tagVersions[$tag = $tags[$tag]] = $version->get() ?: 0;
if (isset($invalidatedTags[$tag])) {
$invalidatedTags[$tag] = $version->set(++$tagVersions[$tag]);
if (!$version->isHit()) {
$newTags[$tag] = $version->set($newVersion ?? $newVersion = random_int(\PHP_INT_MIN, \PHP_INT_MAX));
}
$tagVersions[$tag = $tags[$tag]] = $version->get();
$this->knownTagVersions[$tag] = [$now, $tagVersions[$tag]];
}

if ($newTags) {
(self::$saveTags)($this->tags, $newTags);
}

return $tagVersions;
}
}
Expand Up @@ -40,25 +40,6 @@ public static function tearDownAfterClass(): void
(new Filesystem())->remove(sys_get_temp_dir().'/symfony-cache');
}

/**
* Test feature specific to TagAwareAdapter as it implicit needs to save deferred when also saving expiry info.
*/
public function testInvalidateCommitsSeperatePools()
{
$pool1 = $this->createCachePool();

$foo = $pool1->getItem('foo');
$foo->tag('tag');

$pool1->saveDeferred($foo->set('foo'));
$pool1->invalidateTags(['tag']);

$pool2 = $this->createCachePool();
$foo = $pool2->getItem('foo');

$this->assertTrue($foo->isHit());
}

public function testPrune()
{
$cache = new TagAwareAdapter($this->getPruneableMock());
Expand All @@ -84,6 +65,7 @@ public function testKnownTagVersionsTtl()

$tag = $this->createMock(CacheItemInterface::class);
$tag->expects(self::exactly(2))->method('get')->willReturn(10);
$tag->expects(self::exactly(2))->method('set')->willReturn($tag);

$tagsPool->expects(self::exactly(2))->method('getItems')->willReturn([
'baz'.TagAwareAdapter::TAGS_PREFIX => $tag,
Expand Down

0 comments on commit 814dd27

Please sign in to comment.