diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 396cf456e3747..f813ae4c342e7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -21,6 +21,7 @@ CHANGELOG * Add autowiring aliases for `Http\Client\HttpAsyncClient` * Deprecate the `Http\Client\HttpClient` service, use `Psr\Http\Client\ClientInterface` instead * Add `stop_worker_on_signals` configuration option to `messenger` to define signals which would stop a worker + * Add `debug:scheduler` command 6.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SchedulerDebugCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SchedulerDebugCommand.php new file mode 100644 index 0000000000000..5711d55bd0b1a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SchedulerDebugCommand.php @@ -0,0 +1,149 @@ + + * + * 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\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\Component\Scheduler\Schedule; + +/** + * A console command for retrieving information about schedulers. + * + * @author Baptiste Leduc + * + * @final + */ +#[AsCommand(name: 'debug:scheduler', description: 'Display current schedules for an application')] +class SchedulerDebugCommand extends Command +{ + public function __construct( + private readonly ServiceLocator $scheduleProviders + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDefinition([ + new InputArgument('name', InputArgument::OPTIONAL, 'A scheduler name (uses `default` if none provided)', 'default'), + new InputArgument('message', InputArgument::OPTIONAL, 'A class name to filter on'), + new InputOption('from', null, InputOption::VALUE_REQUIRED, 'When the next run date will be calculated from (use \DateTime::ATOM format)', 'now'), + new InputOption('from-format', null, InputOption::VALUE_REQUIRED, 'Format to use for the from option', \DateTimeInterface::ATOM), + new InputOption('from-timezone', null, InputOption::VALUE_REQUIRED, 'Timezone to use in combination with the from option', 'UTC'), + new InputOption('show-lock', null, InputOption::VALUE_NONE, 'Show used lock'), + new InputOption('show-state', null, InputOption::VALUE_NONE, 'Show used state'), + ]) + ->setHelp(<<<'EOF' +The %command.name% displays the configured schedules: + + php %command.full_name% + +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output); + $errorIo = $io->getErrorStyle(); + $dateTimeZone = new \DateTimeZone($input->getOption('from-timezone')); + $dateFrom = 'now' === $input->getOption('from') + ? new \DateTimeImmutable(timezone: $dateTimeZone) + : \DateTimeImmutable::createFromFormat($input->getOption('from-format'), $input->getOption('from'), $dateTimeZone); + + $askedScheduler = $input->getArgument('name'); + /** @var string[] $availableSchedulers */ + $availableSchedulers = array_keys($this->scheduleProviders->getProvidedServices()); + + if (!\in_array($askedScheduler, $availableSchedulers)) { + $errorIo->error(sprintf('The "%s" scheduler could not be found. Available schedulers: %s.', $askedScheduler, implode(', ', $availableSchedulers))); + + return Command::INVALID; + } + + $scheduler = $this->scheduleProviders->get($askedScheduler); + /** @var Schedule $schedule */ + $schedule = $scheduler->getSchedule(); + + $io->title(sprintf('Information for Scheduler "%s"', $askedScheduler)); + + if ($input->getOption('show-lock')) { + $this->showLock($io, $schedule); + } elseif ($input->getOption('show-state')) { + $this->showState($io, $schedule); + } else { + $this->showMessages($io, $schedule, $dateFrom, $dateTimeZone, $input->getArgument('message')); + } + + return Command::SUCCESS; + } + + private function showMessages(SymfonyStyle $io, Schedule $schedule, \DateTimeImmutable $dateFrom, \DateTimeZone $dateTimeZone, string $classFilter = null): void + { + if ($filtered = \is_string($classFilter)) { + $io->comment(sprintf('Displaying only \'%s\' messages', $classFilter)); + } + + $messages = []; + foreach ($schedule->getRecurringMessages() as $recurringMessage) { + if ($filtered && !str_contains($recurringMessage->getMessage()::class, $classFilter)) { + continue; + } + + $nextRunDate = $recurringMessage->getTrigger()->getNextRunDate($dateFrom); + $nextRunDate = $nextRunDate->setTimezone($dateTimeZone); + + $messages[] = [ + $recurringMessage->getMessage()::class, + $recurringMessage->getTrigger()::class, + $nextRunDate->format(\DateTimeInterface::ATOM), + ]; + } + + $io->table(['Message', 'Trigger type', 'Next run date'], $messages); + } + + private function showLock(SymfonyStyle $io, Schedule $schedule): void + { + $io->comment('Displaying only Scheduler Lock information'); + + if (null === $schedule->getLock()) { + $io->note('No lock found on given Scheduler'); + + return; + } + + $io->text(sprintf('Using "%s" lock', $schedule->getLock()::class)); + } + + private function showState(SymfonyStyle $io, Schedule $schedule): void + { + $io->comment('Displaying only Scheduler Cache information'); + + if (null === $schedule->getState()) { + $io->note('No state found on given Scheduler'); + + return; + } + + $io->text(sprintf('Using "%s" cache adapter', $schedule->getState()::class)); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index d64cd058e61f8..2d34c492faefd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -28,6 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Command\EventDispatcherDebugCommand; use Symfony\Bundle\FrameworkBundle\Command\RouterDebugCommand; use Symfony\Bundle\FrameworkBundle\Command\RouterMatchCommand; +use Symfony\Bundle\FrameworkBundle\Command\SchedulerDebugCommand; use Symfony\Bundle\FrameworkBundle\Command\SecretsDecryptToLocalCommand; use Symfony\Bundle\FrameworkBundle\Command\SecretsEncryptFromLocalCommand; use Symfony\Bundle\FrameworkBundle\Command\SecretsGenerateKeysCommand; @@ -349,5 +350,9 @@ service('secrets.local_vault')->ignoreOnInvalid(), ]) ->tag('console.command') + + ->set('console.command.scheduler_debug', SchedulerDebugCommand::class) + ->args([tagged_locator('scheduler.schedule_provider', 'name')]) + ->tag('console.command') ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DefaultSchedule.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DefaultSchedule.php new file mode 100644 index 0000000000000..31991e088ef88 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Messenger/DefaultSchedule.php @@ -0,0 +1,27 @@ +add(RecurringMessage::every('1 month', new BarMessage())) + ->add(RecurringMessage::every('2 minutes', new FooMessage())) + ->stateful(new ArrayAdapter()) + ->lock(new Lock(new Key('dummy'), new InMemoryStore())) + ; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SchedulerDebugCommandTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SchedulerDebugCommandTest.php new file mode 100644 index 0000000000000..23a7ecccfadc5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SchedulerDebugCommandTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\BarMessage; +use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\FooMessage; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; + +/** + * @group functional + */ +class SchedulerDebugCommandTest extends AbstractWebTestCase +{ + private $application; + + protected function setUp(): void + { + $kernel = static::createKernel(['test_case' => 'SchedulerDebug', 'root_config' => 'config.yml']); + $this->application = new Application($kernel); + } + + public function testRunWithNoArguments() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $ret, 'Returns 0 in case of success'); + $this->assertStringContainsString('Information for Scheduler "default"', $tester->getDisplay()); + $this->assertStringContainsString(BarMessage::class, $tester->getDisplay()); + $this->assertStringContainsString(FooMessage::class, $tester->getDisplay()); + } + + public function testRunWithNameArgument() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute(['name' => 'default']); + + $this->assertSame(Command::SUCCESS, $ret, 'Returns 0 in case of success'); + $this->assertStringContainsString('Information for Scheduler "default"', $tester->getDisplay()); + $this->assertStringContainsString(BarMessage::class, $tester->getDisplay()); + $this->assertStringContainsString(FooMessage::class, $tester->getDisplay()); + } + + public function testRunWithWrongNameArgument() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute(['name' => 'foo']); + + $this->assertSame(Command::INVALID, $ret, 'Returns 2 in case of invalid input'); + $this->assertStringContainsString('Available schedulers: default', $tester->getDisplay()); + } + + public function testRunWithNameAndMessageArgument() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute(['name' => 'default', 'message' => 'FooMessage']); + + $this->assertSame(Command::SUCCESS, $ret, 'Returns 0 in case of success'); + $this->assertStringContainsString('Information for Scheduler "default"', $tester->getDisplay()); + $this->assertStringNotContainsString(BarMessage::class, $tester->getDisplay()); + $this->assertStringContainsString(FooMessage::class, $tester->getDisplay()); + } + + public function testRunWithShowLockOption() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute(['--show-lock' => true]); + + $this->assertSame(Command::SUCCESS, $ret, 'Returns 0 in case of success'); + $this->assertStringContainsString('Information for Scheduler "default"', $tester->getDisplay()); + $this->assertStringContainsString('Displaying only Scheduler Lock information', $tester->getDisplay()); + $this->assertStringNotContainsString(BarMessage::class, $tester->getDisplay()); + $this->assertStringNotContainsString(FooMessage::class, $tester->getDisplay()); + } + + public function testRunWithShowStateOption() + { + $tester = $this->createCommandTester(); + $ret = $tester->execute(['--show-state' => true]); + + $this->assertSame(Command::SUCCESS, $ret, 'Returns 0 in case of success'); + $this->assertStringContainsString('Information for Scheduler "default"', $tester->getDisplay()); + $this->assertStringContainsString('Displaying only Scheduler Cache information', $tester->getDisplay()); + $this->assertStringNotContainsString(BarMessage::class, $tester->getDisplay()); + $this->assertStringNotContainsString(FooMessage::class, $tester->getDisplay()); + } + + private function createCommandTester(): CommandTester + { + $command = $this->application->find('debug:scheduler'); + + return new CommandTester($command); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/bundles.php new file mode 100644 index 0000000000000..13ab9fddee4a6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/bundles.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; + +return [ + new FrameworkBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/config.yml new file mode 100644 index 0000000000000..06c2a92718769 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/SchedulerDebug/config.yml @@ -0,0 +1,11 @@ +imports: + - { resource: ../config/default.yml } + +framework: + lock: ~ + scheduler: ~ + messenger: ~ + +services: + Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Messenger\DefaultSchedule: + autoconfigure: true