CronCommand.php 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Commands;
  4. use App\Service\Cron\CronjobInterface;
  5. use App\Service\Cron\UI\ConsoleUI;
  6. use App\Service\ServiceIterator\CronjobIterator;
  7. use Monolog\Formatter\LineFormatter;
  8. use Monolog\Handler\FingersCrossedHandler;
  9. use Monolog\Handler\RotatingFileHandler;
  10. use Psr\Log\LoggerInterface;
  11. use Symfony\Component\Console\Attribute\AsCommand;
  12. use Symfony\Component\Console\Command\Command;
  13. use Symfony\Component\Console\Command\LockableTrait;
  14. use Symfony\Component\Console\Helper\FormatterHelper;
  15. use Symfony\Component\Console\Input\InputArgument;
  16. use Symfony\Component\Console\Input\InputInterface;
  17. use Symfony\Component\Console\Input\InputOption;
  18. use Symfony\Component\Console\Output\OutputInterface;
  19. use Symfony\Contracts\Service\Attribute\Required;
  20. /**
  21. * CLI Command to run the cron-jobs.
  22. *
  23. * Ex:
  24. *
  25. * bin/console ot:cron list
  26. * bin/console ot:cron run clean-db --preview
  27. * bin/console ot:cron run clean-db
  28. *
  29. * @see doc/cron.md
  30. */
  31. #[AsCommand(
  32. name: 'ot:cron',
  33. description: 'Executes cron jobs'
  34. )]
  35. class CronCommand extends Command
  36. {
  37. use LockableTrait;
  38. private const ACTION_LIST = 'list';
  39. private const ACTION_RUN = 'run';
  40. private const ACTION_RUN_ALL = 'all';
  41. private const ACTIONS = [
  42. self::ACTION_LIST => 'List registered jobs',
  43. self::ACTION_RUN => 'Run the given job',
  44. self::ACTION_RUN_ALL => 'Successively run all the registered cron jobs',
  45. ];
  46. private OutputInterface $output;
  47. private LoggerInterface $logger;
  48. private CronjobIterator $cronjobIterator;
  49. /** @noinspection PhpUnused */
  50. #[Required]
  51. /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
  52. public function setLoggerInterface(LoggerInterface $cronLogger): void
  53. {
  54. $this->logger = $cronLogger;
  55. }
  56. /** @noinspection PhpUnused */
  57. #[Required]
  58. public function setCronjobIterator(CronjobIterator $cronjobIterator): void
  59. {
  60. $this->cronjobIterator = $cronjobIterator;
  61. }
  62. /**
  63. * Configures the command.
  64. */
  65. protected function configure(): void
  66. {
  67. $this->addArgument(
  68. 'action',
  69. InputArgument::REQUIRED,
  70. 'Action to execute among : '.
  71. implode(
  72. ', ',
  73. array_map(
  74. static function ($v, $k) { return "'".$k."' (".$v.')'; },
  75. self::ACTIONS,
  76. array_keys(self::ACTIONS)
  77. )
  78. )
  79. );
  80. $this->addArgument(
  81. 'jobs',
  82. InputArgument::OPTIONAL,
  83. "Name(s) of the cron-job(s) to execute with '".self::ACTION_RUN."' (comma-separated)"
  84. );
  85. $this->addOption(
  86. 'preview',
  87. 'p',
  88. InputOption::VALUE_NONE,
  89. 'Only preview the operations instead of executing them'
  90. );
  91. }
  92. /**
  93. * Executes the command.
  94. */
  95. final protected function execute(InputInterface $input, OutputInterface $output): int
  96. {
  97. $this->output = $output;
  98. $this->configureLoggerFormatter();
  99. /** @var FormatterHelper $formatter */
  100. $formatter = $this->getHelper('formatter');
  101. $action = $input->getArgument('action');
  102. $jobNames = $input->getArgument('jobs');
  103. $preview = $input->getOption('preview');
  104. $jobs = [];
  105. if ($preview) {
  106. $this->disableLoggerEmailHandler();
  107. }
  108. if (!array_key_exists($action, self::ACTIONS)) {
  109. $this->output->writeln($formatter->formatBlock('Error: unrecognized action', 'error'));
  110. return Command::INVALID;
  111. }
  112. if ($action === self::ACTION_LIST) {
  113. $this->listJobs();
  114. return Command::SUCCESS;
  115. }
  116. if ($action === self::ACTION_RUN_ALL) {
  117. $jobs = iterator_to_array($this->cronjobIterator->getAll());
  118. }
  119. if ($action === self::ACTION_RUN) {
  120. foreach (explode(',', $jobNames) as $name) {
  121. try {
  122. $jobs[] = $this->cronjobIterator->getByName($name);
  123. } catch (\RuntimeException $e) {
  124. $this->output->writeln($e->getMessage());
  125. $this->listJobs();
  126. return Command::INVALID;
  127. }
  128. }
  129. }
  130. $results = [];
  131. foreach ($jobs as $job) {
  132. $this->logger->info(
  133. 'CronCommand will execute `'.$job->name().'`'.($preview ? ' [PREVIEW MODE]' : '')
  134. );
  135. $results[] = $this->runJob($job, $preview);
  136. }
  137. return (int) max($results); // If there is one failure result, the whole command is shown as a failure too
  138. }
  139. /**
  140. * List all available cron jobs.
  141. */
  142. private function listJobs(): void
  143. {
  144. $availableJobs = $this->cronjobIterator->getAll();
  145. if (empty($availableJobs)) {
  146. $this->output->writeln('No cronjob found');
  147. return;
  148. }
  149. $this->output->writeln('Available cron jobs : ');
  150. foreach ($this->cronjobIterator->getAll() as $job) {
  151. $this->output->writeln('* '.$job->name());
  152. }
  153. }
  154. /**
  155. * Run one Cronjob.
  156. */
  157. private function runJob(CronjobInterface $job, bool $preview = false): int
  158. {
  159. /** @var FormatterHelper $formatter */
  160. $formatter = $this->getHelper('formatter');
  161. if (!$this->lock($job->name())) {
  162. $msg = 'The command '.$job->name().' is already running in another process. Abort.';
  163. $this->output->writeln($formatter->formatBlock($msg, 'error'));
  164. $this->logger->error($msg);
  165. return Command::FAILURE;
  166. }
  167. $t0 = microtime(true);
  168. $this->output->writeln(
  169. $formatter->formatSection($job->name(), 'Start'.($preview ? ' [PREVIEW MODE]' : ''))
  170. );
  171. // Establish communication between job and the console
  172. $ui = new ConsoleUI($this->output);
  173. $job->setUI($ui);
  174. $this->configureLoggerFormatter($job->name());
  175. try {
  176. if ($preview) {
  177. $job->preview();
  178. } else {
  179. $job->execute();
  180. }
  181. $t1 = microtime(true);
  182. $msg = 'Job has been successfully executed ('.round($t1 - $t0, 2).' sec.)'.($preview ? ' [PREVIEW MODE]' : '');
  183. $this->output->writeln($formatter->formatSection($job->name(), $msg));
  184. $this->logger->info($job->name().' - '.$msg);
  185. } catch (\Throwable $e) {
  186. $this->logger->critical((string) $e);
  187. $this->output->write('An error happened while running the process : '.$e);
  188. return Command::FAILURE;
  189. } finally {
  190. $this->resetLoggerFormatter();
  191. }
  192. return Command::SUCCESS;
  193. }
  194. /**
  195. * Modify the RotatingFile logger line format to match the display the current job's name (if any).
  196. */
  197. protected function configureLoggerFormatter(?string $jobName = null): void
  198. {
  199. /* @noinspection PhpPossiblePolymorphicInvocationInspection @phpstan-ignore-next-line */
  200. foreach ($this->logger->getHandlers() as $handler) {
  201. if ($handler instanceof RotatingFileHandler) {
  202. $format = '[%datetime%] '.
  203. ($jobName !== null ? '['.$jobName.'] ' : '').
  204. "%channel%.%level_name%: %message% %context% %extra%\n";
  205. $handler->setFormatter(new LineFormatter($format, 'Y-m-d H:i:s.v'));
  206. }
  207. }
  208. }
  209. /**
  210. * Alias for `$this->configureLoggerFormatter(null)`.
  211. */
  212. protected function resetLoggerFormatter(): void
  213. {
  214. $this->configureLoggerFormatter();
  215. }
  216. protected function disableLoggerEmailHandler(): void
  217. {
  218. $handlers = [];
  219. /* @noinspection PhpPossiblePolymorphicInvocationInspection @phpstan-ignore-next-line */
  220. foreach ($this->logger->getHandlers() as $handler) {
  221. if (!$handler instanceof FingersCrossedHandler) {
  222. $handlers[] = $handler;
  223. }
  224. }
  225. $this->logger->setHandlers($handlers); // @phpstan-ignore-line
  226. }
  227. }