Skip to content

Commit feee964

Browse files
[Cache] Add hierachical tags based invalidation
1 parent 84b48de commit feee964

File tree

9 files changed

+369
-14
lines changed

9 files changed

+369
-14
lines changed

src/Symfony/Component/Cache/Adapter/AbstractAdapter.php

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,25 @@ function ($key, $value, $isHit) use ($defaultLifetime) {
4545
CacheItem::class
4646
);
4747
$this->mergeByLifetime = \Closure::bind(
48-
function ($deferred, $namespace, &$expiredIds) {
48+
function ($deferred, $namespace, &$expiredIds, &$tags) {
4949
$byLifetime = array();
5050
$now = time();
5151
$expiredIds = array();
52+
$tags = array();
5253

5354
foreach ($deferred as $key => $item) {
55+
$id = $namespace.$key;
56+
5457
if (null === $item->expiry) {
55-
$byLifetime[0][$namespace.$key] = $item->value;
58+
$byLifetime[0][$id] = $item->value;
5659
} elseif ($item->expiry > $now) {
57-
$byLifetime[$item->expiry - $now][$namespace.$key] = $item->value;
60+
$byLifetime[$item->expiry - $now][$id] = $item->value;
5861
} else {
59-
$expiredIds[] = $namespace.$key;
62+
$expiredIds[] = $id;
63+
continue;
64+
}
65+
foreach ($item->tags as $tag) {
66+
$tags[$namespace.'/'.$tag][$id] = $id;
6067
}
6168
}
6269

@@ -113,6 +120,22 @@ abstract protected function doDelete(array $ids);
113120
*/
114121
abstract protected function doSave(array $values, $lifetime);
115122

123+
/**
124+
* Adds hierarchical tags to a set of cache keys.
125+
*
126+
* @param array $tags The tags as keys, the set of corresponding identifiers as values.
127+
*
128+
* @return bool True if the tags were successfully stored, false otherwise.
129+
*/
130+
protected function doTag(array $tags)
131+
{
132+
if ($this instanceof TagInvalidationInterface) {
133+
throw new \LogicException(sprintf('Class "%s" must overwrite the AbstractAdapter::doTag() method to implement TagInvalidationInterface', get_class($this)));
134+
}
135+
136+
return false;
137+
}
138+
116139
/**
117140
* {@inheritdoc}
118141
*/
@@ -279,7 +302,7 @@ public function commit()
279302
{
280303
$ok = true;
281304
$byLifetime = $this->mergeByLifetime;
282-
$byLifetime = $byLifetime($this->deferred, $this->namespace, $expiredIds);
305+
$byLifetime = $byLifetime($this->deferred, $this->namespace, $expiredIds, $tags);
283306
$retry = $this->deferred = array();
284307

285308
if ($expiredIds) {
@@ -321,9 +344,24 @@ public function commit()
321344
$ok = false;
322345
$type = is_object($v) ? get_class($v) : gettype($v);
323346
CacheItem::log($this->logger, 'Failed to save key "{key}" ({type})', array('key' => substr($id, strlen($this->namespace)), 'type' => $type, 'exception' => $e instanceof \Exception ? $e : null));
347+
348+
foreach ($tags as $tag => $ids) {
349+
unset($tags[$tag][$id]);
350+
}
351+
}
352+
}
353+
354+
if ($tags) {
355+
try {
356+
$ok = $this->doTag($tags) && $ok;
357+
} catch (\Exception $e) {
358+
if (!$this instanceof TagInvalidationInterface) {
359+
throw $e;
360+
}
361+
$ok = false;
362+
CacheItem::log($this->logger, 'Failed to commit cache tags', array('exception' => $e));
324363
}
325364
}
326-
$this->deferred = array();
327365

328366
return $ok;
329367
}

src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111

1212
namespace Symfony\Component\Cache\Adapter;
1313

14+
use Symfony\Component\Cache\CacheItem;
1415
use Symfony\Component\Cache\Exception\InvalidArgumentException;
1516

1617
/**
1718
* @author Nicolas Grekas <p@tchwork.com>
1819
*/
19-
class FilesystemAdapter extends AbstractAdapter
20+
class FilesystemAdapter extends AbstractAdapter implements TagInvalidationInterface
2021
{
2122
private $directory;
2223

@@ -50,6 +51,22 @@ public function __construct($namespace = '', $defaultLifetime = 0, $directory =
5051
$this->directory = $dir;
5152
}
5253

54+
/**
55+
* {@inheritdoc}
56+
*/
57+
public function invalidate($tag)
58+
{
59+
$tag = '/'.CacheItem::normalizeTag($tag);
60+
$ok = true;
61+
62+
foreach ($this->getInvalidatedIds($tag) as $id) {
63+
$file = $this->getFile($id);
64+
$ok = (!file_exists($file) || @unlink($file) || !file_exists($file)) && $ok;
65+
}
66+
67+
return $ok;
68+
}
69+
5370
/**
5471
* {@inheritdoc}
5572
*/
@@ -154,4 +171,57 @@ private function getFile($id)
154171

155172
return $this->directory.$hash[0].DIRECTORY_SEPARATOR.$hash[1].DIRECTORY_SEPARATOR.substr($hash, 2, -2);
156173
}
174+
175+
/**
176+
* {@inheritdoc}
177+
*/
178+
protected function doTag(array $tags)
179+
{
180+
$ok = true;
181+
182+
foreach ($tags as $tag => $ids) {
183+
$file = $this->getFile($tag);
184+
$dir = dirname($file);
185+
if (!file_exists($dir)) {
186+
@mkdir($dir, 0777, true);
187+
}
188+
$h = fopen($this->getFile($tag), 'ab');
189+
190+
foreach ($ids as $id) {
191+
$ok = fwrite($h, rawurlencode($id)."\n") && $ok;
192+
}
193+
fclose($h);
194+
}
195+
$s = strpos($tag, '/');
196+
$r = strrpos($tag, '/');
197+
while ($r > $s) {
198+
$parent = substr($tag, 0, $r);
199+
$ok = file_put_contents($this->getFile($parent), ':'.rawurlencode($tag)."\n", FILE_APPEND) && $ok;
200+
$r = strrpos($tag = $parent, '/');
201+
}
202+
203+
return $ok;
204+
}
205+
206+
private function getInvalidatedIds($tag)
207+
{
208+
$file = $this->getFile($tag);
209+
210+
if ($h = @fopen($file, 'rb')) {
211+
while (false !== $id = fgets($h)) {
212+
$id = rawurldecode(substr($id, 0, -1));
213+
214+
if (':' === $id[0]) {
215+
foreach ($this->getInvalidatedIds(substr($id, 1)) as $id) {
216+
yield $id;
217+
}
218+
} else {
219+
yield $id;
220+
}
221+
}
222+
223+
fclose($h);
224+
@unlink($file);
225+
}
226+
}
157227
}

src/Symfony/Component/Cache/Adapter/RedisAdapter.php

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@
1111

1212
namespace Symfony\Component\Cache\Adapter;
1313

14+
use Symfony\Component\Cache\CacheItem;
1415
use Symfony\Component\Cache\Exception\InvalidArgumentException;
1516

1617
/**
1718
* @author Aurimas Niekis <aurimas@niekis.lt>
19+
* @author Nicolas Grekas <p@tchwork.com>
1820
*/
19-
class RedisAdapter extends AbstractAdapter
21+
class RedisAdapter extends AbstractAdapter implements TagInvalidationInterface
2022
{
2123
private $redis;
24+
private $namespace;
2225

2326
public function __construct(\Redis $redisConnection, $namespace = '', $defaultLifetime = 0)
2427
{
2528
$this->redis = $redisConnection;
29+
$this->namespace = $namespace;
2630

2731
if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) {
2832
throw new InvalidArgumentException(sprintf('RedisAdapter namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));
@@ -31,6 +35,20 @@ public function __construct(\Redis $redisConnection, $namespace = '', $defaultLi
3135
parent::__construct($namespace, $defaultLifetime);
3236
}
3337

38+
/**
39+
* {@inheritdoc}
40+
*/
41+
public function invalidate($tag)
42+
{
43+
$tag = $this->namespace.'/'.CacheItem::normalizeTag($tag);
44+
45+
foreach ($this->getInvalidatedIds($tag) as $ids) {
46+
$this->redis->del($ids);
47+
}
48+
49+
return true;
50+
}
51+
3452
/**
3553
* {@inheritdoc}
3654
*/
@@ -115,4 +133,46 @@ protected function doSave(array $values, $lifetime)
115133

116134
return $failed;
117135
}
136+
137+
/**
138+
* {@inheritdoc}
139+
*/
140+
protected function doTag(array $tags)
141+
{
142+
$pipe = $this->redis->multi(\Redis::PIPELINE);
143+
144+
foreach ($tags as $tag => $ids) {
145+
foreach ($ids as $id) {
146+
$pipe->sAdd($tag, $id);
147+
}
148+
}
149+
$s = strpos($tag, '/');
150+
$r = strrpos($tag, '/');
151+
while ($r > $s) {
152+
$parent = substr($tag, 0, $r);
153+
$pipe->sAdd($parent.':child', $tag);
154+
$r = strrpos($tag = $parent, '/');
155+
}
156+
157+
return $pipe->exec();
158+
}
159+
160+
private function getInvalidatedIds($tag)
161+
{
162+
$h = null;
163+
while (false !== $children = $this->redis->sScan($tag.':child', $h)) {
164+
foreach ($children as $child) {
165+
foreach ($this->getInvalidatedIds($child) as $ids) {
166+
yield $ids;
167+
}
168+
}
169+
}
170+
171+
$h = null;
172+
while (false !== $ids = $this->redis->sScan($tag, $h)) {
173+
yield $ids;
174+
};
175+
176+
yield $tag;
177+
}
118178
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Cache\Adapter;
13+
14+
use Psr\Cache\CacheItemPoolInterface;
15+
use Psr\Cache\InvalidArgumentException;
16+
17+
/**
18+
* Interface for invalidating cached items using tag hierarchies.
19+
*
20+
* @author Nicolas Grekas <p@tchwork.com>
21+
*/
22+
interface TagInvalidationInterface extends CacheItemPoolInterface
23+
{
24+
/**
25+
* Invalidates cached items using tag hierarchies.
26+
*
27+
* @param string $tag A tag hierarchy to invalidate.
28+
*
29+
* @return bool True on success.
30+
*
31+
* @throw InvalidArgumentException When $tag is not valid.
32+
*/
33+
public function invalidate($tag);
34+
}

src/Symfony/Component/Cache/CacheItem.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@
1111

1212
namespace Symfony\Component\Cache;
1313

14-
use Psr\Cache\CacheItemInterface;
1514
use Psr\Log\LoggerInterface;
1615
use Symfony\Component\Cache\Exception\InvalidArgumentException;
1716

1817
/**
1918
* @author Nicolas Grekas <p@tchwork.com>
2019
*/
21-
final class CacheItem implements CacheItemInterface
20+
final class CacheItem implements TaggedCacheItemInterface
2221
{
2322
/**
2423
* @internal
@@ -30,6 +29,7 @@ final class CacheItem implements CacheItemInterface
3029
private $isHit;
3130
private $expiry;
3231
private $defaultLifetime;
32+
private $tags = array();
3333

3434
/**
3535
* {@inheritdoc}
@@ -99,6 +99,44 @@ public function expiresAfter($time)
9999
return $this;
100100
}
101101

102+
/**
103+
* {@inheritdoc}
104+
*/
105+
public function tag($tag)
106+
{
107+
$tag = self::normalizeTag($tag);
108+
$this->tags[$tag] = $tag;
109+
110+
return $this;
111+
}
112+
113+
/**
114+
* Validates a cache tag.
115+
*
116+
* @param string $tag The tag to validate.
117+
*
118+
* @throws InvalidArgumentException When $tag is not valid.
119+
*/
120+
public static function normalizeTag($tag)
121+
{
122+
if (!is_string($tag)) {
123+
throw new InvalidArgumentException(sprintf('Cache tag must be string, "%s" given', is_object($tag) ? get_class($tag) : gettype($tag)));
124+
}
125+
$tag = trim($tag, '/');
126+
127+
if (!isset($tag[0])) {
128+
throw new InvalidArgumentException('Cache tag length must be greater than zero');
129+
}
130+
if (isset($tag[strcspn($tag, '{}()*\@:')])) {
131+
throw new InvalidArgumentException(sprintf('Cache tag "%s" contains reserved characters {}()*\@:', $tag));
132+
}
133+
if (false !== strpos($tag, '//')) {
134+
throw new InvalidArgumentException(sprintf('Cache tag "%s" contains double slashes', $tag));
135+
}
136+
137+
return $tag;
138+
}
139+
102140
/**
103141
* Validates a cache key according to PSR-6.
104142
*

0 commit comments

Comments
 (0)