diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index d582cd3b74596..7c5a2c90398cd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Environment variable `SYMFONY_IDE` is read by default when `framework.ide` config is not set. * Load PHP configuration files by default in the `MicroKernelTrait` + * Add `cache:pool:invalidate-tags` command 6.0 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolInvalidateTagsCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolInvalidateTagsCommand.php new file mode 100644 index 0000000000000..8ad84bfa69503 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/CachePoolInvalidateTagsCommand.php @@ -0,0 +1,115 @@ + + * + * 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\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * @author Kevin Bond + */ +#[AsCommand(name: 'cache:pool:invalidate-tags', description: 'Invalidate cache tags for all or a specific pool')] +final class CachePoolInvalidateTagsCommand extends Command +{ + private ServiceProviderInterface $pools; + private array $poolNames; + + public function __construct(ServiceProviderInterface $pools) + { + parent::__construct(); + + $this->pools = $pools; + $this->poolNames = array_keys($pools->getProvidedServices()); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this + ->addArgument('tags', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The tags to invalidate') + ->addOption('pool', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The pools to invalidate on') + ->setHelp(<<<'EOF' + The %command.name% command invalidates tags from taggable pools. By default, all pools + have the passed tags invalidated. Pass --pool=my_pool to invalidate tags on a specific pool. + + php %command.full_name% tag1 tag2 + php %command.full_name% tag1 tag2 --pool=cache2 --pool=cache1 + EOF) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $pools = $input->getOption('pool') ?: $this->poolNames; + $tags = $input->getArgument('tags'); + $tagList = implode(', ', $tags); + $errors = false; + + foreach ($pools as $name) { + $io->comment(sprintf('Invalidating tag(s): %s from pool %s.', $tagList, $name)); + + try { + $pool = $this->pools->get($name); + } catch (ServiceNotFoundException) { + $io->error(sprintf('Pool "%s" not found.', $name)); + $errors = true; + + continue; + } + + if (!$pool instanceof TagAwareCacheInterface) { + $io->error(sprintf('Pool "%s" is not taggable.', $name)); + $errors = true; + + continue; + } + + if (!$pool->invalidateTags($tags)) { + $io->error(sprintf('Cache tag(s) "%s" could not be invalidated for pool "%s".', $tagList, $name)); + $errors = true; + } + } + + if ($errors) { + $io->error('Done but with errors.'); + + return self::FAILURE; + } + + $io->success('Successfully invalidated cache tags.'); + + return self::SUCCESS; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('pool')) { + $suggestions->suggestValues($this->poolNames); + } + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index a556599e76d0c..67bbc740f816b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -27,6 +27,7 @@ class UnusedTagsPass implements CompilerPassInterface 'auto_alias', 'cache.pool', 'cache.pool.clearer', + 'cache.taggable', 'chatter.transport_factory', 'config_cache.resource_checker', 'console.command', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c04814c6bef27..b81e8d147f921 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2145,9 +2145,11 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con if ($isRedisTagAware && 'cache.app' === $name) { $container->setAlias('cache.app.taggable', $name); + $definition->addTag('cache.taggable', ['pool' => $name]); } elseif ($isRedisTagAware) { $tagAwareId = $name; $container->setAlias('.'.$name.'.inner', $name); + $definition->addTag('cache.taggable', ['pool' => $name]); } elseif ($pool['tags']) { if (true !== $pool['tags'] && ($config['pools'][$pool['tags']]['tags'] ?? false)) { $pool['tags'] = '.'.$pool['tags'].'.inner'; @@ -2156,6 +2158,7 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con ->addArgument(new Reference('.'.$name.'.inner')) ->addArgument(true !== $pool['tags'] ? new Reference($pool['tags']) : null) ->setPublic($pool['public']) + ->addTag('cache.taggable', ['pool' => $name]) ; if (method_exists(TagAwareAdapter::class, 'setLogger')) { @@ -2172,6 +2175,7 @@ private function registerCacheConfiguration(array $config, ContainerBuilder $con $tagAwareId = '.'.$name.'.taggable'; $container->register($tagAwareId, TagAwareAdapter::class) ->addArgument(new Reference($name)) + ->addTag('cache.taggable', ['pool' => $name]) ; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php index d1a10e2b36c4b..679d74c80dc37 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/cache.php @@ -39,6 +39,7 @@ ->set('cache.app.taggable', TagAwareAdapter::class) ->args([service('cache.app')]) + ->tag('cache.taggable', ['pool' => 'cache.app']) ->set('cache.system') ->parent('cache.adapter.system') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 610a83addec42..c8ff77f1e795d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\Command\CacheClearCommand; use Symfony\Bundle\FrameworkBundle\Command\CachePoolClearCommand; use Symfony\Bundle\FrameworkBundle\Command\CachePoolDeleteCommand; +use Symfony\Bundle\FrameworkBundle\Command\CachePoolInvalidateTagsCommand; use Symfony\Bundle\FrameworkBundle\Command\CachePoolListCommand; use Symfony\Bundle\FrameworkBundle\Command\CachePoolPruneCommand; use Symfony\Bundle\FrameworkBundle\Command\CacheWarmupCommand; @@ -93,6 +94,12 @@ ]) ->tag('console.command') + ->set('console.command.cache_pool_invalidate_tags', CachePoolInvalidateTagsCommand::class) + ->args([ + tagged_locator('cache.taggable', 'pool'), + ]) + ->tag('console.command') + ->set('console.command.cache_pool_delete', CachePoolDeleteCommand::class) ->args([ service('cache.global_clearer'), diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePoolInvalidateTagsCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePoolInvalidateTagsCommandTest.php new file mode 100644 index 0000000000000..d0286b7e16faa --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Command/CachePoolInvalidateTagsCommandTest.php @@ -0,0 +1,138 @@ + + * + * 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\CachePoolInvalidateTagsCommand; +use Symfony\Bundle\FrameworkBundle\Tests\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandCompletionTester; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Contracts\Cache\TagAwareCacheInterface; + +class CachePoolInvalidateTagsCommandTest extends TestCase +{ + public function testComplete() + { + $tester = new CommandCompletionTester($this->createCommand(['foo' => null, 'bar' => null])); + + $suggestions = $tester->complete(['--pool=']); + + $this->assertSame(['foo', 'bar'], $suggestions); + } + + public function testInvalidatesTagsForAllPoolsByDefault() + { + $tagsToInvalidate = ['tag1', 'tag2']; + + $foo = $this->createMock(TagAwareCacheInterface::class); + $foo->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $bar = $this->createMock(TagAwareCacheInterface::class); + $bar->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $tester = new CommandTester($this->createCommand([ + 'foo' => $foo, + 'bar' => $bar, + ])); + + $ret = $tester->execute(['tags' => $tagsToInvalidate]); + + $this->assertSame(Command::SUCCESS, $ret); + } + + public function testCanInvalidateSpecificPools() + { + $tagsToInvalidate = ['tag1', 'tag2']; + + $foo = $this->createMock(TagAwareCacheInterface::class); + $foo->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $bar = $this->createMock(TagAwareCacheInterface::class); + $bar->expects($this->never())->method('invalidateTags'); + + $tester = new CommandTester($this->createCommand([ + 'foo' => $foo, + 'bar' => $bar, + ])); + + $ret = $tester->execute(['tags' => $tagsToInvalidate, '--pool' => ['foo']]); + + $this->assertSame(Command::SUCCESS, $ret); + } + + public function testCommandFailsIfPoolNotFound() + { + $tagsToInvalidate = ['tag1', 'tag2']; + + $foo = $this->createMock(TagAwareCacheInterface::class); + $foo->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $bar = $this->createMock(TagAwareCacheInterface::class); + $bar->expects($this->never())->method('invalidateTags'); + + $tester = new CommandTester($this->createCommand([ + 'foo' => $foo, + 'bar' => $bar, + ])); + + $ret = $tester->execute(['tags' => $tagsToInvalidate, '--pool' => ['invalid', 'foo']]); + + $this->assertSame(Command::FAILURE, $ret); + } + + public function testCommandFailsIfPoolNotTaggable() + { + $tagsToInvalidate = ['tag1', 'tag2']; + + $foo = new \stdClass(); + + $bar = $this->createMock(TagAwareCacheInterface::class); + $bar->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $tester = new CommandTester($this->createCommand([ + 'foo' => $foo, + 'bar' => $bar, + ])); + + $ret = $tester->execute(['tags' => $tagsToInvalidate]); + + $this->assertSame(Command::FAILURE, $ret); + } + + public function testCommandFailsIfInvalidatingTagsFails() + { + $tagsToInvalidate = ['tag1', 'tag2']; + + $foo = $this->createMock(TagAwareCacheInterface::class); + $foo->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(false); + + $bar = $this->createMock(TagAwareCacheInterface::class); + $bar->expects($this->once())->method('invalidateTags')->with($tagsToInvalidate)->willReturn(true); + + $tester = new CommandTester($this->createCommand([ + 'foo' => $foo, + 'bar' => $bar, + ])); + + $ret = $tester->execute(['tags' => $tagsToInvalidate]); + + $this->assertSame(Command::FAILURE, $ret); + } + + private function createCommand(array $services): CachePoolInvalidateTagsCommand + { + return new CachePoolInvalidateTagsCommand( + new ServiceLocator(array_map(fn ($service) => fn () => $service, $services)) + ); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index 248eeacd0102a..ccd35edd68d08 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -31,6 +31,8 @@ use Symfony\Component\Cache\DependencyInjection\CachePoolPass; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass; @@ -1627,6 +1629,55 @@ public function appRedisTagAwareConfigProvider(): array ]; } + public function testCacheTaggableTagAppliedToPools() + { + $container = $this->createContainerFromFile('cache'); + + $servicesToCheck = [ + 'cache.app.taggable' => 'cache.app', + 'cache.redis_tag_aware.bar' => 'cache.redis_tag_aware.bar', + '.cache.foobar.taggable' => 'cache.foobar', + ]; + + foreach ($servicesToCheck as $id => $expectedPool) { + $this->assertTrue($container->hasDefinition($id)); + + $def = $container->getDefinition($id); + + $this->assertTrue($def->hasTag('cache.taggable')); + $this->assertSame($expectedPool, $def->getTag('cache.taggable')[0]['pool'] ?? null); + } + } + + /** + * @dataProvider appRedisTagAwareConfigProvider + */ + public function testCacheTaggableTagAppliedToRedisAwareAppPool(string $configFile) + { + $container = $this->createContainerFromFile($configFile); + + $def = $container->getDefinition('cache.app'); + + $this->assertTrue($def->hasTag('cache.taggable')); + $this->assertSame('cache.app', $def->getTag('cache.taggable')[0]['pool'] ?? null); + } + + public function testCachePoolInvalidateTagsCommandRegistered() + { + $container = $this->createContainerFromFile('cache'); + $this->assertTrue($container->hasDefinition('console.command.cache_pool_invalidate_tags')); + + $locator = $container->getDefinition('console.command.cache_pool_invalidate_tags')->getArgument(0); + $this->assertInstanceOf(ServiceLocatorArgument::class, $locator); + + $iterator = $locator->getTaggedIteratorArgument(); + $this->assertInstanceOf(TaggedIteratorArgument::class, $iterator); + + $this->assertSame('cache.taggable', $iterator->getTag()); + $this->assertSame('pool', $iterator->getIndexAttribute()); + $this->assertTrue($iterator->needsIndexes()); + } + public function testRemovesResourceCheckerConfigCacheFactoryArgumentOnlyIfNoDebug() { $container = $this->createContainer(['kernel.debug' => true]);