diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 2067942665..a06afaae0a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -20,6 +20,8 @@ CHANGELOG `Symfony\Component\Translation\DependencyInjection\TranslatorPass` instead * Added `command` attribute to the `console.command` tag which takes the command name as value, using it makes the command lazy + * Added `cache:pool:prune` command to allow manual stale cache item pruning of supported PSR-6 and PSR-16 cache pool + implementations 3.3.0 ----- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php new file mode 100644 index 0000000000..741979bd4f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolPruneCommand.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Cache pool pruner command. + * + * @author Rob Frawley 2nd + */ +final class CachePoolPruneCommand extends Command +{ + private $pools; + + /** + * @param iterable|PruneableInterface[] $pools + */ + public function __construct($pools) + { + parent::__construct(); + + $this->pools = $pools; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('cache:pool:prune') + ->setDescription('Prune cache pools') + ->setHelp(<<<'EOF' +The %command.name% command deletes all expired items from all pruneable pools. + + %command.full_name% +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + foreach ($this->pools as $name => $pool) { + $io->comment(sprintf('Pruning cache pool: %s', $name)); + $pool->prune(); + } + + $io->success('Successfully pruned cache pool(s).'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/CachePoolPrunerPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/CachePoolPrunerPass.php new file mode 100644 index 0000000000..cd79f58ce8 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/CachePoolPrunerPass.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler; + +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @author Rob Frawley 2nd + */ +class CachePoolPrunerPass implements CompilerPassInterface +{ + private $cacheCommandServiceId; + private $cachePoolTag; + + public function __construct($cacheCommandServiceId = 'cache.command.pool_pruner', $cachePoolTag = 'cache.pool') + { + $this->cacheCommandServiceId = $cacheCommandServiceId; + $this->cachePoolTag = $cachePoolTag; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition($this->cacheCommandServiceId)) { + return; + } + + $services = array(); + + foreach ($container->findTaggedServiceIds($this->cachePoolTag) as $id => $tags) { + $class = $container->getParameterBag()->resolveValue($container->getDefinition($id)->getClass()); + + if (!$reflection = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + + if ($reflection->implementsInterface(PruneableInterface::class)) { + $services[$id] = new Reference($id); + } + } + + $container->getDefinition($this->cacheCommandServiceId)->replaceArgument(0, new IteratorArgument($services)); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index f26a78a53d..63017892a7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\AddDebugLogProce use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CacheCollectorPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CachePoolPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CachePoolClearerPass; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CachePoolPrunerPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\DataCollectorTranslatorPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\TemplatingPass; use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\ProfilerPass; @@ -108,6 +109,7 @@ class FrameworkBundle extends Bundle $container->addCompilerPass(new CachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 32); $this->addCompilerPassIfExists($container, ValidateWorkflowsPass::class); $container->addCompilerPass(new CachePoolClearerPass(), PassConfig::TYPE_AFTER_REMOVING); + $container->addCompilerPass(new CachePoolPrunerPass(), PassConfig::TYPE_AFTER_REMOVING); $this->addCompilerPassIfExists($container, FormPass::class); if ($container->getParameter('kernel.debug')) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.xml index e0d5788bc4..182d07e6b3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.xml @@ -100,6 +100,11 @@ + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePruneCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePruneCommandTest.php new file mode 100644 index 0000000000..3477c650f7 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePruneCommandTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Command; + +use Symfony\Bundle\FrameworkBundle\Command\CachePoolPruneCommand; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\Argument\RewindableGenerator; +use Symfony\Component\HttpKernel\KernelInterface; + +class CachePruneCommandTest extends TestCase +{ + public function testCommandWithPools() + { + $tester = $this->getCommandTester($this->getKernel(), $this->getRewindableGenerator()); + $tester->execute(array()); + } + + public function testCommandWithNoPools() + { + $tester = $this->getCommandTester($this->getKernel(), $this->getEmptyRewindableGenerator()); + $tester->execute(array()); + } + + /** + * @return RewindableGenerator + */ + private function getRewindableGenerator() + { + return new RewindableGenerator(function () { + yield 'foo_pool' => $this->getPruneableInterfaceMock(); + yield 'bar_pool' => $this->getPruneableInterfaceMock(); + }, 2); + } + + /** + * @return RewindableGenerator + */ + private function getEmptyRewindableGenerator() + { + return new RewindableGenerator(function () { + return new \ArrayIterator(array()); + }, 0); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|KernelInterface + */ + private function getKernel() + { + $container = $this + ->getMockBuilder('Symfony\Component\DependencyInjection\ContainerInterface') + ->getMock(); + + $kernel = $this + ->getMockBuilder(KernelInterface::class) + ->getMock(); + + $kernel + ->expects($this->any()) + ->method('getContainer') + ->willReturn($container); + + $kernel + ->expects($this->once()) + ->method('getBundles') + ->willReturn(array()); + + return $kernel; + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|PruneableInterface + */ + private function getPruneableInterfaceMock() + { + $pruneable = $this + ->getMockBuilder(PruneableInterface::class) + ->getMock(); + + $pruneable + ->expects($this->atLeastOnce()) + ->method('prune'); + + return $pruneable; + } + + /** + * @param KernelInterface $kernel + * @param RewindableGenerator $generator + * + * @return CommandTester + */ + private function getCommandTester(KernelInterface $kernel, RewindableGenerator $generator) + { + $application = new Application($kernel); + $application->add(new CachePoolPruneCommand($generator)); + + return new CommandTester($application->find('cache:pool:prune')); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/CachePoolPrunerPassTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/CachePoolPrunerPassTest.php new file mode 100644 index 0000000000..51dba222a3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Compiler/CachePoolPrunerPassTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\DependencyInjection\Compiler\CachePoolPrunerPass; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +class CachePoolPrunerPassTest extends TestCase +{ + public function testCompilerPassReplacesCommandArgument() + { + $container = new ContainerBuilder(); + $container->register('cache.command.pool_pruner')->addArgument(array()); + $container->register('pool.foo', FilesystemAdapter::class)->addTag('cache.pool'); + $container->register('pool.bar', PhpFilesAdapter::class)->addTag('cache.pool'); + + $pass = new CachePoolPrunerPass(); + $pass->process($container); + + $expected = array( + 'pool.foo' => new Reference('pool.foo'), + 'pool.bar' => new Reference('pool.bar'), + ); + $argument = $container->getDefinition('cache.command.pool_pruner')->getArgument(0); + + $this->assertInstanceOf(IteratorArgument::class, $argument); + $this->assertEquals($expected, $argument->getValues()); + } + + public function testCompilePassIsIgnoredIfCommandDoesNotExist() + { + $container = $this + ->getMockBuilder(ContainerBuilder::class) + ->setMethods(array('hasDefinition', 'getDefinition', 'findTaggedServiceIds')) + ->getMock(); + + $container + ->expects($this->atLeastOnce()) + ->method('hasDefinition') + ->with('cache.command.pool_pruner') + ->will($this->returnValue(false)); + + $container + ->expects($this->never()) + ->method('getDefinition'); + + $container + ->expects($this->never()) + ->method('findTaggedServiceIds'); + + $pass = new CachePoolPrunerPass(); + $pass->process($container); + } + + /** + * @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException + * @expectedExceptionMessage Class "Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\Compiler\NotFound" used for service "pool.not-found" cannot be found. + */ + public function testCompilerPassThrowsOnInvalidDefinitionClass() + { + $container = new ContainerBuilder(); + $container->register('cache.command.pool_pruner')->addArgument(array()); + $container->register('pool.not-found', NotFound::class)->addTag('cache.pool'); + + $pass = new CachePoolPrunerPass(); + $pass->process($container); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 955835a040..e544cef349 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=5.5.9", "ext-xml": "*", - "symfony/cache": "~3.3|~4.0", + "symfony/cache": "~3.4|~4.0", "symfony/class-loader": "~3.2", "symfony/dependency-injection": "~3.3|~4.0", "symfony/config": "~3.3|~4.0", diff --git a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php index a6bb335bbd..d071964ec2 100644 --- a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php @@ -11,9 +11,10 @@ namespace Symfony\Component\Cache\Adapter; +use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\FilesystemTrait; -class FilesystemAdapter extends AbstractAdapter +class FilesystemAdapter extends AbstractAdapter implements PruneableInterface { use FilesystemTrait; diff --git a/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php b/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php index fee500dbfc..3ce1ac7d13 100644 --- a/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php @@ -12,9 +12,10 @@ namespace Symfony\Component\Cache\Adapter; use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\PhpFilesTrait; -class PhpFilesAdapter extends AbstractAdapter +class PhpFilesAdapter extends AbstractAdapter implements PruneableInterface { use PhpFilesTrait; diff --git a/src/Symfony/Component/Cache/CHANGELOG.md b/src/Symfony/Component/Cache/CHANGELOG.md index 9a4a31dd83..e8172d9498 100644 --- a/src/Symfony/Component/Cache/CHANGELOG.md +++ b/src/Symfony/Component/Cache/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG ========= +3.4.0 +----- + + * added PruneableInterface so PSR-6 or PSR-16 cache implementations can declare support for manual stale cache pruning + * added FilesystemTrait::prune() and PhpFilesTrait::prune() implementations + * now FilesystemAdapter, PhpFilesAdapter, FilesystemCache, and PhpFilesCache implement PruneableInterface and support + manual stale cache pruning + 3.3.0 ----- diff --git a/src/Symfony/Component/Cache/PruneableInterface.php b/src/Symfony/Component/Cache/PruneableInterface.php new file mode 100644 index 0000000000..cd366adb55 --- /dev/null +++ b/src/Symfony/Component/Cache/PruneableInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache; + +/** + * Interface for adapters and simple cache implementations that allow pruning expired items. + */ +interface PruneableInterface +{ + /** + * @return bool + */ + public function prune(); +} diff --git a/src/Symfony/Component/Cache/Simple/FilesystemCache.php b/src/Symfony/Component/Cache/Simple/FilesystemCache.php index dac9ade9ca..ccd5795342 100644 --- a/src/Symfony/Component/Cache/Simple/FilesystemCache.php +++ b/src/Symfony/Component/Cache/Simple/FilesystemCache.php @@ -11,9 +11,10 @@ namespace Symfony\Component\Cache\Simple; +use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\FilesystemTrait; -class FilesystemCache extends AbstractCache +class FilesystemCache extends AbstractCache implements PruneableInterface { use FilesystemTrait; diff --git a/src/Symfony/Component/Cache/Simple/PhpFilesCache.php b/src/Symfony/Component/Cache/Simple/PhpFilesCache.php index 810b80f812..38a9fe3e5f 100644 --- a/src/Symfony/Component/Cache/Simple/PhpFilesCache.php +++ b/src/Symfony/Component/Cache/Simple/PhpFilesCache.php @@ -12,9 +12,10 @@ namespace Symfony\Component\Cache\Simple; use Symfony\Component\Cache\Exception\CacheException; +use Symfony\Component\Cache\PruneableInterface; use Symfony\Component\Cache\Traits\PhpFilesTrait; -class PhpFilesCache extends AbstractCache +class PhpFilesCache extends AbstractCache implements PruneableInterface { use PhpFilesTrait; diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php index c3cbd3bef7..6ba02b0d15 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/AdapterTestCase.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; use Cache\IntegrationTests\CachePoolTest; +use Symfony\Component\Cache\PruneableInterface; abstract class AdapterTestCase extends CachePoolTest { @@ -22,6 +23,10 @@ abstract class AdapterTestCase extends CachePoolTest if (!array_key_exists('testDeferredSaveWithoutCommit', $this->skippedTests) && defined('HHVM_VERSION')) { $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Destructors are called late on HHVM.'; } + + if (!array_key_exists('testPrune', $this->skippedTests) && !$this->createCachePool() instanceof PruneableInterface) { + $this->skippedTests['testPrune'] = 'Not a pruneable cache pool.'; + } } public function testDefaultLifeTime() @@ -67,6 +72,59 @@ abstract class AdapterTestCase extends CachePoolTest } $this->assertFalse($item->isHit()); } + + public function testPrune() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + if (!method_exists($this, 'isPruned')) { + $this->fail('Test classes for pruneable caches must implement `isPruned($cache, $name)` method.'); + } + + $cache = $this->createCachePool(); + + $doSet = function ($name, $value, \DateInterval $expiresAfter = null) use ($cache) { + $item = $cache->getItem($name); + $item->set($value); + + if ($expiresAfter) { + $item->expiresAfter($expiresAfter); + } + + $cache->save($item); + }; + + $doSet('foo', 'foo-val'); + $doSet('bar', 'bar-val', new \DateInterval('PT20S')); + $doSet('baz', 'baz-val', new \DateInterval('PT40S')); + $doSet('qux', 'qux-val', new \DateInterval('PT80S')); + + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertFalse($this->isPruned($cache, 'bar')); + $this->assertFalse($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'bar')); + $this->assertFalse($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'qux')); + } } class NotUnserializable implements \Serializable diff --git a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php index 68357860f2..b6757514eb 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; /** @@ -49,4 +50,12 @@ class FilesystemAdapterTest extends AdapterTestCase } rmdir($dir); } + + protected function isPruned(CacheItemPoolInterface $cache, $name) + { + $getFileMethod = (new \ReflectionObject($cache))->getMethod('getFile'); + $getFileMethod->setAccessible(true); + + return !file_exists($getFileMethod->invoke($cache, $name)); + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php index 05e312cbf7..8e93c937f6 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Tests\Adapter; +use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\PhpFilesAdapter; /** @@ -35,4 +36,12 @@ class PhpFilesAdapterTest extends AdapterTestCase { FilesystemAdapterTest::rmdir(sys_get_temp_dir().'/symfony-cache'); } + + protected function isPruned(CacheItemPoolInterface $cache, $name) + { + $getFileMethod = (new \ReflectionObject($cache))->getMethod('getFile'); + $getFileMethod->setAccessible(true); + + return !file_exists($getFileMethod->invoke($cache, $name)); + } } diff --git a/src/Symfony/Component/Cache/Tests/Simple/CacheTestCase.php b/src/Symfony/Component/Cache/Tests/Simple/CacheTestCase.php index b3799ebcda..600cd338be 100644 --- a/src/Symfony/Component/Cache/Tests/Simple/CacheTestCase.php +++ b/src/Symfony/Component/Cache/Tests/Simple/CacheTestCase.php @@ -12,9 +12,19 @@ namespace Symfony\Component\Cache\Tests\Simple; use Cache\IntegrationTests\SimpleCacheTest; +use Symfony\Component\Cache\PruneableInterface; abstract class CacheTestCase extends SimpleCacheTest { + protected function setUp() + { + parent::setUp(); + + if (!array_key_exists('testPrune', $this->skippedTests) && !$this->createSimpleCache() instanceof PruneableInterface) { + $this->skippedTests['testPrune'] = 'Not a pruneable cache pool.'; + } + } + public static function validKeys() { if (defined('HHVM_VERSION')) { @@ -59,6 +69,48 @@ abstract class CacheTestCase extends SimpleCacheTest } $this->assertNull($value); } + + public function testPrune() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + } + + if (!method_exists($this, 'isPruned')) { + $this->fail('Test classes for pruneable caches must implement `isPruned($cache, $name)` method.'); + } + + $cache = $this->createSimpleCache(); + + $cache->set('foo', 'foo-val'); + $cache->set('bar', 'bar-val', new \DateInterval('PT20S')); + $cache->set('baz', 'baz-val', new \DateInterval('PT40S')); + $cache->set('qux', 'qux-val', new \DateInterval('PT80S')); + + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertFalse($this->isPruned($cache, 'bar')); + $this->assertFalse($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'bar')); + $this->assertFalse($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'baz')); + $this->assertFalse($this->isPruned($cache, 'qux')); + + sleep(30); + $cache->prune(); + $this->assertFalse($this->isPruned($cache, 'foo')); + $this->assertTrue($this->isPruned($cache, 'qux')); + } } class NotUnserializable implements \Serializable diff --git a/src/Symfony/Component/Cache/Tests/Simple/FilesystemCacheTest.php b/src/Symfony/Component/Cache/Tests/Simple/FilesystemCacheTest.php index 0f2d519cad..620305a58a 100644 --- a/src/Symfony/Component/Cache/Tests/Simple/FilesystemCacheTest.php +++ b/src/Symfony/Component/Cache/Tests/Simple/FilesystemCacheTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Tests\Simple; +use Psr\SimpleCache\CacheInterface; use Symfony\Component\Cache\Simple\FilesystemCache; /** @@ -22,4 +23,12 @@ class FilesystemCacheTest extends CacheTestCase { return new FilesystemCache('', $defaultLifetime); } + + protected function isPruned(CacheInterface $cache, $name) + { + $getFileMethod = (new \ReflectionObject($cache))->getMethod('getFile'); + $getFileMethod->setAccessible(true); + + return !file_exists($getFileMethod->invoke($cache, $name)); + } } diff --git a/src/Symfony/Component/Cache/Tests/Simple/PhpFilesCacheTest.php b/src/Symfony/Component/Cache/Tests/Simple/PhpFilesCacheTest.php index 3118fcf94e..7a402682ae 100644 --- a/src/Symfony/Component/Cache/Tests/Simple/PhpFilesCacheTest.php +++ b/src/Symfony/Component/Cache/Tests/Simple/PhpFilesCacheTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Cache\Tests\Simple; +use Psr\SimpleCache\CacheInterface; use Symfony\Component\Cache\Simple\PhpFilesCache; /** @@ -30,4 +31,12 @@ class PhpFilesCacheTest extends CacheTestCase return new PhpFilesCache('sf-cache'); } + + protected function isPruned(CacheInterface $cache, $name) + { + $getFileMethod = (new \ReflectionObject($cache))->getMethod('getFile'); + $getFileMethod->setAccessible(true); + + return !file_exists($getFileMethod->invoke($cache, $name)); + } } diff --git a/src/Symfony/Component/Cache/Traits/FilesystemTrait.php b/src/Symfony/Component/Cache/Traits/FilesystemTrait.php index 1db720452f..bcb940cb0f 100644 --- a/src/Symfony/Component/Cache/Traits/FilesystemTrait.php +++ b/src/Symfony/Component/Cache/Traits/FilesystemTrait.php @@ -15,6 +15,7 @@ use Symfony\Component\Cache\Exception\CacheException; /** * @author Nicolas Grekas + * @author Rob Frawley 2nd * * @internal */ @@ -22,6 +23,30 @@ trait FilesystemTrait { use FilesystemCommonTrait; + /** + * @return bool + */ + public function prune() + { + $time = time(); + $pruned = true; + + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY) as $file) { + if (!$h = @fopen($file, 'rb')) { + continue; + } + + if ($time >= (int) $expiresAt = fgets($h)) { + fclose($h); + $pruned = isset($expiresAt[0]) && @unlink($file) && !file_exists($file) && $pruned; + } else { + fclose($h); + } + } + + return $pruned; + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Cache/Traits/PhpFilesTrait.php b/src/Symfony/Component/Cache/Traits/PhpFilesTrait.php index 259caf0c74..4a7c296134 100644 --- a/src/Symfony/Component/Cache/Traits/PhpFilesTrait.php +++ b/src/Symfony/Component/Cache/Traits/PhpFilesTrait.php @@ -17,6 +17,7 @@ use Symfony\Component\Cache\Exception\InvalidArgumentException; /** * @author Piotr Stankowski * @author Nicolas Grekas + * @author Rob Frawley 2nd * * @internal */ @@ -31,6 +32,35 @@ trait PhpFilesTrait return function_exists('opcache_invalidate') && ini_get('opcache.enable'); } + /** + * @return bool + */ + public function prune() + { + $time = time(); + $pruned = true; + $allowCompile = 'cli' !== PHP_SAPI || ini_get('opcache.enable_cli'); + + set_error_handler($this->includeHandler); + try { + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY) as $file) { + list($expiresAt) = include $file; + + if ($time >= $expiresAt) { + $pruned = @unlink($file) && !file_exists($file) && $pruned; + + if ($allowCompile) { + @opcache_invalidate($file, true); + } + } + } + } finally { + restore_error_handler(); + } + + return $pruned; + } + /** * {@inheritdoc} */