├── .gitignore ├── .travis.yml ├── Command ├── AddCommandJobToQueueCommand.php ├── AddRecurringConsoleJobToQueueCommand.php ├── AddScheduleJobToQueueCommand.php ├── AddTestJobCommand.php ├── CheckRecurringJobConfigurationCommand.php ├── ConsumerCommand.php ├── RabbitMqConsumerCommand.php ├── ReadRecurringConsoleJobConfigurationCommand.php ├── RunTestJobCommand.php ├── WriteCliConsumerConfigFileCommand.php └── WriteSupervisordConfigFileCommand.php ├── Consumer └── JobConsumer.php ├── Controller └── RecurringViewController.php ├── DependencyInjection ├── Configuration.php └── MarkupJobQueueExtension.php ├── Entity ├── JobLog.php ├── JobStatus.php ├── Repository │ ├── DoctrineOrmAwareRepositoryTrait.php │ └── ScheduledJobRepository.php └── ScheduledJob.php ├── EventSubscriber ├── AddUuidOptionConsoleCommandEventSubscriber.php ├── CompleteConsoleCommandEventSubscriber.php └── LogConsoleCommandEventSubscriber.php ├── Exception ├── InvalidConfigurationException.php ├── InvalidCronSyntaxException.php ├── InvalidJobArgumentException.php ├── JobFailedException.php ├── JobMissingClassException.php ├── MissingConfigurationException.php ├── MissingJobLogException.php ├── MissingScheduleException.php ├── MissingTopicException.php ├── UndefinedProducerException.php └── UnknownQueueException.php ├── Form ├── Data │ └── SearchJobLogs.php ├── Handler │ └── SearchJobLogs.php └── Type │ └── SearchJobLogs.php ├── Job ├── BadJob.php ├── ConsoleCommandJob.php ├── ExceptionJob.php ├── SleepJob.php └── WorkJob.php ├── LICENSE ├── MarkupJobQueueBundle.php ├── Model ├── Job.php ├── JobLogCollection.php ├── Queue.php ├── RecurringConsoleCommandConfiguration.php └── ScheduledJobRepositoryInterface.php ├── Publisher └── JobPublisher.php ├── README.md ├── Reader ├── QueueReader.php └── RecurringConsoleCommandConfigurationJobLogReader.php ├── Repository ├── CronHealthRepository.php ├── JobLogRepository.php └── JobStatusRepository.php ├── Resources ├── config │ ├── admin_routing.yml │ ├── commands.yml │ ├── doctrine │ │ ├── JobLog.orm.yml │ │ ├── JobStatus.orm.yml │ │ └── ScheduledJob.orm.yml │ └── services.yml └── views │ ├── View │ └── recurring.html.twig │ └── layout.html.twig ├── Service ├── CliConsumerConfigFileWriter.php ├── JobManager.php ├── RecurringConsoleCommandReader.php └── SupervisordConfigFileWriter.php ├── Tests ├── Model │ └── RecurringConsoleCommandConfigurationTest.php ├── Publisher │ └── JobPublisherTest.php └── Service │ ├── CliConsumerConfigFileWriterTest.php │ ├── JobManagerTest.php │ ├── SupervisordConfigFileWriterTest.php │ └── fixtures │ ├── rabbitmq-cli-consumer-config.conf │ ├── supervisord_config_cli.conf │ └── supervisord_config_php.conf ├── composer.json ├── phpstan.neon └── phpunit.xml.dist /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | vendor/* 3 | bin/* 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | matrix: 4 | include: 5 | - php: 7.1 6 | env: deps=low 7 | - php: 7.1 8 | - php: 7.2 9 | - php: 7.3 10 | - php: 7.4 11 | - php: nightly 12 | allow_failures: 13 | - php: nightly 14 | 15 | sudo: false 16 | dist: xenial 17 | 18 | env: 19 | global: 20 | - deps=standard 21 | 22 | install: 23 | - composer self-update -q 24 | - if [ "$deps" = "standard" ]; then composer --prefer-stable --prefer-dist -n update; fi; 25 | - if [ "$deps" = "low" ]; then composer --prefer-lowest -n --prefer-stable --prefer-dist update; fi; 26 | 27 | 28 | script: bin/phpunit && bin/phpstan analyse -l 5 ./ 29 | 30 | notifications: 31 | email: "calum@usemarkup.com" 32 | -------------------------------------------------------------------------------- /Command/AddCommandJobToQueueCommand.php: -------------------------------------------------------------------------------- 1 | jobby = $jobby; 28 | } 29 | 30 | /** 31 | * @see Command 32 | */ 33 | protected function configure() 34 | { 35 | $this 36 | ->setDescription('Adds a single job that executes a command via the job queue') 37 | ->addArgument( 38 | 'cmd', 39 | InputArgument::REQUIRED, 40 | 'The command to add' 41 | ) 42 | ->addArgument( 43 | 'topic', 44 | InputArgument::REQUIRED, 45 | 'The topic to add the command to' 46 | ) 47 | ->addOption( 48 | 'timeout', 49 | 't', 50 | InputOption::VALUE_OPTIONAL, 51 | 'The timeout time for the command. Defaults to 60 seconds', 52 | '60' 53 | ) 54 | ->addOption( 55 | 'idle_timeout', 56 | 'i', 57 | InputOption::VALUE_OPTIONAL, 58 | 'The idle timeout time for the command. Defaults to 60 seconds', 59 | '60' 60 | ); 61 | } 62 | 63 | protected function execute(InputInterface $input, OutputInterface $output) 64 | { 65 | $command = strval($input->getArgument('cmd')); 66 | $topic = strval($input->getArgument('topic')); 67 | $timeout = intval($input->getOption('timeout')); 68 | $idleTimeout = intval($input->getOption('idle_timeout')); 69 | 70 | $this->jobby->addConsoleCommandJob($command, [], $topic, $timeout, $idleTimeout); 71 | 72 | $output->writeln('Added command to job queue'); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Command/AddRecurringConsoleJobToQueueCommand.php: -------------------------------------------------------------------------------- 1 | recurringConsoleCommandReader = $recurringConsoleCommandReader; 63 | $this->jobManager = $jobManager; 64 | $this->cronHealthRepository = $cronHealthRepository; 65 | $this->jobLogRepository = $jobLogRepository; 66 | $this->jobStatusRepository = $jobStatusRepository; 67 | $this->environment = $environment; 68 | 69 | parent::__construct(null); 70 | } 71 | 72 | /** 73 | * @see Command 74 | */ 75 | protected function configure() 76 | { 77 | $this 78 | ->setDescription('Adds any configured recurring jobs, which are due NOW, to the specified job queue'); 79 | } 80 | 81 | /** 82 | * {@inheritdoc} 83 | */ 84 | protected function execute(InputInterface $input, OutputInterface $output) 85 | { 86 | $this->addRecurringJobs($output); 87 | $this->maintainJobLogs(); 88 | } 89 | 90 | /** 91 | * @param OutputInterface $output 92 | */ 93 | private function addRecurringJobs(OutputInterface $output) 94 | { 95 | $due = $this->recurringConsoleCommandReader->getDue(); 96 | 97 | foreach ($due as $configuration) { 98 | if (!$configuration instanceof RecurringConsoleCommandConfiguration) { 99 | throw new \Exception('Invalid configuration'); 100 | } 101 | 102 | if ($configuration->getEnvs()) { 103 | $env = $this->environment; 104 | 105 | if (!in_array($env, $configuration->getEnvs())) { 106 | $output->writeln( 107 | sprintf( 108 | 'Skipping `%s`, not to run in this env', 109 | $configuration->getCommand() 110 | ) 111 | ); 112 | continue; 113 | } 114 | } 115 | 116 | $command = $configuration->getCommand(); 117 | 118 | if (stripos($configuration->getCommand(), ' ') !== false) { 119 | throw new \LogicException(sprintf('%s Command cannot contain spaces', $configuration->getCommand())); 120 | } 121 | 122 | foreach ($configuration->getArguments() as $argument) { 123 | if (!is_string($argument)) { 124 | throw new \Exception( 125 | sprintf( 126 | 'Argument %s in command %s was expected to be a string, received %s', 127 | (string)$argument, 128 | $configuration->getCommand(), 129 | gettype($argument) 130 | ) 131 | ); 132 | } 133 | 134 | $this->validateNoQuotes($argument, $configuration); 135 | 136 | if (substr($argument, 0, 2) === '--') { 137 | $optionValue = ltrim(strstr($argument, '='), '='); 138 | 139 | $this->validateNoQuotes($optionValue, $configuration); 140 | } 141 | } 142 | 143 | if ($configuration->isUserManaged()) { 144 | $arguments = $configuration->getArguments() ? json_encode($configuration->getArguments()) : null; 145 | 146 | if (!$this->jobStatusRepository->isStatusEnabled($command, $arguments)) { 147 | continue; 148 | } 149 | } 150 | 151 | $this->jobManager->addConsoleCommandJob( 152 | $command, 153 | $configuration->getArguments(), 154 | $configuration->getTopic(), 155 | $configuration->getTimeout(), 156 | $configuration->getTimeout() 157 | ); 158 | $message = sprintf( 159 | '%s Added command `%s` with the topic `%s`', 160 | $configuration->previousRun()->format('c'), 161 | $configuration->getCommand(), 162 | $configuration->getTopic() 163 | ); 164 | 165 | $message = sprintf('%s. Will next be added %s', $message, $configuration->nextRun()->format('r')); 166 | $output->writeln(sprintf('%s', $message)); 167 | } 168 | 169 | $this->cronHealthRepository->set(); 170 | } 171 | 172 | private function maintainJobLogs() 173 | { 174 | $this->jobLogRepository->removeExpiredJobs(); 175 | } 176 | 177 | /** 178 | * @param string $argument 179 | * @param RecurringConsoleCommandConfiguration $configuration 180 | * @throws \Exception 181 | */ 182 | private function validateNoQuotes(string $argument, RecurringConsoleCommandConfiguration $configuration): void 183 | { 184 | $firstCharacter = substr($argument, 0, 1); 185 | $lastCharacter = substr($argument, strlen($argument)-1, 1); 186 | 187 | if ($firstCharacter === '"' && $lastCharacter === '"') { 188 | throw new \Exception(sprintf('remove quotes as they will be included as literal values on %s', $configuration->getCommand())); 189 | } 190 | 191 | if ($firstCharacter === "'" && $lastCharacter === "'") { 192 | throw new \Exception(sprintf('remove quotes as they will be included as literal values on %s', $configuration->getCommand())); 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Command/AddScheduleJobToQueueCommand.php: -------------------------------------------------------------------------------- 1 | jobManager = $jobManager; 42 | $this->logger = $logger; 43 | $this->scheduledJobRepository = $scheduledJobRepository; 44 | 45 | parent::__construct(null); 46 | } 47 | 48 | /** 49 | * @see Command 50 | */ 51 | protected function configure() 52 | { 53 | $this 54 | ->setDescription('Adds scheduled jobs to the job-queue'); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output) 58 | { 59 | $jobs = $this->scheduledJobRepository->fetchUnqueuedJobs(); 60 | 61 | if ($jobs) { 62 | foreach ($jobs as $job) { 63 | if (!$job instanceof ScheduledJob) { 64 | continue; 65 | } 66 | 67 | try { 68 | $this->jobManager->addConsoleCommandJob( 69 | $job->getJob(), 70 | $job->getArguments(), 71 | $job->getTopic(), 72 | 3600, 73 | 3600 74 | ); 75 | $job->setQueued(true); 76 | 77 | $this->scheduledJobRepository->save($job, $flush = true); 78 | } catch (\Exception $e) { 79 | $this->logger->error( 80 | sprintf( 81 | 'There was an error adding the job "%s" to the job-queue, error: %s', 82 | $job->getJob(), 83 | $e->getMessage() 84 | ) 85 | ); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Command/AddTestJobCommand.php: -------------------------------------------------------------------------------- 1 | jobby = $jobby; 36 | 37 | parent::__construct(); 38 | } 39 | 40 | /** 41 | * @see Command 42 | */ 43 | protected function configure() 44 | { 45 | $this 46 | ->setDescription('Adds a single job to allow testing of the job queue') 47 | ->addArgument( 48 | 'type', 49 | InputArgument::REQUIRED, 50 | 'The type of job to add. Should be one of `sleep`, `bad` (fatal error), `work` (cryptography) or `exception` (uncaught exception)' 51 | ) 52 | ->addArgument( 53 | 'quantity', 54 | InputArgument::OPTIONAL, 55 | 'The number of times to add the job', 56 | '1' 57 | ) 58 | ->addArgument( 59 | 'topic', 60 | InputArgument::OPTIONAL, 61 | 'The topic of the test job (defaults to `test`)', 62 | 'test' 63 | ); 64 | } 65 | 66 | protected function execute(InputInterface $input, OutputInterface $output) 67 | { 68 | $type = $input->getArgument('type'); 69 | $topic = $input->getArgument('topic'); 70 | 71 | switch ($type) { 72 | case self::TYPE_SLEEP: 73 | $job = new SleepJob(['time' => 10], $topic); 74 | break; 75 | case self::TYPE_BAD: 76 | $job = new BadJob([], $topic); 77 | break; 78 | case self::TYPE_EXCEPTION: 79 | $job = new ExceptionJob([], $topic); 80 | break; 81 | case self::TYPE_WORK: 82 | $job = new WorkJob(['units' => 200, 'complexity' => 32], $topic); 83 | break; 84 | default: 85 | throw new \Exception(sprintf('Unknown job of type %s specified', $type)); 86 | break; 87 | } 88 | 89 | $quantity = intval($input->getArgument('quantity')); 90 | for ($i = 0; $i < $quantity; $i++) { 91 | $this->jobby->addJob($job); 92 | } 93 | $output->writeln(sprintf('Added %s job * %s', $type, $quantity)); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Command/CheckRecurringJobConfigurationCommand.php: -------------------------------------------------------------------------------- 1 | recurringConsoleCommandReader = $recurringConsoleCommandReader; 23 | parent::__construct(null); 24 | } 25 | 26 | protected function configure() 27 | { 28 | $this 29 | ->setDescription('Checks the recurring job config files for validity.'); 30 | } 31 | 32 | /** 33 | * @param InputInterface $input 34 | * @param OutputInterface $output 35 | */ 36 | protected function execute(InputInterface $input, OutputInterface $output) 37 | { 38 | $message = ''; 39 | /** 40 | * @var RecurringConsoleCommandReader $reader 41 | */ 42 | try { 43 | $this->recurringConsoleCommandReader->getConfigurations(); 44 | $isGood = true; 45 | } catch (InvalidConfigurationException $e) { 46 | $isGood = false; 47 | 48 | $message = $e->getMessage(); 49 | } 50 | 51 | if ($isGood) { 52 | $output->writeln('Recurring jobs config is good.'); 53 | 54 | return 0; 55 | } else { 56 | $output->writeln(sprintf('Recurring jobs config is invalid. Message: %s', $message)); 57 | 58 | return 1; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Command/ConsumerCommand.php: -------------------------------------------------------------------------------- 1 | consumer = $consumer; 35 | } 36 | 37 | protected function configure() 38 | { 39 | $this 40 | ->addArgument('event', InputArgument::REQUIRED) 41 | ->addOption( 42 | 'strict-exit-code', 43 | null, 44 | InputOption::VALUE_NONE, 45 | 'If strict_exit_code is chosen then this command will return the following exit codes. 0=ACK, 3=REJECT, 4=REJECT & REQUEUE, 5=NEG ACK, 6=NEG ACK & REQUEUE' 46 | ); 47 | } 48 | 49 | /** 50 | * {inheritDoc} 51 | */ 52 | protected function execute(InputInterface $input, OutputInterface $output) 53 | { 54 | $jsonData = base64_decode($input->getArgument('event')); 55 | $data = json_decode($jsonData, true); 56 | 57 | if (isset($data['body'])) { 58 | $message = new AMQPMessage($data['body'], $data['properties']); 59 | } else { 60 | $message = new AMQPMessage($jsonData); 61 | } 62 | 63 | $strict = $input->getOption('strict-exit-code'); 64 | $consumerReturn = $this->consumer->execute($message); 65 | 66 | // if not running in strict mode - always acknowledge the message otherwise it will requeue forever 67 | if (!$strict) { 68 | exit(self::STRICT_CODE_ACK); 69 | } 70 | 71 | // If in strict mode then test the return value from the consumer and return an appropriate code 72 | if ($consumerReturn === ConsumerInterface::MSG_REJECT) { 73 | exit(self::STRICT_CODE_REJECT); 74 | } 75 | 76 | exit(self::STRICT_CODE_ACK); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Command/RabbitMqConsumerCommand.php: -------------------------------------------------------------------------------- 1 | consumer = $consumer; 34 | 35 | parent::__construct(); 36 | } 37 | 38 | protected function configure() 39 | { 40 | $this 41 | ->addArgument('event', InputArgument::REQUIRED) 42 | ->addOption( 43 | 'strict-exit-code', 44 | null, 45 | InputOption::VALUE_NONE, 46 | 'If strict_exit_code is chosen then this command will return the following exit codes. 0=ACK, 3=REJECT, 4=REJECT & REQUEUE, 5=NEG ACK, 6=NEG ACK & REQUEUE' 47 | ); 48 | } 49 | 50 | /** 51 | * {inheritDoc} 52 | */ 53 | protected function execute(InputInterface $input, OutputInterface $output) 54 | { 55 | if (!$this->consumer) { 56 | return 0; 57 | } 58 | 59 | $data = json_decode(base64_decode($input->getArgument('event')), true); 60 | 61 | $strict = $input->getOption('strict-exit-code'); 62 | 63 | $consumerReturn = $this->consumer->execute(new AMQPMessage($data['body'], $data['properties'])); 64 | 65 | // if not running in strict mode - always acknowledge the message otherwise it will requeue forever 66 | if (!$strict) { 67 | exit(self::STRICT_CODE_ACK); 68 | } 69 | 70 | // If in strict mode then test the return value from the consumer and return an appropriate code 71 | if ($consumerReturn === ConsumerInterface::MSG_REJECT) { 72 | exit(self::STRICT_CODE_REJECT); 73 | } 74 | 75 | exit(self::STRICT_CODE_ACK); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Command/ReadRecurringConsoleJobConfigurationCommand.php: -------------------------------------------------------------------------------- 1 | setName('markup:job_queue:recurring:view') 23 | ->setDescription('Views the current application configuration for recurring console jobs, showing the next run time') 24 | ->addOption( 25 | 'time', 26 | 't', 27 | InputOption::VALUE_OPTIONAL, 28 | 'If set - the command takes this value as the current time when showing job information. Value needs to be a valid datetime constructor.' 29 | ); 30 | } 31 | 32 | protected function execute(InputInterface $input, OutputInterface $output) 33 | { 34 | $time = new \DateTime('now'); 35 | if ($input->hasOption('time')) { 36 | $selectedTime = $input->getOption('time'); 37 | try { 38 | $throwaway = new \DateTime($selectedTime); 39 | $time = $throwaway; 40 | } catch (\Exception $e) { 41 | // dont do - this handles bad user input and will default to 'now' 42 | } 43 | } 44 | 45 | $recurringConsoleCommandReader = $this->getContainer()->get('markup_job_queue.reader.recurring_console_command'); 46 | 47 | $output->writeln(sprintf('Treating current time as %s', $time->format('r'))); 48 | $table = (class_exists(Table::class)) ? new Table($output) : $this->getHelperSet()->get('table'); 49 | $table->setHeaders(['command', 'topic', 'schedule', 'envs', 'valid command?', 'due?', 'next run?']); 50 | foreach ($recurringConsoleCommandReader->getConfigurations() as $configuration) { 51 | $row = []; 52 | $row[] = $configuration->getCommand(); 53 | $row[] = $configuration->getTopic(); 54 | $row[] = $configuration->getSchedule(); 55 | $row[] = $configuration->getEnvs() ? implode(',', $configuration->getEnvs()) : 'all'; 56 | $row[] = $this->isCommandValid($configuration->getCommand()) ? '✓' : '✗'; 57 | $row[] = $configuration->isDue($time) ? '✓' : '✗'; 58 | if ($configuration->nextRun()) { 59 | $row[] = $configuration->nextRun()->format('r'); 60 | } else { 61 | $row[] = '-'; 62 | } 63 | $table->addRow( 64 | $row 65 | ); 66 | } 67 | 68 | $this->renderTable($table, $output); 69 | } 70 | 71 | /** 72 | * Uses the process component to determine if a command is valid, by looking at the output of cmd:xyz --help 73 | * @param string $command 74 | * @return bool 75 | */ 76 | private function isCommandValid($command) 77 | { 78 | // because command contains the arguments, we need the cmd part only 79 | // @TODO: Split the CommandJob into cmd, option and argument parts 80 | $cmdParts = explode(' ', $command); 81 | try { 82 | $cmd = $this->getApplication()->find(reset($cmdParts)); 83 | 84 | return true; 85 | } catch (\InvalidArgumentException $e) { 86 | return false; 87 | } 88 | } 89 | 90 | private function renderTable($table, OutputInterface $output) 91 | { 92 | if (!$table instanceof Table) { 93 | $table->render($output); 94 | 95 | return; 96 | } 97 | 98 | $table->render(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Command/RunTestJobCommand.php: -------------------------------------------------------------------------------- 1 | parameterBag = $parameterBag; 37 | 38 | parent::__construct(); 39 | } 40 | 41 | /** 42 | * @see Command 43 | */ 44 | protected function configure() 45 | { 46 | $this 47 | ->setDescription('Adds a single job to allow testing of the job queue') 48 | ->addArgument( 49 | 'type', 50 | InputArgument::REQUIRED, 51 | 'The type of job to add. Should be one of `sleep`, `bad` (fatal error), `work` (cryptography) or `exception` (uncaught exception)' 52 | ) 53 | ->addArgument( 54 | 'topic', 55 | InputArgument::OPTIONAL, 56 | 'The topic of the test job (defaults to `test`)', 57 | 'test' 58 | ); 59 | } 60 | 61 | protected function execute(InputInterface $input, OutputInterface $output) 62 | { 63 | $type = $input->getArgument('type'); 64 | $topic = $input->getArgument('topic'); 65 | 66 | switch ($type) { 67 | case self::TYPE_SLEEP: 68 | $job = new SleepJob(['time' => 3], $topic); 69 | break; 70 | case self::TYPE_BAD: 71 | $job = new BadJob([], $topic); 72 | break; 73 | case self::TYPE_EXCEPTION: 74 | $job = new ExceptionJob([], $topic); 75 | break; 76 | case self::TYPE_WORK: 77 | $job = new WorkJob(['units' => 200, 'complexity' => 32], $topic); 78 | break; 79 | default: 80 | throw new \Exception(sprintf('Unknown job of type %s specified', $type)); 81 | break; 82 | } 83 | 84 | $job->validate(); 85 | $job->run($this->parameterBag); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Command/WriteCliConsumerConfigFileCommand.php: -------------------------------------------------------------------------------- 1 | cliConsumerConfigFileWriter = $cliConsumerConfigFileWriter; 26 | 27 | parent::__construct(); 28 | } 29 | 30 | /** 31 | * @see Command 32 | */ 33 | protected function configure() 34 | { 35 | $this 36 | ->setDescription('Writes a series of rabbitmq-cli-consumer config files (one per consumer)') 37 | ->addArgument( 38 | 'unique_environment', 39 | InputArgument::REQUIRED, 40 | 'A string representing the unique environment. E.G pre-staging' 41 | ); 42 | } 43 | 44 | protected function execute(InputInterface $input, OutputInterface $output) 45 | { 46 | $env = $input->getArgument('unique_environment'); 47 | $output->writeln('Started writing consumer configurations'); 48 | $this->cliConsumerConfigFileWriter->writeConfig($env); 49 | $output->writeln('Finished writing consumer configurations'); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Command/WriteSupervisordConfigFileCommand.php: -------------------------------------------------------------------------------- 1 | configFileWriter = $configFileWriter; 27 | } 28 | 29 | /** 30 | * @see Command 31 | */ 32 | protected function configure() 33 | { 34 | $this 35 | ->setDescription('Writes a supervisord config file to monitor rabbitmq consumers') 36 | ->addArgument( 37 | 'unique_environment', 38 | InputArgument::REQUIRED, 39 | 'A string representing the unique environment. E.G pre-staging' 40 | ); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output) 44 | { 45 | $env = $input->getArgument('unique_environment'); 46 | $output->writeln('Started writing queue configuration'); 47 | $this->configFileWriter->writeConfig($env); 48 | $output->writeln('Finished writing queue configuration'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Consumer/JobConsumer.php: -------------------------------------------------------------------------------- 1 | jobLogRepository = $jobLogRepository; 36 | $this->parameterBag = $parameterBag; 37 | $this->logger = $logger ?: new NullLogger(); 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function execute(AMQPMessage $message) 44 | { 45 | $data = json_decode($message->body, true); 46 | 47 | try { 48 | if (!isset($data['job_class'])) { 49 | throw new JobMissingClassException('`job_class` must be set in the message'); 50 | } 51 | // rehydrate job class 52 | $jobClass = $data['job_class']; 53 | unset($data['job_class']); 54 | $job = new $jobClass($data); 55 | if (!$job instanceof Job) { 56 | throw new \LogicException('This consumer can only consume instances of Markup\JobQueueBundle\Model\Job but job of following type was given: '.get_class($job)); 57 | } 58 | $job->validate(); 59 | $output = $job->run($this->parameterBag); 60 | 61 | if (isset($data['uuid'])) { 62 | try { 63 | $this->jobLogRepository->saveOutput( 64 | $data['uuid'], 65 | strval($output) 66 | ); 67 | } catch (\Throwable $t) { 68 | // do nothing 69 | } 70 | } 71 | 72 | } catch (\Throwable $e) { 73 | $command = ''; 74 | 75 | if ((isset($job))) { 76 | $command = get_class($job); 77 | if ($job instanceof ConsoleCommandJob) { 78 | $command = $job->getCommand(); 79 | } 80 | } 81 | 82 | $exitCode = null; 83 | $output = sprintf('%s - %s', $e->getMessage(), $e->getTraceAsString()); 84 | if ($e instanceof JobFailedException) { 85 | $exitCode = intval($e->getExitCode()); 86 | } 87 | // save failure if job had uuid 88 | if (isset($data['uuid'])) { 89 | try { 90 | $this->jobLogRepository->saveFailure( 91 | $data['uuid'], 92 | strval($output), 93 | $exitCode ?? 1 94 | ); 95 | } catch (\Throwable $t) { 96 | // do nothing 97 | } 98 | } 99 | 100 | $this->logger->error( 101 | sprintf('Job Failed: %s', $command), 102 | [ 103 | 'exception' => get_class($e), 104 | 'message' => $e->getMessage(), 105 | 'line' => $e->getLine(), 106 | 'file' => $e->getFile(), 107 | 'trace' => $e->getTraceAsString(), 108 | ] 109 | ); 110 | 111 | return ConsumerInterface::MSG_REJECT; 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Controller/RecurringViewController.php: -------------------------------------------------------------------------------- 1 | render( 19 | 'MarkupJobQueueBundle:View:recurring.html.twig', 20 | array( 21 | 'recurringReader' => $this->get('markup_job_queue.reader.recurring_console_command'), 22 | ) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | root('markup_job_queue'); 22 | 23 | // can specify a list of allowed queues 24 | // can specify a configuration file (.yml) that can be evaluated using cron syntax 25 | $rootNode 26 | ->children() 27 | ->arrayNode('topics') 28 | ->useAttributeAsKey('name') 29 | ->prototype('array') 30 | ->addDefaultsIfNotSet() 31 | ->children() 32 | ->integerNode('prefetch_count') 33 | ->defaultValue(5) 34 | ->min(5) 35 | ->end() 36 | ->scalarNode('consumer') 37 | ->defaultValue('markup:job_queue:consumer') 38 | ->end() 39 | ->end() 40 | ->end() 41 | ->end() 42 | ->arrayNode('rabbitmq') 43 | ->addDefaultsIfNotSet() 44 | ->children() 45 | ->scalarNode('host') 46 | ->info('RabbitMQ host') 47 | ->defaultValue('localhost') 48 | ->end() 49 | ->scalarNode('username') 50 | ->info('RabbitMQ username') 51 | ->defaultValue('guest') 52 | ->end() 53 | ->scalarNode('password') 54 | ->info('RabbitMQ password') 55 | ->defaultValue('guest') 56 | ->end() 57 | ->scalarNode('vhost') 58 | ->info('RabbitMQ vhost') 59 | ->defaultValue('/') 60 | ->end() 61 | ->scalarNode('port') 62 | ->info('RabbitMQ port') 63 | ->defaultValue('5672') 64 | ->end() 65 | ->end() 66 | ->end() 67 | ->arrayNode('cli_consumer') 68 | ->addDefaultsIfNotSet() 69 | ->children() 70 | ->booleanNode('enabled') 71 | ->info('If enabled, the supervisord config writer will use the golang cli-consumer instead of the php consumer') 72 | ->defaultFalse() 73 | ->end() 74 | ->scalarNode('log_path') 75 | ->info('The path into which to place rabbit-cli-consumer log files') 76 | ->defaultValue('/var/log/rabbitmq-cli-consumer') 77 | ->end() 78 | ->scalarNode('config_path') 79 | ->info('The path into which to place rabbit-cli-consumer configuration files') 80 | ->defaultValue('/etc/rabbit-cli-consumer/config') 81 | ->end() 82 | ->scalarNode('consumer_path') 83 | ->info('The full path to the binary rabbit-cli-consumer') 84 | ->defaultValue('/usr/local/bin/rabbitmq-cli-consumer') 85 | ->end() 86 | ->end() 87 | ->end() 88 | ->scalarNode('recurring') 89 | ->info('The path to a .yml file containing configuration for recurring jobs') 90 | ->validate() 91 | ->ifTrue(function ($v) { 92 | if ($v == false) { 93 | return false; 94 | } 95 | //check that the file has a .yml extension 96 | return (strpos($v, '.yml') === false); 97 | })->thenInvalid('Recurring Console Command configuration must be in .yml format')->end() 98 | ->defaultFalse() 99 | ->end() 100 | ->scalarNode('supervisor_config_path') 101 | ->info('Path to store supervisord configuration files. Your supervisord configuration should load all files from this path') 102 | ->defaultValue('/etc/supervisord/conf.d/') 103 | ->end() 104 | ->integerNode('job_logging_ttl') 105 | ->defaultValue(1209600) 106 | ->end() 107 | ->booleanNode('clear_log_for_complete_jobs') 108 | ->defaultFalse() 109 | ->end() 110 | ->booleanNode('use_root_dir_for_symfony_console') 111 | ->defaultFalse() 112 | ->info('Choose whether to use a Symfony app root directory for the location of the Symfony console script. Defaults to app root for Symfony 2, and bin/ for Symfony 3+.') 113 | ->end() 114 | ->end() 115 | ->end(); 116 | 117 | return $treeBuilder; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /DependencyInjection/MarkupJobQueueExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 29 | 30 | $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 31 | $loader->load('services.yml'); 32 | $loader->load('commands.yml'); 33 | 34 | $this->registerRecurringConfigurationFile($config, $container); 35 | $this->addSupervisordConfig($config, $container); 36 | $this->addCliConsumerConfig($config, $container); 37 | $this->configureRabbitMqApiClient($config, $container); 38 | $this->configureJobLogRepository($config, $container); 39 | $this->configureConsoleDirectory($config, $container); 40 | } 41 | 42 | /** 43 | * Stores the configured .yml file for use by the recurring console command reader 44 | * @param array $config 45 | * @param ContainerBuilder $container 46 | */ 47 | private function registerRecurringConfigurationFile(array $config, ContainerBuilder $container) 48 | { 49 | if ($config['recurring'] !== false) { 50 | $recurringConsoleCommandReader = $container->getDefinition('markup_job_queue.reader.recurring_console_command'); 51 | $recurringConsoleCommandReader->addMethodCall('setConfigurationFileName', [$config['recurring']]); 52 | } 53 | } 54 | 55 | /** 56 | * If Supervisord config variables have been set then set them against the container as parameters 57 | * 58 | * @param array $config 59 | * @param ContainerBuilder $container 60 | * 61 | * @throws InvalidConfigurationException 62 | */ 63 | private function addSupervisordConfig(array $config, ContainerBuilder $container) 64 | { 65 | $configFileWriter = $container->getDefinition('markup_job_queue.writer.supervisord_config_file'); 66 | if (!$config['topics']) { 67 | throw new InvalidConfigurationException('markup_job_queue requires that at least 1 `topic` is configured'); 68 | } 69 | $configFileWriter->replaceArgument(3, $config['supervisor_config_path']); 70 | $configFileWriter->replaceArgument(4, $config['cli_consumer']['consumer_path']); 71 | $configFileWriter->replaceArgument(5, $config['cli_consumer']['config_path']); 72 | $configFileWriter->addMethodCall('setTopicsConfiguration', [$config['topics']]); 73 | 74 | if ($config['cli_consumer']['enabled'] === true) { 75 | $configFileWriter->addMethodCall('setMode', [SupervisordConfigFileWriter::MODE_CLI]); 76 | } 77 | } 78 | 79 | /** 80 | * @param array $config 81 | * @param ContainerBuilder $container 82 | * 83 | * @throws InvalidConfigurationException 84 | */ 85 | private function addCliConsumerConfig(array $config, ContainerBuilder $container) 86 | { 87 | $configFileWriter = $container->getDefinition('markup_job_queue.writer.cli_consumer_config_file'); 88 | if (!$config['topics']) { 89 | throw new InvalidConfigurationException('markup_job_queue requires that at least 1 `topic` is configured'); 90 | } 91 | 92 | $cliConsumerConfig = $config['cli_consumer']; 93 | $rabbitConfig = $config['rabbitmq']; 94 | 95 | $configFileWriter->setArguments([ 96 | $cliConsumerConfig['log_path'], 97 | $cliConsumerConfig['config_path'], 98 | $rabbitConfig['host'], 99 | $rabbitConfig['username'], 100 | $rabbitConfig['password'], 101 | $rabbitConfig['vhost'], 102 | $rabbitConfig['port'], 103 | ]); 104 | $configFileWriter->addMethodCall('setTopicsConfiguration', [$config['topics']]); 105 | } 106 | 107 | /** 108 | * Configures the RabbitMQ Api client to allow api connection 109 | * 110 | * @param array $config 111 | * @param ContainerBuilder $container 112 | */ 113 | private function configureRabbitMqApiClient(array $config, ContainerBuilder $container) 114 | { 115 | $rabbitMqClient = $container->getDefinition('markup_job_queue.rabbit_mq_api.client'); 116 | $rabbitConfig = $config['rabbitmq']; 117 | 118 | $baseUrl = sprintf('http://%s:15672', $rabbitConfig['host']); 119 | $rabbitMqClient->setArguments([ 120 | $baseUrl, 121 | $rabbitConfig['username'], 122 | $rabbitConfig['password'], 123 | ]); 124 | 125 | $queueReader = $container->getDefinition('markup_job_queue.reader.queue'); 126 | 127 | $queueReader->replaceArgument(1, $rabbitConfig['vhost']); 128 | } 129 | 130 | private function configureJobLogRepository(array $config, ContainerBuilder $container) 131 | { 132 | $repository = $container->getDefinition('markup_job_queue.repository.job_log'); 133 | $repository->addMethodCall('setTtl', [$config['job_logging_ttl']]); 134 | $repository->addMethodCall('setShouldClearLogForCompleteJob', [$config['clear_log_for_complete_jobs']]); 135 | } 136 | 137 | private function configureConsoleDirectory(array $config, ContainerBuilder $container) 138 | { 139 | $parameter = 'markup_job_queue.console_dir'; 140 | 141 | if ($config['use_root_dir_for_symfony_console']) { 142 | $container->setParameter($parameter, $container->getParameter('kernel.root_dir')); 143 | 144 | return; 145 | } 146 | 147 | $container->setParameter($parameter, $container->getParameter('kernel.project_dir') . '/bin'); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Entity/JobLog.php: -------------------------------------------------------------------------------- 1 | command = $command; 74 | $this->uuid = $uuid; 75 | $this->topic = $topic; 76 | $this->added = $added ?? new \DateTime(); 77 | $this->status = self::STATUS_ADDED; 78 | } 79 | 80 | public function getCommand(): string 81 | { 82 | return $this->command; 83 | } 84 | 85 | public function getTopic(): string 86 | { 87 | return $this->topic; 88 | } 89 | 90 | public function getAdded(): ?\DateTime 91 | { 92 | return $this->added; 93 | } 94 | 95 | public function getCompleted(): ?\DateTime 96 | { 97 | return $this->completed; 98 | } 99 | 100 | /** 101 | * Returns duration in seconds 102 | * @return int 103 | */ 104 | public function getDuration(): int 105 | { 106 | $completed = $this->getCompleted(); 107 | $started = $this->getStarted(); 108 | 109 | if (!$completed || !$started) { 110 | return 0; 111 | } 112 | 113 | return $completed->getTimestamp() - $started->getTimestamp(); 114 | } 115 | 116 | public function getStatus(): string 117 | { 118 | return $this->status; 119 | } 120 | 121 | public function getOutput(): ?string 122 | { 123 | return $this->output; 124 | } 125 | 126 | public function getPeakMemoryUse(): ?int 127 | { 128 | return $this->peakMemoryUse; 129 | } 130 | 131 | public function getUuid(): string 132 | { 133 | return $this->uuid; 134 | } 135 | 136 | public function getExitCode(): int 137 | { 138 | return $this->exitCode ?? 0; 139 | } 140 | 141 | public function setCompleted(?\DateTime $completed = null) 142 | { 143 | $this->completed = $completed; 144 | } 145 | 146 | public function setStatus(string $status) 147 | { 148 | $this->status = $status; 149 | } 150 | 151 | public function setOutput(string $output) 152 | { 153 | $this->output = $output; 154 | } 155 | 156 | public function setPeakMemoryUse(?int $peakMemoryUse = null) 157 | { 158 | $this->peakMemoryUse = $peakMemoryUse; 159 | } 160 | 161 | public function setExitCode(int $exitCode) 162 | { 163 | $this->exitCode = $exitCode; 164 | } 165 | 166 | public function getStarted(): ?\DateTime 167 | { 168 | return $this->started; 169 | } 170 | 171 | public function setStarted(?\DateTime $started = null): void 172 | { 173 | $this->started = $started; 174 | } 175 | 176 | public function getExitCodeText(): string 177 | { 178 | if (!$this->exitCode) { 179 | return ''; 180 | } 181 | 182 | $text = isset(Process::$exitCodes[$this->getExitCode()]) ? Process::$exitCodes[$this->getExitCode()] : ''; 183 | 184 | return sprintf('Exit code `%s`: %s', $this->getExitCode(), $text); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Entity/JobStatus.php: -------------------------------------------------------------------------------- 1 | id = $id; 27 | $this->command = $command; 28 | $this->arguments = $arguments; 29 | $this->enabled = $enabled; 30 | } 31 | 32 | public function getCommand(): string 33 | { 34 | return $this->command; 35 | } 36 | 37 | public function getArguments(): string 38 | { 39 | return $this->arguments; 40 | } 41 | 42 | public function getEnabled(): bool 43 | { 44 | return $this->enabled; 45 | } 46 | 47 | public function setCommand(string $command): void 48 | { 49 | $this->command = $command; 50 | } 51 | 52 | public function setArguments(string $arguments): void 53 | { 54 | $this->arguments = $arguments; 55 | } 56 | 57 | public function setEnabled(bool $enabled): void 58 | { 59 | $this->enabled = $enabled; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Entity/Repository/DoctrineOrmAwareRepositoryTrait.php: -------------------------------------------------------------------------------- 1 | ensureDatabaseConnectionIsOpen(); 26 | 27 | $repository = $this->doctrine->getRepository($this->entity); 28 | 29 | if ($repository instanceof EntityRepository) { 30 | return $repository; 31 | } 32 | 33 | throw new \RuntimeException(sprintf('Doctrine returned an invalid repository for entity %s', $this->entity)); 34 | } 35 | 36 | protected function getEntityManager(): EntityManager 37 | { 38 | $manager = $this->doctrine->getManager(); 39 | 40 | if ($manager instanceof EntityManagerInterface && !$manager->isOpen()) { 41 | $manager = $this->doctrine->resetManager(); 42 | } 43 | 44 | if (!$manager instanceof EntityManager) { 45 | throw new \RuntimeException('Doctrine returned an invalid manager'); 46 | } 47 | 48 | return $manager; 49 | } 50 | 51 | private function ensureDatabaseConnectionIsOpen(): void 52 | { 53 | $manager = $this->doctrine->getManager(); 54 | 55 | if ($manager instanceof EntityManagerInterface && !$manager->isOpen()) { 56 | $this->doctrine->resetManager(); 57 | } 58 | 59 | return; 60 | } 61 | 62 | /** 63 | * @param object $entity 64 | * @throws \Doctrine\ORM\ORMException 65 | */ 66 | protected function persist($entity): void 67 | { 68 | $this->getEntityManager()->persist($entity); 69 | } 70 | 71 | protected function flush($entity): void 72 | { 73 | $this->getEntityManager()->flush($entity); 74 | } 75 | 76 | /** 77 | * @param object $entity 78 | */ 79 | protected function remove($entity): void 80 | { 81 | $this->getEntityManager()->remove($entity); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Entity/Repository/ScheduledJobRepository.php: -------------------------------------------------------------------------------- 1 | doctrine = $doctrine; 20 | $this->entity = ScheduledJob::class; 21 | } 22 | 23 | /** 24 | * @return ?iterable 25 | */ 26 | public function fetchUnqueuedJobs() 27 | { 28 | $qb = $this->getEntityRepository() 29 | ->createQueryBuilder('job'); 30 | $qb->andWhere($qb->expr()->eq('job.queued', ':queued')); 31 | $qb->andWhere($qb->expr()->lt('job.scheduledTime', ':now')); 32 | $qb->setParameter(':queued', false); 33 | $qb->setParameter(':now', new \DateTime('now')); 34 | $jobs = $qb->getQuery()->getResult(); 35 | 36 | if (count($jobs) > 0) { 37 | return $jobs; 38 | } 39 | 40 | return null; 41 | } 42 | 43 | public function isJobScheduledWithinRange( 44 | string $job, 45 | \DateTime $rangeFrom, 46 | \DateTime $rangeTo, 47 | ?array $arguments 48 | ): bool { 49 | $qb = $this->getEntityRepository() 50 | ->createQueryBuilder('j') 51 | ->select('COUNT(1)') 52 | ->where('j.job = :job') 53 | ->andWhere('j.scheduledTime >= :from') 54 | ->andWhere('j.scheduledTime <= :to') 55 | ->setParameter('job', $job) 56 | ->setParameter('from', $rangeFrom) 57 | ->setParameter('to', $rangeTo); 58 | 59 | if ($arguments) { 60 | $qb 61 | ->andWhere('j.arguments = :arguments') 62 | ->setParameter('arguments', serialize($arguments)); 63 | } 64 | try { 65 | return boolval($qb->getQuery()->getSingleScalarResult()); 66 | } catch (NoResultException|NonUniqueResultException $e) { 67 | return false; 68 | } 69 | } 70 | 71 | public function hasUnQueuedDuplicate(string $job, ?array $arguments): bool 72 | { 73 | $qb = $this->getEntityRepository() 74 | ->createQueryBuilder('j') 75 | ->select('COUNT(1)') 76 | ->where('j.job = :job') 77 | ->andWhere('j.queued = :queued') 78 | ->andWhere('j.scheduledTime <= :now') 79 | ->setParameter('queued', false) 80 | ->setParameter('job', $job) 81 | ->setParameter('now', (new \DateTime())); 82 | 83 | if ($arguments) { 84 | $qb 85 | ->andWhere('j.arguments = :arguments') 86 | ->setParameter('arguments', serialize($arguments)); 87 | } 88 | 89 | try { 90 | return boolval($qb->getQuery()->getSingleScalarResult()); 91 | } catch (NoResultException|NonUniqueResultException $e) { 92 | return false; 93 | } 94 | } 95 | 96 | public function save(ScheduledJob $scheduledJob, $flush = false): void 97 | { 98 | $this->persist($scheduledJob); 99 | 100 | if ($flush) { 101 | $this->flush($scheduledJob); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Entity/ScheduledJob.php: -------------------------------------------------------------------------------- 1 | job = $job; 57 | $this->arguments = $arguments; 58 | $this->scheduledTime = $scheduledTime; 59 | $this->topic = $topic; 60 | $this->queued = false; 61 | } 62 | 63 | 64 | /** 65 | * @param int $id 66 | */ 67 | public function setId($id) 68 | { 69 | $this->id = $id; 70 | } 71 | 72 | /** 73 | * @return int 74 | */ 75 | public function getId() 76 | { 77 | return $this->id; 78 | } 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function getJob() 84 | { 85 | return $this->job; 86 | } 87 | 88 | public function getArguments(): array 89 | { 90 | return $this->arguments; 91 | } 92 | 93 | /** 94 | * @return string 95 | */ 96 | public function getTopic() 97 | { 98 | return $this->topic; 99 | } 100 | 101 | /** 102 | * @return \DateTime 103 | */ 104 | public function getScheduledTime() 105 | { 106 | return $this->scheduledTime; 107 | } 108 | 109 | /** 110 | * @param boolean $queued 111 | */ 112 | public function setQueued($queued) 113 | { 114 | $this->queued = $queued; 115 | } 116 | 117 | /** 118 | * @return boolean 119 | */ 120 | public function getQueued() 121 | { 122 | return $this->queued; 123 | } 124 | 125 | /** 126 | * @param \DateTime $created 127 | */ 128 | public function setCreated($created) 129 | { 130 | $this->created = $created; 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * @return \DateTime 137 | */ 138 | public function getCreated() 139 | { 140 | return $this->created; 141 | } 142 | 143 | /** 144 | * @param \DateTime $updated 145 | */ 146 | public function setUpdated($updated) 147 | { 148 | $this->updated = $updated; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * @return \DateTime 155 | */ 156 | public function getUpdated() 157 | { 158 | return $this->updated; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /EventSubscriber/AddUuidOptionConsoleCommandEventSubscriber.php: -------------------------------------------------------------------------------- 1 | [ 24 | ['onConsoleCommand', 100], 25 | ['bindInput', -9999998], 26 | ] 27 | ]; 28 | } 29 | 30 | /** 31 | * @param ConsoleCommandEvent $event 32 | */ 33 | public function onConsoleCommand(ConsoleCommandEvent $event) 34 | { 35 | $inputOption = new InputOption( 36 | 'uuid', 37 | null, 38 | InputOption::VALUE_OPTIONAL, 39 | 'The uuid of this console command. Should be unique for this console command at this time', 40 | null 41 | ); 42 | 43 | $command = $event->getCommand(); 44 | 45 | if (!$command) { 46 | return; 47 | } 48 | 49 | $definition = $command->getDefinition(); 50 | $definition->addOption($inputOption); 51 | } 52 | 53 | public function bindInput(ConsoleCommandEvent $event) 54 | { 55 | $command = $event->getCommand(); 56 | 57 | if (!$command) { 58 | return; 59 | } 60 | 61 | $event->getInput()->bind($command->getDefinition()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /EventSubscriber/CompleteConsoleCommandEventSubscriber.php: -------------------------------------------------------------------------------- 1 | jobLogRepository = $jobLogRepository; 35 | } 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public static function getSubscribedEvents() 41 | { 42 | return [ 43 | ConsoleEvents::TERMINATE => [ 44 | ['onConsoleTerminate', 10] 45 | ], 46 | ConsoleEvents::ERROR => [ 47 | ['onConsoleError', 10] 48 | ], 49 | ]; 50 | } 51 | 52 | public function onConsoleTerminate(ConsoleTerminateEvent $event): void 53 | { 54 | $input = $event->getInput(); 55 | 56 | if (!$input->hasOption('uuid')) { 57 | return; 58 | } 59 | 60 | $uuid = $input->getOption('uuid'); 61 | 62 | if (!$uuid) { 63 | return; 64 | } 65 | 66 | $log = $this->jobLogRepository->findJobLog(strval($uuid)); 67 | 68 | if (!$log) { 69 | return; 70 | } 71 | 72 | if ($log->getStatus() === JobLog::STATUS_RUNNING) { 73 | $log->setStatus(JobLog::STATUS_COMPLETE); 74 | } 75 | 76 | $log->setPeakMemoryUse(memory_get_peak_usage(true)); 77 | $log->setCompleted(new \DateTime()); 78 | 79 | $this->jobLogRepository->save($log); 80 | } 81 | 82 | public function onConsoleError(ConsoleEvent $event): void 83 | { 84 | $input = $event->getInput(); 85 | 86 | if (!$input->hasOption('uuid')) { 87 | return; 88 | } 89 | 90 | $uuid = $input->getOption('uuid'); 91 | 92 | if (!$uuid) { 93 | return; 94 | } 95 | 96 | $log = $this->jobLogRepository->findJobLog(strval($uuid)); 97 | if (!$log) { 98 | return; 99 | } 100 | 101 | $log->setStatus(JobLog::STATUS_FAILED); 102 | $this->jobLogRepository->save($log); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /EventSubscriber/LogConsoleCommandEventSubscriber.php: -------------------------------------------------------------------------------- 1 | jobLogRepository = $jobLogRepository; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public static function getSubscribedEvents() 36 | { 37 | return [ 38 | ConsoleEvents::COMMAND => [ 39 | ['onConsoleCommand', -9999999] 40 | ] 41 | ]; 42 | } 43 | 44 | public function onConsoleCommand(ConsoleCommandEvent $event): void 45 | { 46 | $input = $event->getInput(); 47 | 48 | if (!$input->hasOption('uuid')) { 49 | return; 50 | } 51 | 52 | $uuid = $input->getOption('uuid'); 53 | 54 | if (!$uuid) { 55 | return; 56 | } 57 | 58 | $uuid = strval($uuid); 59 | 60 | $log = $this->jobLogRepository->findJobLog($uuid); 61 | 62 | if (!$log) { 63 | return; 64 | } 65 | 66 | $log->setStatus(JobLog::STATUS_RUNNING); 67 | $log->setStarted(new \DateTime()); 68 | 69 | $this->jobLogRepository->save($log); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Exception/InvalidConfigurationException.php: -------------------------------------------------------------------------------- 1 | exitCode = $exitCode; 27 | parent::__construct($message, $code, $previous); 28 | } 29 | 30 | /** 31 | * @return null 32 | */ 33 | public function getExitCode() 34 | { 35 | return $this->exitCode; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Exception/JobMissingClassException.php: -------------------------------------------------------------------------------- 1 | page = 1; 40 | } 41 | 42 | public function getId(): ?string 43 | { 44 | return $this->id; 45 | } 46 | 47 | public function setId(string $id) 48 | { 49 | $this->id = $id; 50 | } 51 | 52 | public function getCommand(): ?string 53 | { 54 | return $this->command; 55 | } 56 | 57 | public function setCommand(string $command) 58 | { 59 | $this->command = $command; 60 | } 61 | 62 | public function getBefore(): ?\DateTime 63 | { 64 | return $this->before; 65 | } 66 | 67 | public function setBefore(?\DateTime $before = null) 68 | { 69 | $this->before = $before; 70 | } 71 | 72 | public function getSince(): ?\DateTime 73 | { 74 | return $this->since; 75 | } 76 | 77 | public function setSince(?\DateTime $since = null) 78 | { 79 | $this->since = $since; 80 | } 81 | 82 | public function getStatus(): ?string 83 | { 84 | return $this->status; 85 | } 86 | 87 | public function setStatus(string $status) 88 | { 89 | $this->status = $status; 90 | } 91 | 92 | /** 93 | * @return bool Is search for a single id? 94 | */ 95 | public function isIdSearch() 96 | { 97 | return $this->getId() !== null; 98 | } 99 | 100 | /** 101 | * Is this a search for a commandId or Status? 102 | * 103 | * @return bool 104 | */ 105 | public function isDiscriminatorSearch() 106 | { 107 | if ($this->getStatus() || $this->getCommand()) { 108 | return true; 109 | } 110 | return false; 111 | } 112 | 113 | /** 114 | * @return bool Is this a search for datetime range only 115 | */ 116 | public function isRangeSearchOnly() 117 | { 118 | if (!$this->getBefore() && $this->getSince()) { 119 | return false; 120 | } 121 | if (!$this->getStatus() && !$this->getCommand() && !$this->getId()) { 122 | return true; 123 | } 124 | return false; 125 | } 126 | 127 | public function setPage(int $page) 128 | { 129 | $this->page = $page; 130 | } 131 | 132 | public function getPage(): int 133 | { 134 | return $this->page; 135 | } 136 | 137 | public function toArray() 138 | { 139 | return [ 140 | 'id' => $this->getId(), 141 | 'since' => $this->getSince(), 142 | 'before' => $this->getBefore(), 143 | 'status' => $this->getStatus(), 144 | 'command' => $this->getCommand() 145 | ]; 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /Form/Handler/SearchJobLogs.php: -------------------------------------------------------------------------------- 1 | jobLogRepository = $jobLogRepository; 25 | } 26 | 27 | /** 28 | * @param SearchJobLogsData $options 29 | * 30 | * @param int $page 31 | * 32 | * @return Pagerfanta 33 | */ 34 | public function handle(SearchJobLogsData $options, int $page = 1) 35 | { 36 | return $this->jobLogRepository->getJobLogs($options, 10, $page); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Form/Type/SearchJobLogs.php: -------------------------------------------------------------------------------- 1 | add( 20 | 'id', 21 | TextType::class, 22 | [ 23 | 'required' => false, 24 | 'label' => 'Uuid' 25 | ] 26 | )->add( 27 | 'command', 28 | TextType::class, 29 | [ 30 | 'required' => false, 31 | 'label' => 'Command' 32 | ] 33 | )->add( 34 | 'since', 35 | DateTimeType::class, 36 | [ 37 | 'widget' => 'single_text', 38 | 'attr' => ['data-dtime-format' => "YYYY-MM-DDTHH:mm:ssZ"], 39 | 'label' => 'Added After', 40 | 'required' => false, 41 | 'html5' => false, 42 | ] 43 | )->add( 44 | 'before', 45 | DateTimeType::class, 46 | [ 47 | 'widget' => 'single_text', 48 | 'attr' => ['data-dtime-format' => "YYYY-MM-DDTHH:mm:ssZ"], 49 | 'label' => 'Added Before', 50 | 'required' => false, 51 | 'html5' => false, 52 | ] 53 | )->add( 54 | 'status', 55 | ChoiceType::class, 56 | [ 57 | 'required' => false, 58 | 'multiple' => false, 59 | 'empty_data' => null, 60 | 'choices' => [ 61 | 'Added' => JobLog::STATUS_ADDED, 62 | 'Running' => JobLog::STATUS_RUNNING, 63 | 'Failed' => JobLog::STATUS_FAILED, 64 | 'Complete' => JobLog::STATUS_COMPLETE, 65 | ], 66 | 'choices_as_values' => true, 67 | ] 68 | )->add( 69 | 'search', 70 | SubmitType::class, 71 | ['attr' => ['class' => 'btn-info'], 'icon' => 'search', 'label' => 'Search'] 72 | ); 73 | } 74 | 75 | public function configureOptions(OptionsResolver $resolver) 76 | { 77 | $resolver->setDefaults([ 78 | 'method' => 'GET', 79 | 'data_class' => SearchJobLogsData::class, 80 | 'csrf_protection' => false 81 | ]); 82 | } 83 | 84 | public function getBlockPrefix() 85 | { 86 | return 'phoenix_admin_search_job_logs'; 87 | } 88 | 89 | public function getName() 90 | { 91 | return $this->getBlockPrefix(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Job/BadJob.php: -------------------------------------------------------------------------------- 1 | get('markup_job_queue.php_bin_path'); 26 | $command[] = $this->getConsolePath($parameterBag->get('markup_job_queue.console_dir')); 27 | $command[] = $this->getCommand(); 28 | 29 | foreach ($this->getArguments() as $argument) { 30 | $command[] = $argument; 31 | }; 32 | 33 | $uuid = isset($this->args['uuid']) ? $this->args['uuid']: null; 34 | if($uuid) { 35 | $command[] = sprintf('--uuid=%s', $uuid); 36 | } 37 | if ($parameterBag->get('kernel.debug') !== true) { 38 | $command[] = sprintf('--no-debug'); 39 | } 40 | 41 | $command[] = sprintf('--env=%s', $parameterBag->get('kernel.environment')); 42 | 43 | $process = new Process($command); 44 | 45 | if (!isset($this->args['timeout'])) { 46 | $this->args['timeout'] = 60; 47 | } 48 | $process->setTimeout((int) $this->args['timeout']); 49 | if (!isset($this->args['idleTimeout'])) { 50 | $this->args['idleTimeout'] = $process->getIdleTimeout(); 51 | } 52 | $process->setIdleTimeout((int) $this->args['idleTimeout']); 53 | 54 | try { 55 | $process->run(); 56 | 57 | if (!$process->isSuccessful()) { 58 | $message = sprintf( 59 | 'A job `%s` failed with topic `%s` with output:%s and the error output: %s', 60 | $this->getCommand(), 61 | $this->topic, 62 | $process->getOutput(), 63 | $process->getErrorOutput() 64 | ); 65 | throw new JobFailedException($message, $process->getExitCode()); 66 | } 67 | return strval($process->getOutput()); 68 | } catch (ProcessTimedOutException $e) { 69 | if ($e->isGeneralTimeout()) { 70 | throw new JobFailedException(sprintf('Timeout: %s', $e->getMessage()), $process->getExitCode(), 0, $e); 71 | } 72 | 73 | if ($e->isIdleTimeout()) { 74 | throw new JobFailedException(sprintf('Idle Timeout: %s', $e->getMessage()), $process->getExitCode(), 0, $e); 75 | } 76 | 77 | throw $e; 78 | } catch (JobFailedException $e) { 79 | throw $e; 80 | } catch (RuntimeException $e) { 81 | throw new JobFailedException($e->getMessage(), $process->getExitCode(), $e->getCode(), $e); 82 | } catch (\Throwable $e) { 83 | throw new JobFailedException($e->getMessage(), $process->getExitCode(), $e->getCode(), $e); 84 | } 85 | } 86 | 87 | /** 88 | * {inheritdoc} 89 | */ 90 | public function validate() 91 | { 92 | if (!isset($this->args['command'])) { 93 | throw new InvalidJobArgumentException('`command` must be set'); 94 | } 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getCommand() 101 | { 102 | return $this->getArgs()['command']; 103 | } 104 | 105 | public function getArguments(): array 106 | { 107 | return $this->getArgs()['arguments'] ?? []; 108 | } 109 | 110 | /** 111 | * @param string $kernelDir 112 | * @return string 113 | */ 114 | private function getConsolePath($kernelDir) 115 | { 116 | $finder = new Finder(); 117 | $finder->name('console')->depth(0)->in($kernelDir); 118 | $results = iterator_to_array($finder); 119 | $file = current($results); 120 | 121 | return sprintf('%s/%s', $file->getPath(), $file->getBasename()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Job/ExceptionJob.php: -------------------------------------------------------------------------------- 1 | args['time'])) { 21 | throw new InvalidJobArgumentException('`time` must be set'); 22 | } 23 | if (!is_numeric($this->args['time'])) { 24 | throw new InvalidJobArgumentException('time must be an integer'); 25 | } 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function run(ParameterBagInterface $parameterBag): string 32 | { 33 | $process = new Process(['sleep', $this->args['time']]); 34 | $process->run(); 35 | 36 | return ''; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Job/WorkJob.php: -------------------------------------------------------------------------------- 1 | args['units'])) { 20 | throw new InvalidJobArgumentException('`units` must be set'); 21 | } 22 | if (!isset($this->args['complexity'])) { 23 | throw new InvalidJobArgumentException('`complexity` must be set'); 24 | } 25 | if (!is_numeric($this->args['units']) || !is_numeric($this->args['complexity'])) { 26 | throw new InvalidJobArgumentException('`units` & `complexity` must both be integers'); 27 | } 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function run(ParameterBagInterface $parameterBag): string 34 | { 35 | $garbage = ''; 36 | $completed = 0; 37 | while ($completed < $this->args['units']) { 38 | $garbage .= password_hash(openssl_random_pseudo_bytes($this->args['complexity']), PASSWORD_BCRYPT); 39 | $completed++; 40 | } 41 | 42 | return ''; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Markup Digital Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MarkupJobQueueBundle.php: -------------------------------------------------------------------------------- 1 | args = $args; 28 | $this->topic = str_replace('-', '_', $topic); 29 | } 30 | 31 | /** 32 | * @return string 33 | */ 34 | public function getTopic() 35 | { 36 | return $this->topic; 37 | } 38 | 39 | /** 40 | * @return array 41 | */ 42 | public function getArgs() 43 | { 44 | return $this->args; 45 | } 46 | 47 | abstract public function run(ParameterBagInterface $parameterBag): string; 48 | 49 | /** 50 | * To be run after job constructed to check arguments are correct 51 | */ 52 | public function validate() 53 | { 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Model/JobLogCollection.php: -------------------------------------------------------------------------------- 1 | count(); 22 | if ($total === 0) { 23 | return 0; 24 | } 25 | $completed = 0; 26 | 27 | foreach($this as $log) { 28 | if ($log->getStatus() !== JobLog::STATUS_FAILED){ 29 | $completed++; 30 | } 31 | } 32 | 33 | if($completed === 0) { 34 | return 0; 35 | } 36 | return round($completed/$total, 2); 37 | } 38 | 39 | /** 40 | * Returns the average duration of completion for the jobs in this collection 41 | * 42 | * @return int 43 | */ 44 | public function getAverageDuration() 45 | { 46 | $withDuration = 0; 47 | $totalDuration = 0; 48 | foreach($this as $log) { 49 | if ($log->getDuration()){ 50 | $withDuration++; 51 | $totalDuration = $totalDuration + $log->getDuration(); 52 | } 53 | } 54 | if (!$withDuration || !$totalDuration) { 55 | return 0; 56 | } 57 | return (int)floor($totalDuration/$withDuration); 58 | } 59 | 60 | /** 61 | * Gets the highest memory use for jobs in this collection 62 | * 63 | * @return int 64 | */ 65 | public function getPeakMemoryUse() 66 | { 67 | $peak = 0; 68 | foreach($this as $log) { 69 | if ($log->getPeakMemoryUse() && $log->getPeakMemoryUse() > $peak){ 70 | $peak = $log->getPeakMemoryUse(); 71 | } 72 | } 73 | return $peak; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Model/Queue.php: -------------------------------------------------------------------------------- 1 | name = $name; 60 | $this->vhost = $vhost; 61 | $this->state = $state; 62 | $this->consumerCount = $consumerCount; 63 | $this->messages = $messages; 64 | $this->messagesReady = $messagesReady; 65 | $this->messagesUnacknowledged = $messagesUnacknowledged; 66 | $this->idleSince = $idleSince; 67 | } 68 | 69 | /** 70 | * Takes the response from the RabbitMq Api for queues and uses it to build an instance of this class 71 | * 72 | * @param array $response 73 | * @return Queue 74 | */ 75 | public static function constructFromApiResponse(array $response) 76 | { 77 | return new self( 78 | isset($response['name']) ? $response['name'] : 'undefined', 79 | isset($response['vhost']) ? $response['vhost'] : 'undefined', 80 | isset($response['state']) ? $response['state'] : 'unknown', 81 | isset($response['consumers']) ? $response['consumers'] : 0, 82 | isset($response['messages']) ? $response['messages'] : 0, 83 | isset($response['messages_ready']) ? $response['messages_ready'] : 0, 84 | isset($response['messages_unacknowledged']) ? $response['messages_unacknowledged'] : 0, 85 | isset($response['idle_since']) ? new \DateTime($response['idle_since']) : new \DateTime('now') 86 | ); 87 | } 88 | 89 | /** 90 | * @return string 91 | */ 92 | public function getName() 93 | { 94 | return $this->name; 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getVhost() 101 | { 102 | return $this->vhost; 103 | } 104 | 105 | /** 106 | * @return string 107 | */ 108 | public function getState() 109 | { 110 | return $this->state; 111 | } 112 | 113 | /** 114 | * @return string 115 | */ 116 | public function getConsumerCount() 117 | { 118 | return $this->consumerCount; 119 | } 120 | 121 | /** 122 | * @return string 123 | */ 124 | public function getMessages() 125 | { 126 | return $this->messages; 127 | } 128 | 129 | /** 130 | * @return string 131 | */ 132 | public function getMessagesReady() 133 | { 134 | return $this->messagesReady; 135 | } 136 | 137 | /** 138 | * @return string 139 | */ 140 | public function getMessagesUnacknowledged() 141 | { 142 | return $this->messagesUnacknowledged; 143 | } 144 | 145 | /** 146 | * @return \DateTime 147 | */ 148 | public function getIdleSince() 149 | { 150 | return $this->idleSince; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Model/RecurringConsoleCommandConfiguration.php: -------------------------------------------------------------------------------- 1 | command = $command; 77 | $this->arguments = $arguments; 78 | $this->schedule = $schedule; 79 | $this->topic = str_replace('-', '_', $topic); 80 | $this->timeout = $timeout; 81 | $this->description = $description; 82 | $this->envs = $envs; 83 | $this->userManaged = $userManaged; 84 | $this->jobStatusEnabled = $jobStatusEnabled; 85 | } 86 | 87 | /** 88 | * Returns a hash which can be used to uniquely identify this configuration 89 | * 90 | * @return string 91 | */ 92 | public function getUuid() 93 | { 94 | return hash('SHA1', serialize($this)); 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getCommand() 101 | { 102 | return $this->command; 103 | } 104 | 105 | /** 106 | * @return array 107 | */ 108 | public function getArguments(): array 109 | { 110 | return $this->arguments; 111 | } 112 | 113 | public function isUserManaged(): ?bool 114 | { 115 | return $this->userManaged; 116 | } 117 | 118 | public function getJobStatusEnabled(): ?bool 119 | { 120 | return $this->jobStatusEnabled; 121 | } 122 | 123 | /** 124 | * @return string 125 | */ 126 | public function getSchedule() 127 | { 128 | return $this->schedule; 129 | } 130 | 131 | /** 132 | * @return string 133 | */ 134 | public function getTopic() 135 | { 136 | return $this->topic; 137 | } 138 | 139 | /** 140 | * @return integer 141 | */ 142 | public function getTimeout() 143 | { 144 | return $this->timeout; 145 | } 146 | 147 | /** 148 | * @return boolean 149 | */ 150 | public function isDue($time = 'now') 151 | { 152 | $cron = Cron\CronExpression::factory($this->getSchedule()); 153 | 154 | return $cron->isDue($time); 155 | } 156 | 157 | /** 158 | * @return \DateTime 159 | */ 160 | public function nextRun($time = 'now') 161 | { 162 | $cron = Cron\CronExpression::factory($this->getSchedule()); 163 | 164 | return $cron->getNextRunDate($time); 165 | } 166 | 167 | /** 168 | * This method will return the 'current' minute if the job is 'due' currently 169 | * 170 | * @return \DateTime 171 | */ 172 | public function previousRun($time = 'now') 173 | { 174 | $cron = Cron\CronExpression::factory($this->getSchedule()); 175 | 176 | if ($cron->isDue($time)) { 177 | $now = new \DateTime(); 178 | $now->setTime(intval($now->format('H')), intval($now->format('i'))); 179 | return $now; 180 | } 181 | return $cron->getPreviousRunDate($time); 182 | } 183 | 184 | /** 185 | * The number of seconds between now (or time passed) and the next time the command will be run 186 | * 187 | * @param mixed $time 188 | * @return integer 189 | */ 190 | public function secondsUntilNextRun($time = 'now') 191 | { 192 | $due = $this->nextRun($time); 193 | if (!$time instanceof \DateTime) { 194 | $time = new \DateTime($time); 195 | } 196 | $diff = $due->getTimestamp() - $time->getTimestamp(); 197 | 198 | return $diff; 199 | } 200 | 201 | /** 202 | * The number of seconds between now (or time passed) and the next time the command will be run 203 | * 204 | * @param mixed $time 205 | * @return integer 206 | */ 207 | public function secondsSincePreviousRun($time = 'now') 208 | { 209 | $previous = $this->previousRun($time); 210 | if (!$time instanceof \DateTime) { 211 | $time = new \DateTime($time); 212 | } 213 | 214 | return $time->getTimestamp() - $previous->getTimestamp(); 215 | } 216 | 217 | /** 218 | * The number of seconds (interval) between last run and next run 219 | * 220 | * @param mixed $time 221 | * @return int 222 | */ 223 | public function secondsBetweenPreviousAndNextRun($time = 'now') 224 | { 225 | $previous = $this->previousRun($time); 226 | $due = $this->nextRun($time); 227 | 228 | return $due->getTimestamp() - $previous->getTimestamp(); 229 | } 230 | 231 | /** 232 | * @return string 233 | */ 234 | public function getDescription() 235 | { 236 | return $this->description; 237 | } 238 | 239 | /** 240 | * @return array|null 241 | */ 242 | public function getEnvs(): ?array 243 | { 244 | return $this->envs; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Model/ScheduledJobRepositoryInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function fetchUnqueuedJobs(); 13 | 14 | /** 15 | * @param ScheduledJob $scheduledJob 16 | */ 17 | public function save(ScheduledJob $scheduledJob, $flush = false); 18 | 19 | public function isJobScheduledWithinRange( 20 | string $job, 21 | \DateTime $rangeFrom, 22 | \DateTime $rangeTo, 23 | ?array $arguments 24 | ): bool; 25 | 26 | public function hasUnQueuedDuplicate(string $job, ?array $arguments): bool; 27 | } 28 | -------------------------------------------------------------------------------- /Publisher/JobPublisher.php: -------------------------------------------------------------------------------- 1 | jobLogRepository = $jobLogRepository; 43 | $this->logger = $logger ?? new NullLogger(); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function setContainer(ContainerInterface $container = null) 50 | { 51 | $this->container = $container; 52 | } 53 | 54 | /** 55 | * Sends a job to RabbitMQ via oldsound producer service 56 | * 57 | * @param Job $job 58 | * 59 | * @throws MissingTopicException 60 | * @throws UndefinedProducerException 61 | */ 62 | public function publish(Job $job, $supressLogging = false) 63 | { 64 | $job->validate(); 65 | 66 | $topic = str_replace('-', '_', $job->getTopic()); 67 | if (!$topic) { 68 | throw new MissingTopicException('A job must have a topic to allow it to be published'); 69 | } 70 | 71 | // ensure rabbit mq producer exists by convention of topic - throw exception if not 72 | $fqProducerName = sprintf('old_sound_rabbit_mq.%s_producer', $topic); 73 | if (!$this->container->has($fqProducerName)) { 74 | throw new UndefinedProducerException(sprintf("Producer for topic '%s' has not been configured", $topic)); 75 | } 76 | 77 | // add the 'class' of the job as an argument to allow it to be constructed again by consumer 78 | $message = array_merge($job->getArgs(), ['job_class' => get_class($job)]); 79 | try { 80 | $producer = $this->container->get($fqProducerName); 81 | $producer->setContentType('application/json'); 82 | 83 | // log the job as existing 84 | if ($job instanceof ConsoleCommandJob) { 85 | if (!$supressLogging) { 86 | $uuid = Uuid::uuid4()->toString(); 87 | $log = new JobLog(trim(sprintf('%s %s', $job->getCommand(), implode(' ', $job->getArguments()))), $uuid, $job->getTopic()); 88 | 89 | $this->jobLogRepository->add($log); 90 | 91 | // adds the uuid to the published job 92 | // which allows the consumer to specify the Uuid when running the command 93 | $message['uuid'] = $uuid; 94 | } 95 | } 96 | 97 | $producer->publish(json_encode($message)); 98 | } catch (AMQPRuntimeException $e) { 99 | $this->logger->error('Unable to add job to the job queue - AMQPRuntimeException - Is RabbitMQ running?:'.$e->getMessage()); 100 | } catch (\Exception $e) { 101 | $this->logger->error('Unable to add job to the job queue - General Exception:'.$e->getMessage()); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://api.travis-ci.org/usemarkup/JobQueueBundle.svg)](http://travis-ci.org/usemarkup/JobQueueBundle) 2 | 3 | Introduction 4 | ============ 5 | 6 | This bundle provides a few features for providing a simple job queue mechanic and scheduling system for symfony console commands. 7 | It uses rabbit-mq to manage a job queue, (for which various workers process tasks). Before proceeding you should read: https://github.com/videlalvaro/RabbitMqBundle. 8 | This bundle assumes the use of 'topic' consumers rather than 'direct' consumers. 9 | 10 | These workers should be maintained by supervisord to ensure they don't fail. 11 | 12 | Features 13 | ============ 14 | - Add console command jobs to RabbitMq to be handled asyncronously 15 | - Add jobs to run at a date in the future 16 | - Log the status of jobs (status, peak memory use, output etc) in redis via uuid option added to all console commands 17 | - Consume Jobs with a PHP consumer or Golang consumer (thanks to ricbra/rabbitmq-cli-consumer) 18 | - Helper command for generating config to manage consumers with Supervisord 19 | 20 | Scheduling Jobs 21 | --------------- 22 | 23 | Rather than scheduling console commands using the crontab, they should be managed in environment specific configuration files (using crontab syntax). This allows the addition of new recurring jobs, or changing of timings, without having to modify crontab in multiple environments. It also has the advantage of forcing a common logging/exception notification strategy for all commands. Examples of these sorts of tasks are polling third party servers for files, sending spooled email or generating reports. 24 | 25 | 26 | Configuration 27 | ------------- 28 | 29 | ```yml 30 | markup_job_queue: 31 | recurring: recurring_jobs.yml # name of file within app/config/ 32 | ``` 33 | 34 | ```yml 35 | # app/config/recurring_jobs.yml 36 | - command: your:console:command --and --any --options and arguments 37 | schedule: 1-59/2 * * * * 38 | topic: topic-of-a-configured-rabbitmq-producer # e.g 'default' 39 | - command: another:console:command --and --any --options and arguments 40 | schedule: * * * * * 41 | topic: topic-of-a-configured-rabbitmq-producer 42 | ``` 43 | 44 | Once you have configured your recurring schedule you need to add only one console command to your live crontab. 45 | This will run a single console command every minute adding any 'due' jobs to RabbitMQ for processing: 46 | 47 | ```vim 48 | * * * * * /usr/bin/php /your/app/location/current/app/console markup:job_queue:recurring:add --no-debug -e=prod >> /var/log/recurring_jobs.log 49 | ``` 50 | 51 | In development instead of installing to the crontab you can run on an interval in the command line if you prefer: 52 | 53 | ```vim 54 | while true; do app/console markup:job_queue:recurring:add; sleep 60; done 55 | ``` 56 | 57 | For the value of 'topic' a valid consumer and producer need to be set up in the oldsound/rabbitmq-bundle configuration as follows, without a configuration of this type, processing of the job will fail (this is currently a convention but would be better enforced by allowing this bundle to configure the oldsound bundle directly - PR's welcome): 58 | __Due to the way oldsound/rabbitmq-bundle treats certain keys, do not use hypens in producers and consumers.__ 59 | 60 | ```yml 61 | 62 | producers: 63 | a_valid_topic: 64 | connection: default 65 | exchange_options: { name: 'a_valid_topic', type: topic } 66 | 67 | consumers: 68 | a_valid_topic: 69 | connection: default 70 | exchange_options: { name: 'a_valid_topic', type: topic } 71 | queue_options: { name: 'a_valid_topic' } 72 | callback: markup_job_queue.consumer 73 | ``` 74 | 75 | There are a few console commands that allow you to preview and validate your configured console jobs via the CLI (see /Command) 76 | 77 | Adding Jobs 78 | ----------- 79 | 80 | Jobs can also be added directly. There is a utility method for adding 'command' jobs, which uses the Symfony process component to execute console commands. Adding a 'Command Job' can be achieved using the 'jobby' service as follows: 81 | 82 | ```php 83 | $container->get('jobby') 84 | ->addConsoleCommandJob( 85 | 'your:console:command', 86 | ['--test'] 87 | 'a_valid_topic', # should be a valid topic name 88 | 600, # allowed timeout for command (see symfony process component documentation) 89 | 600, # allowed idle timeout for command (see symfony process component documentation) 90 | ) 91 | ``` 92 | 93 | You can use this mechanism to break down large import tasks into smaller sections that can be processed asynchronously. Make sure you appropriately escape any user provided parameters to your console commands. Due to the way that console commands are consumed using the Process component, unescaped parameters are a possible security attack vector. 94 | 95 | Enabling and Monitoring Workers (via supervisord) 96 | ================ 97 | 98 | To aid with deployment of this bundle, a console command has been provided which can be run as part of a deployment. This console command will generate a supervisord file for the purpose of including within your main supervisord.conf file. This will produce a configuration that initiates and watches php 'consumers', providing one consumer per topic. There are two options for consuming jobs. The default mechanism is to use the PHP consumers provided by oldsound/rabbitmq-bundle, but an alternative mechanism uses the Golang based consumer (ricbra/rabbitmq-cli-consumer). To use the Golang variant, provide a configuration for the `cli_consumer` node. 99 | 100 | ```yml 101 | markup_job_queue: 102 | cli_consumer: 103 | enabled: true 104 | ``` 105 | 106 | This console command requires a minimal configuration (one block for each consumer you want to start). By convention these must match the consumers you have already defined (as seen above). __Due to the way oldsound/rabbitmq-bundle treats certain keys, do not use hypens in your topic names.__: 107 | 108 | By setting 'prefetch_count' you can select how many messages the consumer should process before respawning. 109 | 110 | ```yml 111 | markup_job_queue: 112 | topics: 113 | test: 114 | prefetch_count: 10 115 | a_valid_topic: 116 | prefetch_count: 20 117 | ``` 118 | 119 | To write the configuration file: 120 | 121 | ```bash 122 | app/console markup:job_queue:supervisord_config:write disambiguator 123 | ``` 124 | 125 | The file will be written to /etc/supervisord/conf.d/ by default. This can be amended: 126 | ```yml 127 | markup_job_queue: 128 | supervisor_config_path: /path/to/conf/file/ 129 | ``` 130 | This path needs to be included in your main /etc/supervisord.conf thus: 131 | ```conf 132 | [include] 133 | files=/path/to/conf/file/*.conf 134 | ``` 135 | 136 | Deployment 137 | ================ 138 | To use this as part of a capistrano deployment for example you can write some custom capistrano tasks that: 139 | 140 | - Stop consumers 141 | - Rewrite the configuration 142 | - Restart the consumers 143 | 144 | The following assumes use of capistrano multistage under capifony 2.X YMMV 145 | ```ruby 146 | namespace :supervisor do 147 | desc "Supervisor Tasks" 148 | task :check_config, :roles => :app do 149 | stream "cd #{latest_release} && #{php_bin} #{symfony_console} markup:job_queue:recurring:check --env=#{symfony_env}" 150 | end 151 | task :write_config, :roles => :worker, :except => { :no_release => true } do 152 | stream("cd #{latest_release} && #{php_bin} #{symfony_console} markup:job_queue:supervisord_config:write #{fetch(:stage)} --env=#{symfony_env_prod};") 153 | end 154 | task :restart_all, :roles => :app, :except => { :no_release => true } do 155 | stream "#{try_sudo} supervisorctl stop all #{fetch(:stage)}:*" 156 | stream "#{try_sudo} supervisorctl update" 157 | stream "#{try_sudo} supervisorctl start all #{fetch(:stage)}:*" 158 | capifony_puts_ok 159 | end 160 | task :stop_all, :roles => :app, :except => { :no_release => true } do 161 | # stops all consumers in this group 162 | stream "#{try_sudo} supervisorctl stop all #{fetch(:stage)}:*" 163 | capifony_puts_ok 164 | end 165 | end 166 | ``` 167 | -------------------------------------------------------------------------------- /Reader/QueueReader.php: -------------------------------------------------------------------------------- 1 | api = $apiFactory; 29 | $this->vhost = $vhost; 30 | } 31 | 32 | /** 33 | * Get an a collection of Queue objects representing information 34 | * about all queues 35 | * 36 | * @return ArrayCollection 37 | */ 38 | public function getQueues() 39 | { 40 | $collection = new ArrayCollection(); 41 | try { 42 | $apiResponse = $this->api->queues()->all($this->vhost); 43 | foreach($apiResponse as $q) { 44 | $collection->add(Queue::constructFromApiResponse($q)); 45 | } 46 | } catch(\Exception $e) { 47 | return $collection; 48 | } 49 | return $collection; 50 | } 51 | 52 | /** 53 | * Can a connection to rabbitMq be established at all? 54 | * 55 | * @return boolean 56 | */ 57 | public function isAlive() 58 | { 59 | try { 60 | $this->api->alivenessTest('test'); 61 | return true; 62 | } catch(\Exception $e) { 63 | return false; 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Reader/RecurringConsoleCommandConfigurationJobLogReader.php: -------------------------------------------------------------------------------- 1 | recurringConsoleCommandReader = $recurringConsoleCommandReader; 36 | $this->jobLogRepository = $jobLogRepository; 37 | } 38 | 39 | /** 40 | * Get an array of JobLogCollections keyed by configuration uuid 41 | * 42 | * @param int $maxQuantity 43 | * @return array 44 | */ 45 | public function getJobLogCollections($maxQuantity = 5) 46 | { 47 | $collection = []; 48 | $configurations = $this->recurringConsoleCommandReader->getConfigurations(); 49 | foreach ($configurations as $configuration) { 50 | $searchData = new SearchJobLogs(); 51 | $searchData->setCommand($configuration->getCommand()); 52 | $jobLogs = $this->jobLogRepository->getJobLogCollection($searchData, $maxQuantity); 53 | 54 | $collection[$configuration->getUuid()] = $jobLogs; 55 | } 56 | 57 | return $collection; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Repository/CronHealthRepository.php: -------------------------------------------------------------------------------- 1 | predis = $predis; 27 | } 28 | 29 | /** 30 | * Set this on every cron run to make sure job is marked as healty 31 | */ 32 | public function set() 33 | { 34 | $this->predis->setex(self::REDIS_KEY, 65, 'true'); 35 | } 36 | 37 | /** 38 | * @return string|null 39 | */ 40 | public function get() 41 | { 42 | return $this->predis->get(self::REDIS_KEY); 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /Repository/JobLogRepository.php: -------------------------------------------------------------------------------- 1 | shouldClearLogForCompleteJob = false; 44 | $this->doctrine = $doctrine; 45 | $this->ttl = $ttl ?: self::DEFAULT_LOG_TTL; 46 | } 47 | 48 | public function saveFailure(string $uuid, string $output, int $exitCode): void 49 | { 50 | $log = $this->findJobLog($uuid); 51 | 52 | if (!$log) { 53 | return; 54 | } 55 | 56 | $log->setStatus(JobLog::STATUS_FAILED); 57 | 58 | if (!$log->getCompleted()) { 59 | $log->setCompleted(new \DateTime()); 60 | } 61 | 62 | $log->setOutput($output); 63 | $log->setExitCode($exitCode); 64 | 65 | $this->save($log); 66 | } 67 | 68 | public function saveOutput(string $uuid, string $output): void 69 | { 70 | $log = $this->findJobLog($uuid); 71 | 72 | if (!$log) { 73 | return; 74 | } 75 | 76 | $log->setOutput($output); 77 | $this->save($log); 78 | } 79 | 80 | public function getJobLogs(SearchJobLogsData $data, $maxPerPage = 10, $currentPage = 1): Pagerfanta 81 | { 82 | $query = $this->getJobLogQuery($data); 83 | 84 | $jobLogs = new Pagerfanta( 85 | new DoctrineORMAdapter($query, true, false) 86 | ); 87 | 88 | $jobLogs->setMaxPerPage($maxPerPage); 89 | $jobLogs->setCurrentPage($currentPage); 90 | 91 | return $jobLogs; 92 | } 93 | 94 | public function getJobLogCollection(SearchJobLogsData $data, $limit = 10): JobLogCollection 95 | { 96 | $query = $this->getJobLogQuery($data, $limit); 97 | 98 | $collection = new JobLogCollection(); 99 | 100 | foreach ($query->getResult() as $row) { 101 | $collection->add($row); 102 | } 103 | 104 | return $collection; 105 | } 106 | 107 | private function getJobLogQuery(SearchJobLogsData $data, ?int $limit = null): Query 108 | { 109 | $qb = $this->getEntityRepository()->createQueryBuilder('j'); 110 | if ($data->getId()) { 111 | $qb->where($qb->expr()->like('j.uuid', ':uuid')) 112 | ->setParameter(':uuid', $data->getId()); 113 | } 114 | 115 | if ($data->getSince()) { 116 | $qb->andWhere($qb->expr()->gte('j.added', ':after')) 117 | ->setParameter(':after', $data->getSince()); 118 | } 119 | 120 | if ($data->getBefore()) { 121 | $qb->andWhere($qb->expr()->lte('j.added', ':before')) 122 | ->setParameter(':before', $data->getBefore()); 123 | } 124 | 125 | if ($data->getStatus()) { 126 | $qb->andWhere($qb->expr()->eq('j.status', ':status')) 127 | ->setParameter(':status', $data->getStatus()); 128 | } 129 | 130 | if ($data->getCommand()) { 131 | $qb->andWhere($qb->expr()->like('j.command', ':command')) 132 | ->setParameter(':command', $data->getCommand().'%'); 133 | } 134 | 135 | if ($limit) { 136 | $qb->setMaxResults($limit); 137 | } 138 | 139 | $qb->orderBy('j.added', 'DESC'); 140 | 141 | return $qb->getQuery(); 142 | } 143 | 144 | public function setTtl(?int $ttl = null): void 145 | { 146 | $this->ttl = $ttl; 147 | } 148 | 149 | public function setShouldClearLogForCompleteJob(bool $shouldClearLogForCompleteJob): void 150 | { 151 | $this->shouldClearLogForCompleteJob = $shouldClearLogForCompleteJob; 152 | } 153 | 154 | public function findJobLog(string $uuid): ?JobLog 155 | { 156 | $jobLog = $this->getEntityRepository()->findOneBy(['uuid' => $uuid]); 157 | if ($jobLog instanceof JobLog) { 158 | return $jobLog; 159 | } 160 | 161 | return null; 162 | } 163 | 164 | /** 165 | * Removes all jobs older than ($this->ttl) from all secondary indexes 166 | */ 167 | public function removeExpiredJobs(): void 168 | { 169 | $interval = new \DateInterval(sprintf('PT%sS', $this->ttl)); 170 | $before = (new \DateTime('now'))->sub($interval); 171 | 172 | $qb = $this->getEntityManager()->createQueryBuilder(); 173 | $qb->delete(JobLog::class, 'j') 174 | ->where($qb->expr()->lte('j.added', ':before')) 175 | ->setParameter(':before', $before); 176 | 177 | $qb->getQuery()->execute(); 178 | } 179 | 180 | public function add(JobLog $jobLog): void 181 | { 182 | $this->getEntityManager()->persist($jobLog); 183 | $this->getEntityManager()->flush($jobLog); 184 | } 185 | 186 | public function save(JobLog $jobLog): void 187 | { 188 | if ($this->shouldClearLogForCompleteJob) { 189 | if ($jobLog->getStatus() === JobLog::STATUS_COMPLETE) { 190 | $this->getEntityManager()->remove($jobLog); 191 | } 192 | } 193 | 194 | $this->getEntityManager()->flush($jobLog); 195 | } 196 | 197 | private function getEntityRepository(): EntityRepository 198 | { 199 | $repository = $this->getEntityManager()->getRepository(JobLog::class); 200 | 201 | if ($repository instanceof EntityRepository) { 202 | return $repository; 203 | } 204 | 205 | throw new \RuntimeException(sprintf('Doctrine returned an invalid repository for entity JobLog')); 206 | } 207 | 208 | private function getEntityManager(): EntityManager 209 | { 210 | $manager = $this->doctrine->getManager(); 211 | 212 | if ($manager instanceof EntityManagerInterface && !$manager->isOpen()) { 213 | $manager = $this->doctrine->resetManager(); 214 | } 215 | 216 | if ($manager instanceof EntityManager) { 217 | return $manager; 218 | } 219 | 220 | throw new \RuntimeException(sprintf('Doctrine returned an invalid type for entity manager')); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Repository/JobStatusRepository.php: -------------------------------------------------------------------------------- 1 | doctrine = $doctrine; 21 | } 22 | 23 | public function isStatusEnabled(string $command, ?string $arguments): bool 24 | { 25 | $jobStatus = $this->findBy([ 26 | 'command' => $command, 27 | 'arguments' => $arguments ?? '[]' 28 | ]); 29 | 30 | if (!$jobStatus) { 31 | return true; 32 | } 33 | 34 | return $jobStatus->getEnabled(); 35 | } 36 | 37 | public function findBy(array $arguments): ?JobStatus 38 | { 39 | $jobStatus = $this->getEntityRepository()->findOneBy($arguments); 40 | 41 | if ($jobStatus instanceof JobStatus) { 42 | return $jobStatus; 43 | } 44 | 45 | return null; 46 | } 47 | 48 | public function fetchOrCreateJobStatus(string $command, string $arguments): JobStatus 49 | { 50 | $jobStatus = $this->findBy([ 51 | 'command' => $command, 52 | 'arguments' => ($arguments) ?? null 53 | ]); 54 | 55 | return $jobStatus ?? new JobStatus(null, $command, $arguments, true); 56 | } 57 | 58 | public function save(JobStatus $jobStatus): void 59 | { 60 | $this->getEntityManager()->persist($jobStatus); 61 | $this->getEntityManager()->flush($jobStatus); 62 | } 63 | 64 | private function getEntityRepository(): EntityRepository 65 | { 66 | $repository = $this->getEntityManager()->getRepository(JobStatus::class); 67 | 68 | if ($repository instanceof EntityRepository) { 69 | return $repository; 70 | } 71 | 72 | throw new \RuntimeException(sprintf('Doctrine returned an invalid repository for entity JobStatus')); 73 | } 74 | 75 | private function getEntityManager(): EntityManager 76 | { 77 | $manager = $this->doctrine->getManager(); 78 | 79 | if ($manager instanceof EntityManagerInterface && !$manager->isOpen()) { 80 | $manager = $this->doctrine->resetManager(); 81 | } 82 | 83 | if ($manager instanceof EntityManager) { 84 | return $manager; 85 | } 86 | 87 | throw new \RuntimeException(sprintf('Doctrine returned an invalid type for entity manager')); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Resources/config/admin_routing.yml: -------------------------------------------------------------------------------- 1 | markup_job_queue_recurring: 2 | path: /recurring/ 3 | defaults: { _controller: MarkupJobQueueBundle:RecurringView:view } 4 | -------------------------------------------------------------------------------- /Resources/config/commands.yml: -------------------------------------------------------------------------------- 1 | services: 2 | markup_job_queue.command.add_command_job_to_queue: 3 | class: Markup\JobQueueBundle\Command\AddCommandJobToQueueCommand 4 | arguments: 5 | - '@jobby' 6 | tags: 7 | - { name: console.command, command: 'markup:job_queue:add:command' } 8 | 9 | markup_job_queue.command.add_recurring_console_job_to_queue: 10 | class: Markup\JobQueueBundle\Command\AddRecurringConsoleJobToQueueCommand 11 | arguments: 12 | $recurringConsoleCommandReader: '@markup_job_queue.reader.recurring_console_command' 13 | $jobManager: '@jobby' 14 | $cronHealthRepository: '@markup_job_queue.repository.cron_health' 15 | $jobLogRepository: '@markup_job_queue.repository.job_log' 16 | $jobStatusRepository: '@Markup\JobQueueBundle\Repository\JobStatusRepository' 17 | $environment: '%kernel.environment%' 18 | tags: 19 | - { name: console.command, command: 'markup:job_queue:recurring:add' } 20 | 21 | markup_job_queue.command.add_schedule_job_to_queue: 22 | autowire: true 23 | class: Markup\JobQueueBundle\Command\AddScheduleJobToQueueCommand 24 | arguments: 25 | $jobManager: '@jobby' 26 | tags: 27 | - { name: console.command, command: 'markup:scheduled_job:add' } 28 | 29 | markup_job_queue.command.add_test_job: 30 | class: Markup\JobQueueBundle\Command\AddTestJobCommand 31 | arguments: 32 | - '@jobby' 33 | tags: 34 | - { name: console.command, command: 'markup:job_queue:add:test' } 35 | 36 | markup_job_queue.command.check_recurring_job_configuration: 37 | class: Markup\JobQueueBundle\Command\CheckRecurringJobConfigurationCommand 38 | arguments: 39 | $recurringConsoleCommandReader: '@markup_job_queue.reader.recurring_console_command' 40 | tags: 41 | - { name: console.command, command: 'markup:job_queue:recurring:check' } 42 | 43 | markup_job_queue.command.consumer: 44 | class: Markup\JobQueueBundle\Command\ConsumerCommand 45 | arguments: 46 | $consumer: '@markup_job_queue.consumer' 47 | tags: 48 | - { name: console.command, command: 'markup:job_queue:consumer' } 49 | 50 | markup_job_queue.command.read_recurring_console_job_configuration: 51 | class: Markup\JobQueueBundle\Command\ReadRecurringConsoleJobConfigurationCommand 52 | tags: 53 | - { name: console.command, command: 'markup:job_queue:recurring:view' } 54 | 55 | markup_job_queue.command.run_test_job: 56 | class: Markup\JobQueueBundle\Command\RunTestJobCommand 57 | autowire: true 58 | tags: 59 | - { name: console.command, command: 'markup:job_queue:run:test' } 60 | 61 | markup_job_queue.command.write_cli_consumer_config_file: 62 | class: Markup\JobQueueBundle\Command\WriteCliConsumerConfigFileCommand 63 | arguments: 64 | $cliConsumerConfigFileWriter: '@markup_job_queue.writer.cli_consumer_config_file' 65 | tags: 66 | - { name: console.command, command: 'markup:job_queue:cli_consumer_config:write' } 67 | 68 | markup_job_queue.command.write_supervisord_config_file: 69 | class: Markup\JobQueueBundle\Command\WriteSupervisordConfigFileCommand 70 | arguments: 71 | $configFileWriter: '@markup_job_queue.writer.supervisord_config_file' 72 | tags: 73 | - { name: console.command, command: 'markup:job_queue:supervisord_config:write' } 74 | 75 | Markup\JobQueueBundle\Command\RabbitMqConsumerCommand: 76 | arguments: 77 | $consumer: '@?simple_bus.rabbit_mq_bundle_bridge.commands_consumer' 78 | tags: 79 | - { name: console.command, command: 'markup:job_queue:rabbitmq_consumer' } 80 | -------------------------------------------------------------------------------- /Resources/config/doctrine/JobLog.orm.yml: -------------------------------------------------------------------------------- 1 | Markup\JobQueueBundle\Entity\JobLog: 2 | type: entity 3 | table: job_log 4 | id: 5 | uuid: 6 | type: guid 7 | unique: true 8 | generator: 9 | strategy: none 10 | indexes: 11 | added: 12 | columns: [ added ] 13 | completed: 14 | columns: [ completed ] 15 | started: 16 | columns: [ started ] 17 | topic: 18 | columns: [ topic ] 19 | status: 20 | columns: [ status ] 21 | fields: 22 | command: 23 | type: text 24 | topic: 25 | type: string 26 | length: 60 27 | added: 28 | type: datetime 29 | started: 30 | type: datetime 31 | nullable: true 32 | completed: 33 | type: datetime 34 | nullable: true 35 | status: 36 | type: string 37 | length: 60 38 | output: 39 | type: text 40 | nullable: true 41 | peakMemoryUse: 42 | type: integer 43 | length: 255 44 | nullable: true 45 | exitCode: 46 | type: integer 47 | nullable: true 48 | -------------------------------------------------------------------------------- /Resources/config/doctrine/JobStatus.orm.yml: -------------------------------------------------------------------------------- 1 | Markup\JobQueueBundle\Entity\JobStatus: 2 | type: entity 3 | table: job_status 4 | id: 5 | id: 6 | type: guid 7 | unique: true 8 | generator: 9 | strategy: AUTO 10 | indexes: 11 | id: 12 | columns: [ id ] 13 | fields: 14 | command: 15 | type: string 16 | length: 60 17 | arguments: 18 | type: text 19 | enabled: 20 | type: boolean 21 | defaultValue: '1' 22 | -------------------------------------------------------------------------------- /Resources/config/doctrine/ScheduledJob.orm.yml: -------------------------------------------------------------------------------- 1 | Markup\JobQueueBundle\Entity\ScheduledJob: 2 | type: entity 3 | table: scheduled_job 4 | id: 5 | id: 6 | type: integer 7 | generator: 8 | strategy: AUTO 9 | fields: 10 | job: 11 | type: string 12 | length: 256 13 | arguments: 14 | type: array 15 | topic: 16 | type: string 17 | length: 60 18 | scheduledTime: 19 | type: datetime 20 | queued: 21 | type: boolean 22 | created: 23 | type: datetime 24 | gedmo: 25 | timestampable: 26 | on: create 27 | updated: 28 | type: datetime 29 | gedmo: 30 | timestampable: 31 | on: update 32 | -------------------------------------------------------------------------------- /Resources/config/services.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | markup_job_queue.php_bin_path: '/usr/bin/php' 3 | 4 | services: 5 | markup_job_queue.publisher: 6 | class: Markup\JobQueueBundle\Publisher\JobPublisher 7 | arguments: 8 | - '@markup_job_queue.repository.job_log' 9 | - '@logger' 10 | calls: 11 | - [ setContainer,[ '@service_container' ] ] 12 | markup_job_queue.consumer: 13 | class: Markup\JobQueueBundle\Consumer\JobConsumer 14 | autowire: true 15 | markup_job_queue.manager: 16 | autowire: true 17 | class: Markup\JobQueueBundle\Service\JobManager 18 | arguments: 19 | - '@markup_job_queue.publisher' 20 | jobby: 21 | alias: markup_job_queue.manager 22 | markup_job_queue.reader.recurring_console_command: 23 | class: Markup\JobQueueBundle\Service\RecurringConsoleCommandReader 24 | arguments: 25 | - '%kernel.root_dir%' 26 | - '@Markup\JobQueueBundle\Repository\JobStatusRepository' 27 | - '%kernel.environment%' 28 | markup_job_queue.writer.supervisord_config_file: 29 | class: Markup\JobQueueBundle\Service\SupervisordConfigFileWriter 30 | arguments: 31 | - '%kernel.root_dir%' 32 | - '%kernel.logs_dir%' 33 | - '%kernel.environment%' 34 | - ~ 35 | - ~ 36 | - ~ 37 | 38 | markup_job_queue.writer.cli_consumer_config_file: 39 | class: Markup\JobQueueBundle\Service\CliConsumerConfigFileWriter 40 | markup_job_queue.rabbit_mq_api.client: 41 | class: Markup\RabbitMq\ManagementApi\Client 42 | markup_job_queue.rabbit_mq_api: 43 | class: Markup\RabbitMq\ApiFactory 44 | arguments: 45 | - '@markup_job_queue.rabbit_mq_api.client' 46 | markup_job_queue.reader.queue: 47 | class: Markup\JobQueueBundle\Reader\QueueReader 48 | arguments: 49 | - '@markup_job_queue.rabbit_mq_api' 50 | - ~ 51 | Markup\JobQueueBundle\Entity\Repository\ScheduledJobRepository: 52 | autowire: true 53 | public: false 54 | 55 | Markup\JobQueueBundle\Repository\JobLogRepository: '@markup_job_queue.repository.job_log' 56 | 57 | Markup\JobQueueBundle\Repository\JobStatusRepository: 58 | autowire: true 59 | public: false 60 | 61 | markup_job_queue.repository.job_log: 62 | class: Markup\JobQueueBundle\Repository\JobLogRepository 63 | arguments: 64 | - '@doctrine' 65 | markup_job_queue.repository.cron_health: 66 | class: Markup\JobQueueBundle\Repository\CronHealthRepository 67 | arguments: 68 | - '@snc_redis.default' 69 | markup_job_queue.event_subscriber.add_uuid_option_to_console_command: 70 | class: Markup\JobQueueBundle\EventSubscriber\AddUuidOptionConsoleCommandEventSubscriber 71 | tags: 72 | - { name: kernel.event_subscriber } 73 | markup_job_queue.event_subscriber.log_console_command: 74 | class: Markup\JobQueueBundle\EventSubscriber\LogConsoleCommandEventSubscriber 75 | arguments: 76 | - '@markup_job_queue.repository.job_log' 77 | tags: 78 | - { name: kernel.event_subscriber } 79 | markup_job_queue.event_subscriber.complete_console_command: 80 | class: Markup\JobQueueBundle\EventSubscriber\CompleteConsoleCommandEventSubscriber 81 | arguments: 82 | - '@markup_job_queue.repository.job_log' 83 | tags: 84 | - { name: kernel.event_subscriber } 85 | 86 | markup_job_queue.reader.recurring_console_command_configuration_job_log: 87 | class: Markup\JobQueueBundle\Reader\RecurringConsoleCommandConfigurationJobLogReader 88 | arguments: 89 | - '@markup_job_queue.reader.recurring_console_command' 90 | - '@markup_job_queue.repository.job_log' 91 | 92 | markup_job_queue.form.type.search_jobs: 93 | class: Markup\JobQueueBundle\Form\Type\SearchJobLogs 94 | tags: 95 | - { name: form.type, alias: phoenix_admin_search_job_logs } 96 | 97 | markup_job_queue.form.handler.search_jobs: 98 | class: Markup\JobQueueBundle\Form\Handler\SearchJobLogs 99 | arguments: 100 | - '@markup_job_queue.repository.job_log' 101 | 102 | -------------------------------------------------------------------------------- /Resources/views/View/recurring.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'MarkupJobQueueBundle::layout.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 |

Recurring Jobs

7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for configuration in recurringReader.configurations %} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% endfor %} 31 | 32 |
CommandTopicScheduleDue?Previously RunNext Run
{{ configuration.command }}{{ configuration.topic }}{{ configuration.schedule }}{% if configuration.due %}✓{% else %}✗{% endif %}{{ configuration.previousRun|localizeddate('short', 'short') }}{{ configuration.nextRun|localizeddate('short', 'short') }}
33 | 34 | {% endblock %} 35 | 36 | -------------------------------------------------------------------------------- /Resources/views/layout.html.twig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usemarkup/JobQueueBundle/3bbc5947ddcd11cb0e572550b9f3b81408199eae/Resources/views/layout.html.twig -------------------------------------------------------------------------------- /Service/CliConsumerConfigFileWriter.php: -------------------------------------------------------------------------------- 1 | logPath = $logPath; 65 | $this->configFilePath = $configFilePath; 66 | $this->host = $host; 67 | $this->username = $username; 68 | $this->password = $password; 69 | $this->vhost = $vhost; 70 | $this->port = $port; 71 | } 72 | 73 | /** 74 | * Sets configuration for topics (in order to configure consumers) 75 | */ 76 | public function setTopicsConfiguration(array $topics) 77 | { 78 | $this->topics = $topics; 79 | } 80 | 81 | /** 82 | * Writes the supervisord config file 83 | */ 84 | public function writeConfig($uniqueEnvironment) 85 | { 86 | // make sure defined paths exist 87 | $fs = new Filesystem(); 88 | if (!$fs->exists($this->logPath)) { 89 | throw new IOException(sprintf("%s does not exist, please create this folder", $this->logPath)); 90 | } 91 | if (!$fs->exists($this->configFilePath)) { 92 | throw new IOException(sprintf("%s does not exist, please create this folder", $this->configFilePath)); 93 | } 94 | 95 | // Write one CLI consumer config for each topic 96 | foreach ($this->topics as $topic => $topicConfig) { 97 | 98 | $cliConfigFile = sprintf( 99 | '%s/%s_%s_consumer.conf', 100 | $this->configFilePath, 101 | $uniqueEnvironment, 102 | $topic 103 | ); 104 | 105 | $config = $this->getConfigString($uniqueEnvironment, $topic, $topicConfig); 106 | file_put_contents($cliConfigFile, $config); 107 | } 108 | } 109 | 110 | /** 111 | * @param string $uniqueEnvironment 112 | * @param string $topic 113 | * @param array $topicConfig 114 | * @return string 115 | */ 116 | public function getConfigString($uniqueEnvironment, $topic, $topicConfig) 117 | { 118 | $conf = []; 119 | $conf[] = "[rabbitmq]"; 120 | $conf[] = sprintf("host=%s", $this->host); 121 | $conf[] = sprintf("username=%s", $this->username); 122 | $conf[] = sprintf("password=%s", $this->password); 123 | $conf[] = sprintf("vhost=/%s", $this->vhost); 124 | $conf[] = sprintf("port=%s", $this->port); 125 | $conf[] = sprintf("queue=%s", $topic); 126 | $conf[] = "compression=Off"; 127 | $conf[] = ""; 128 | $conf[] = "[logs]"; 129 | $conf[] = sprintf("error=%s", sprintf("%s/%s_%s_error.log", $this->logPath, $uniqueEnvironment, $topic)); 130 | $conf[] = sprintf("info=%s", sprintf("%s/%s_%s_info.log", $this->logPath, $uniqueEnvironment, $topic)); 131 | $conf[] = ""; 132 | $conf[] = "[prefetch]"; 133 | $conf[] = sprintf("count=%s", $topicConfig['prefetch_count']); 134 | $conf[] = "global=Off"; 135 | $conf[] = ""; 136 | $conf[] = "[exchange]"; 137 | $conf[] = sprintf("name=%s", $topic); 138 | $conf[] = "autodelete=Off"; 139 | $conf[] = "type=topic"; 140 | $conf[] = "durable=On"; 141 | 142 | return implode("\n", $conf); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Service/JobManager.php: -------------------------------------------------------------------------------- 1 | publisher = $publisher; 32 | $this->scheduledJobRepository = $scheduledJobRepository; 33 | } 34 | 35 | public function addJob(Job $job, $supressLogging = false) 36 | { 37 | $this->publisher->publish($job, $supressLogging); 38 | } 39 | 40 | /** 41 | * Adds a named command to the job queue 42 | * 43 | * @param string $command A valid command for this application. 44 | * @param array $arguments 45 | * @param string $topic The name of a valid topic. 46 | * @param integer $timeout The amount of time to allow the command to run. 47 | * @param integer $idleTimeout The amount of idle time to allow the command to run. Defaults to the same as timeout. 48 | * @param bool $supressLogging Stops the job from being logged by the database 49 | */ 50 | public function addConsoleCommandJob(string $command, array $arguments = [], $topic = 'default', $timeout = 60, $idleTimeout = null, $supressLogging = false) 51 | { 52 | if (stripos($command, " ") !== false) { 53 | throw new \InvalidArgumentException('Console command is not expected to have spaces within the name'); 54 | } 55 | 56 | $args = []; 57 | $args['command'] = $command; 58 | $args['arguments'] = $arguments; 59 | $args['timeout'] = $timeout; 60 | $args['idleTimeout'] = $idleTimeout ?? $timeout; 61 | $job = new ConsoleCommandJob($args, $topic); 62 | 63 | $this->addJob($job, $supressLogging); 64 | } 65 | 66 | /** 67 | * Adds a named command to the job queue at a specific datetime 68 | * 69 | * @param string $command A valid command for this application. 70 | * @param \DateTime $dateTime The DateTime to execute the command. 71 | * @param array $arguments 72 | * @param string $topic The name of a valid topic. 73 | * @param int $timeout The amount of time to allow the command to run. 74 | * @param int $idleTimeout The amount of idle time to allow the command to run. Default to the same as timeout. 75 | */ 76 | public function addScheduledConsoleCommandJob( 77 | $command, 78 | \DateTime $dateTime, 79 | array $arguments = [], 80 | $topic = 'default', 81 | $timeout = 60, 82 | $idleTimeout = null 83 | ) { 84 | if (stripos($command, " ") !== false) { 85 | throw new \InvalidArgumentException('Console command is not expected to have spaces within the name'); 86 | } 87 | 88 | $args = []; 89 | $args['command'] = $command; 90 | $args['arguments'] = $arguments; 91 | $args['timeout'] = $timeout; 92 | $args['idleTimeout'] = $idleTimeout ?? $timeout; 93 | $job = new ConsoleCommandJob($args, $topic); 94 | 95 | $this->addScheduledJob($job, $dateTime); 96 | } 97 | 98 | public function addScheduledJob(ConsoleCommandJob $job, $scheduledTime): ScheduledJob 99 | { 100 | $scheduledJob = new ScheduledJob($job->getCommand(), $job->getArguments(), $scheduledTime, $job->getTopic()); 101 | $this->scheduledJobRepository->save($scheduledJob, true); 102 | 103 | return $scheduledJob; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Service/RecurringConsoleCommandReader.php: -------------------------------------------------------------------------------- 1 | kernelPath = $kernelPath; 55 | $this->jobStatusRepository = $jobStatusRepository; 56 | $this->kernelEnv = $kernelEnv; 57 | } 58 | 59 | public function setConfigurationFileName($name) 60 | { 61 | $this->configurationFileName = $name; 62 | } 63 | 64 | /** 65 | * @return ArrayCollection 66 | * @throws InvalidConfigurationException If any configuration is missing parameters 67 | * @throws MissingScheduleException If schedule file has not been configured 68 | */ 69 | public function getConfigurations() 70 | { 71 | if ($this->configurations !== null) { 72 | return $this->configurations; 73 | } 74 | if ($this->configurationFileName === null) { 75 | throw new MissingScheduleException('Cannot get configurations as no config file has been defined'); 76 | } 77 | $config = $this->getConfiguration(); 78 | $configurations = $this->parseConfiguration($config); 79 | // cache for next lookup 80 | $this->configurations = $configurations; 81 | 82 | return $configurations; 83 | } 84 | 85 | /** 86 | * @param string $id 87 | */ 88 | public function getConfigurationById($id) 89 | { 90 | foreach($this->getConfigurations() as $configuration) { 91 | if ($configuration->getUuid() === $id) { 92 | return $configuration; 93 | } 94 | } 95 | return null; 96 | } 97 | 98 | /** 99 | * Gets any configurations which are due NOW and returns a collection of them 100 | * @return ArrayCollection 101 | */ 102 | public function getDue() 103 | { 104 | $configurations = $this->getConfigurations(); 105 | $due = new ArrayCollection(); 106 | foreach ($configurations as $configuration) { 107 | if ($configuration->isDue()) { 108 | $due->add($configuration); 109 | } 110 | } 111 | 112 | return $due; 113 | } 114 | 115 | /** 116 | * Gets an array of the distinct command strings used in recurring job configurations 117 | */ 118 | public function getConfigurationCommands() 119 | { 120 | $commands = []; 121 | foreach($this->getConfigurations() as $configuration) { 122 | $key = hash('SHA256', $configuration->getCommand()); 123 | $commands[$key] = $configuration->getCommand(); 124 | } 125 | return $commands; 126 | } 127 | 128 | /** 129 | * Parses the configuration and returns an array of of configuration objects 130 | * Configuration is cached after running this function so it should only be run once 131 | * 132 | * @return ArrayCollection 133 | */ 134 | private function parseConfiguration(array $config) 135 | { 136 | $configurations = new ArrayCollection(); 137 | foreach ($config as $group) { 138 | if (!isset($group['command']) || !isset($group['schedule']) || !isset($group['topic'])) { 139 | throw new InvalidConfigurationException('Every job schedule should have a `command`, `topic` and `schedule` component'.json_encode($config)); 140 | } 141 | 142 | if (isset($group['envs']) && !is_array($group['envs'])) { 143 | throw new InvalidConfigurationException('`envs` config key must be an array or null'); 144 | } 145 | 146 | if (isset($group['arguments']) && !is_array($group['arguments'])) { 147 | throw new InvalidConfigurationException(sprintf('`arguments` config key must be an array for %s', $group['command'])); 148 | } 149 | 150 | // user management by default when environment is not prod 151 | if ($this->kernelEnv !== 'prod' && !isset($group['user_managed'])) { 152 | $group['user_managed'] = true; 153 | } 154 | 155 | $recurringConsoleCommandConfiguration = new RecurringConsoleCommandConfiguration( 156 | $group['command'], 157 | $group['arguments'] ?? [], 158 | $group['topic'], 159 | $group['schedule'], 160 | isset($group['description']) ? $group['description'] : null, 161 | isset($group['timeout']) ? $group['timeout'] : null, 162 | isset($group['envs']) ? $group['envs'] : null, 163 | isset($group['user_managed']) ? $group['user_managed'] : null, 164 | isset($group['user_managed']) ? $this->jobStatusEnabled($group) : null 165 | ); 166 | 167 | $configurations->add($recurringConsoleCommandConfiguration); 168 | } 169 | 170 | return $configurations; 171 | } 172 | 173 | private function jobStatusEnabled(array $group): bool 174 | { 175 | $arguments = isset($group['arguments']) ? json_encode($group['arguments']) : null; 176 | 177 | return $this->jobStatusRepository->isStatusEnabled($group['command'], $arguments); 178 | } 179 | 180 | /** 181 | * Reads the configuration file using the yml component and returns an array 182 | * @return array 183 | */ 184 | private function getConfiguration() 185 | { 186 | $yamlParser = new Parser(); 187 | $finder = new Finder(); 188 | $finder->name($this->configurationFileName)->in($this->kernelPath)->path('config')->depth(1); 189 | 190 | $results = iterator_to_array($finder); 191 | 192 | $file = current($results); 193 | if (false === $file) { 194 | throw new MissingConfigurationException(sprintf('A configuration file "%s" was expected to be found in %s.', $this->configurationFileName, $this->kernelPath . '/config')); 195 | } 196 | 197 | /** 198 | * @var SplFileInfo $file 199 | */ 200 | $contents = $file->getContents(); 201 | 202 | try { 203 | $config = $yamlParser->parse($contents); 204 | } catch (ParseException $e) { 205 | throw new InvalidConfigurationException(sprintf('The job configuration file "%s" cannot be parsed: %s', $file->getRealPath(), $e->getMessage())); 206 | } 207 | 208 | return $config; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /Service/SupervisordConfigFileWriter.php: -------------------------------------------------------------------------------- 1 | kernelPath = $kernelPath; 59 | $this->logsDir = $logsDir; 60 | $this->kernelEnv = $kernelEnv; 61 | $this->supervisordConfigPath = $supervisordConfigPath; 62 | $this->consumerPath = $consumerPath; 63 | $this->configFilePath = $configFilePath; 64 | $this->mode = self::MODE_PHP; 65 | } 66 | 67 | /** 68 | * @param string $mode a valid mode constant 69 | * @throws \Exception if an ainvalid mode supplied 70 | */ 71 | public function setMode($mode) 72 | { 73 | if (!in_array($mode, [self::MODE_PHP, self::MODE_CLI])) { 74 | throw new \Exception(sprintf('Mode `%s` is invalid', $mode)); 75 | } 76 | $this->mode = $mode; 77 | } 78 | 79 | /** 80 | * Sets configuration for topics (in order to configure consumers) 81 | */ 82 | public function setTopicsConfiguration($topics) 83 | { 84 | $this->topics = $topics; 85 | } 86 | 87 | /** 88 | * Writes the supervisord config file, the format of the file output depends on the mode 89 | */ 90 | public function writeConfig($uniqueEnvironment) 91 | { 92 | $fs = new Filesystem(); 93 | if (!$fs->exists($this->supervisordConfigPath)) { 94 | throw new IOException( 95 | sprintf("%s does not exist, please create this folder", $this->supervisordConfigPath) 96 | ); 97 | } 98 | 99 | $supervisordConfigFilePath = sprintf('%s/%s_programs.conf', $this->supervisordConfigPath, $uniqueEnvironment); 100 | 101 | if ($this->mode === self::MODE_CLI) { 102 | $conf = $this->getConfigForCliConsumer($uniqueEnvironment); 103 | } else { 104 | $conf = $this->getConfigForPhpConsumer($uniqueEnvironment); 105 | } 106 | 107 | file_put_contents($supervisordConfigFilePath, $conf); 108 | } 109 | 110 | /** 111 | * @param string $uniqueEnvironment environment disambiguator 112 | * @param bool $skipExistsChecks If set skips FS checks for binary and config file 113 | * @return string 114 | */ 115 | public function getConfigForCliConsumer($uniqueEnvironment, $skipExistsChecks = false) 116 | { 117 | // make sure consumer binary exists 118 | $fs = new Filesystem(); 119 | if (!$skipExistsChecks && !$fs->exists($this->consumerPath)) { 120 | throw new IOException( 121 | sprintf("%s does not exist, please ensure the consumer binary is installed", $this->consumerPath) 122 | ); 123 | } 124 | 125 | $kernelPath = $skipExistsChecks ? $this->kernelPath : realpath($this->kernelPath); 126 | 127 | // write a configuration entry for each queue 128 | $programNames = []; 129 | $conf = []; 130 | 131 | foreach ($this->topics as $topic => $topicConfig) { 132 | $programName = sprintf("markup_job_queue_%s_%s", $uniqueEnvironment, $topic); 133 | $programNames[] = $programName; 134 | 135 | $cliConfigFile = sprintf( 136 | '%s/%s_%s_consumer.conf', 137 | $this->configFilePath, 138 | $uniqueEnvironment, 139 | $topic 140 | ); 141 | 142 | if (!$skipExistsChecks && !$fs->exists($cliConfigFile)) { 143 | throw new IOException(sprintf("%s does not exist, ensure consumer config file has been written before writing supervisor config", $cliConfigFile)); 144 | } 145 | 146 | $consumer = sprintf( 147 | '%s -e "%s/console %s --strict-exit-code --env=%s --no-debug" -c %s -V -i --strict-exit-code', 148 | $this->consumerPath, 149 | $kernelPath, 150 | $topicConfig['consumer'], 151 | $this->kernelEnv, 152 | $cliConfigFile 153 | ); 154 | 155 | $conf[] = "\n"; 156 | $conf[] = sprintf("[program:%s]", $programName); 157 | $conf[] = sprintf("command=%s", $consumer); 158 | $conf[] = sprintf("stderr_logfile=%s/supervisord.error.log", $this->logsDir); 159 | $conf[] = sprintf("stdout_logfile=%s/supervisord.out.log", $this->logsDir); 160 | $conf[] = "autostart=false"; 161 | $conf[] = "autorestart=true"; 162 | $conf[] = "stopsignal=QUIT"; 163 | $conf[] = "startsecs=0"; 164 | } 165 | $conf[] = "\n"; 166 | $conf[] = sprintf("[group:markup_%s]\nprograms=%s", $uniqueEnvironment, implode(',', $programNames)); 167 | 168 | return implode("\n", $conf); 169 | } 170 | 171 | /** 172 | * @param string $uniqueEnvironment 173 | * @return string 174 | */ 175 | public function getConfigForPhpConsumer($uniqueEnvironment, $skipExistsChecks = false) 176 | { 177 | $kernelPath = $skipExistsChecks ? $this->kernelPath : realpath($this->kernelPath); 178 | $absoluteReleasePath = $skipExistsChecks ? $kernelPath.'/..' : realpath($kernelPath.'/..'); 179 | 180 | // write a configuration entry for each queue 181 | $programNames = []; 182 | $conf = []; 183 | 184 | foreach ($this->topics as $topic => $topicConfig) { 185 | //number of jobs to run before restarting... 186 | $programName = sprintf("markup_job_queue_%s_%s", $uniqueEnvironment, $topic); 187 | $programNames[] = $programName; 188 | $consumerCommand = sprintf( 189 | '%s/console %s -m %s %s --env=%s --no-debug', 190 | $this->kernelPath, 191 | 'rabbitmq:consumer', 192 | $topicConfig['prefetch_count'], 193 | $topic, 194 | $this->kernelEnv 195 | ); 196 | $conf[] = "\n"; 197 | $conf[] = sprintf("[program:%s]", $programName); 198 | $conf[] = sprintf("command=%s", $consumerCommand); 199 | $conf[] = sprintf("directory=%s", $absoluteReleasePath); 200 | $conf[] = sprintf("stderr_logfile=%s/supervisord.error.log", $this->logsDir); 201 | $conf[] = sprintf("stdout_logfile=%s/supervisord.out.log", $this->logsDir); 202 | $conf[] = "autostart=false"; 203 | $conf[] = "autorestart=true"; 204 | $conf[] = "stopsignal=QUIT"; 205 | $conf[] = "startsecs=0"; 206 | } 207 | $conf[] = "\n"; 208 | $conf[] = sprintf("[group:markup_%s]\nprograms=%s", $uniqueEnvironment, implode(',', $programNames)); 209 | 210 | return implode("\n", $conf); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Tests/Model/RecurringConsoleCommandConfigurationTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($config->getCommand(), 'foo:bar'); 14 | $this->assertEquals($config->getTopic(), 'test'); 15 | $this->assertEquals($config->getSchedule(), '30 1 * * *'); 16 | $this->assertEquals($config->getDescription(), 'a short description'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/Publisher/JobPublisherTest.php: -------------------------------------------------------------------------------- 1 | producer = m::mock('SimpleBus\RabbitMQBundle\RabbitMQPublisher'); 35 | $this->jobLogRepository = m::mock(JobLogRepository::class); 36 | $this->producer->shouldReceive('setContentType')->andReturn(null); 37 | $this->container = m::mock(ContainerInterface::class); 38 | $this->container->shouldReceive('has')->with('old_sound_rabbit_mq.test_producer')->andReturn(true); 39 | $this->container->shouldReceive('get')->with('old_sound_rabbit_mq.test_producer')->andReturn($this->producer); 40 | $this->container->shouldReceive('has')->with('old_sound_rabbit_mq.nonsense_producer')->andReturn(false); 41 | $this->container->shouldReceive('get')->with('logger')->andReturn(new NullLogger()); 42 | } 43 | 44 | public function testPublishingJobWithInvalidTopicThrowsException() 45 | { 46 | $this->expectException(UndefinedProducerException::class); 47 | $job = new BadJob([], 'nonsense'); 48 | $publisher = new JobPublisher($this->jobLogRepository, new NullLogger()); 49 | $publisher->setContainer($this->container); 50 | $publisher->publish($job); 51 | } 52 | 53 | public function testCanPublish() 54 | { 55 | $job = new BadJob([], 'test'); 56 | $publisher = new JobPublisher($this->jobLogRepository, new NullLogger()); 57 | $publisher->setContainer($this->container); 58 | $this->producer->shouldReceive('publish')->once()->andReturn(null); 59 | $publisher->publish($job); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/Service/CliConsumerConfigFileWriterTest.php: -------------------------------------------------------------------------------- 1 | getConfigString('dev', 'indexing', ['prefetch_count' => 1]); 25 | 26 | $this->assertEquals($fixture, $config); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Tests/Service/JobManagerTest.php: -------------------------------------------------------------------------------- 1 | jobPublisher = m::mock(JobPublisher::class); 34 | $this->jobManager = $this->createJobManager( 35 | $this->jobPublisher, 36 | $this->createScheduledJobRepositoryMock() 37 | ); 38 | } 39 | 40 | public function testCanAddJobWithoutDateTime(): void 41 | { 42 | $job = new SleepJob(); 43 | $this->jobManager->addJob($job); 44 | $this->assertSame([$job], $this->jobManager->getJobs()); 45 | } 46 | 47 | public function testCanAddConsoleCommandJobWithDateTime(): void 48 | { 49 | $job = 'muh:console:jerb'; 50 | $scheduledTime = new \DateTime(); 51 | $this->jobManager->addScheduledConsoleCommandJob($job, $scheduledTime); 52 | $this->assertCount(1, $this->jobManager->getJobs()); 53 | } 54 | 55 | public function testCanAddCommandJob(): void 56 | { 57 | $this->jobManager->addConsoleCommandJob('console:herp:derp', [], 'system', 60, 60); 58 | $this->assertCount(1, $this->jobManager->getJobs()); 59 | } 60 | 61 | public function testIdleTimeoutDefaultsToTimeout(): void 62 | { 63 | $timeout = 720; 64 | $this->jobManager->addConsoleCommandJob('command', [], 'topic', $timeout); 65 | /** @var Job $job */ 66 | $job = $this->jobManager->getJobs()[0]; 67 | $this->assertEquals($timeout, $job->getArgs()['idleTimeout']); 68 | } 69 | 70 | private function createScheduledJobRepositoryMock() 71 | { 72 | return m::mock(ScheduledJobRepository::class) 73 | ->shouldReceive('save') 74 | ->andReturn(null) 75 | ->getMock(); 76 | } 77 | 78 | private function createJobManager($jobPublisher, $scheduledJobRepository) 79 | { 80 | return new class ($jobPublisher, $scheduledJobRepository) extends JobManager { 81 | use JobStore; 82 | 83 | public function __construct(&$jobPublisher, $scheduledJobRepository) 84 | { 85 | parent::__construct($jobPublisher, $scheduledJobRepository); 86 | } 87 | 88 | public function addScheduledJob(ConsoleCommandJob $job, $scheduledTime): ScheduledJob 89 | { 90 | $this->addJob($job); 91 | return parent::addScheduledJob($job, $scheduledTime); 92 | } 93 | }; 94 | } 95 | } 96 | 97 | trait JobStore { 98 | /** 99 | * @var array 100 | */ 101 | private $jobs; 102 | 103 | public function addJob(Job $job, $supressLogging = false) 104 | { 105 | $this->jobs[] = $job; 106 | } 107 | 108 | public function getJobs(): array 109 | { 110 | return $this->jobs; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Tests/Service/SupervisordConfigFileWriterTest.php: -------------------------------------------------------------------------------- 1 | writer = new SupervisordConfigFileWriter( 13 | '/vagrant/app', 14 | '/vagrant/app/logs', 15 | 'dev', 16 | '/etc/supervisord/conf.d', 17 | '/usr/local/bin/rabbitmq-cli-consumer', 18 | '/etc/rabbitmq-cli-consumer/config' 19 | ); 20 | 21 | $topicA = ['prefetch_count' => 1, 'consumer' => 'markup:job_queue:consumer']; 22 | $topicB = ['prefetch_count' => 2, 'consumer' => 'markup:job_queue:consumer']; 23 | $this->writer->setTopicsConfiguration(['testqueuea' => $topicA, 'testqueueb' => $topicB]); 24 | } 25 | 26 | public function testWritesCliConfiguration() 27 | { 28 | $fixture = file_get_contents(__DIR__ . '/fixtures/supervisord_config_cli.conf'); 29 | $fixture = rtrim($fixture); 30 | $config = $this->writer->getConfigForCliConsumer('testenv', $skipCheck = true); 31 | 32 | $this->assertEquals($fixture, $config); 33 | } 34 | 35 | public function testWritesPhpConfiguration() 36 | { 37 | $fixture = file_get_contents(__DIR__ . '/fixtures/supervisord_config_php.conf'); 38 | $fixture = rtrim($fixture); 39 | $config = $this->writer->getConfigForPhpConsumer('testenv', $skipCheck = true); 40 | 41 | $this->assertEquals($fixture, $config); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/Service/fixtures/rabbitmq-cli-consumer-config.conf: -------------------------------------------------------------------------------- 1 | [rabbitmq] 2 | host=localhost 3 | username=test 4 | password=test 5 | vhost=/test 6 | port=5672 7 | queue=indexing 8 | compression=Off 9 | 10 | [logs] 11 | error=/var/log/rabbitmq-cli-consumer/dev_indexing_error.log 12 | info=/var/log/rabbitmq-cli-consumer/dev_indexing_info.log 13 | 14 | [prefetch] 15 | count=1 16 | global=Off 17 | 18 | [exchange] 19 | name=indexing 20 | autodelete=Off 21 | type=topic 22 | durable=On 23 | -------------------------------------------------------------------------------- /Tests/Service/fixtures/supervisord_config_cli.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | [program:markup_job_queue_testenv_testqueuea] 4 | command=/usr/local/bin/rabbitmq-cli-consumer -e "/vagrant/app/console markup:job_queue:consumer --strict-exit-code --env=dev --no-debug" -c /etc/rabbitmq-cli-consumer/config/testenv_testqueuea_consumer.conf -V -i --strict-exit-code 5 | stderr_logfile=/vagrant/app/logs/supervisord.error.log 6 | stdout_logfile=/vagrant/app/logs/supervisord.out.log 7 | autostart=false 8 | autorestart=true 9 | stopsignal=QUIT 10 | startsecs=0 11 | 12 | 13 | [program:markup_job_queue_testenv_testqueueb] 14 | command=/usr/local/bin/rabbitmq-cli-consumer -e "/vagrant/app/console markup:job_queue:consumer --strict-exit-code --env=dev --no-debug" -c /etc/rabbitmq-cli-consumer/config/testenv_testqueueb_consumer.conf -V -i --strict-exit-code 15 | stderr_logfile=/vagrant/app/logs/supervisord.error.log 16 | stdout_logfile=/vagrant/app/logs/supervisord.out.log 17 | autostart=false 18 | autorestart=true 19 | stopsignal=QUIT 20 | startsecs=0 21 | 22 | 23 | [group:markup_testenv] 24 | programs=markup_job_queue_testenv_testqueuea,markup_job_queue_testenv_testqueueb 25 | -------------------------------------------------------------------------------- /Tests/Service/fixtures/supervisord_config_php.conf: -------------------------------------------------------------------------------- 1 | 2 | 3 | [program:markup_job_queue_testenv_testqueuea] 4 | command=/vagrant/app/console rabbitmq:consumer -m 1 testqueuea --env=dev --no-debug 5 | directory=/vagrant/app/.. 6 | stderr_logfile=/vagrant/app/logs/supervisord.error.log 7 | stdout_logfile=/vagrant/app/logs/supervisord.out.log 8 | autostart=false 9 | autorestart=true 10 | stopsignal=QUIT 11 | startsecs=0 12 | 13 | 14 | [program:markup_job_queue_testenv_testqueueb] 15 | command=/vagrant/app/console rabbitmq:consumer -m 2 testqueueb --env=dev --no-debug 16 | directory=/vagrant/app/.. 17 | stderr_logfile=/vagrant/app/logs/supervisord.error.log 18 | stdout_logfile=/vagrant/app/logs/supervisord.out.log 19 | autostart=false 20 | autorestart=true 21 | stopsignal=QUIT 22 | startsecs=0 23 | 24 | 25 | [group:markup_testenv] 26 | programs=markup_job_queue_testenv_testqueuea,markup_job_queue_testenv_testqueueb 27 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markup/job-queue-bundle", 3 | "description": "The Markup Job Queue bundle integrates with oldsound/rabbiitmq-bundle to provide automatic scheduling of recurring console command jobs", 4 | "keywords": ["recurring", "rabbit-mq", "job", "queue", "cron"], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Calum Brodie", 10 | "email": "calum@usemarkup.com" 11 | }, 12 | { 13 | "name": "Markup", 14 | "homepage": "http://www.usemarkup.com/" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=7.1", 19 | "symfony/framework-bundle": "^3.4|^4", 20 | "symfony/finder": "^3.4|^4", 21 | "symfony/yaml": "^3.4|^4", 22 | "symfony/console": "^3.4|^4", 23 | "symfony/process": "^3.4|^4", 24 | "twig/twig": "^2", 25 | "mtdowling/cron-expression": "1.0.*", 26 | "doctrine/orm": "~2.7", 27 | "doctrine/collections": "^1.6", 28 | "doctrine/persistence": "^1.3", 29 | "php-amqplib/rabbitmq-bundle": "^1.14", 30 | "snc/redis-bundle": "~1.1.2|^2", 31 | "markup/rabbitmq-management-api": ">=2.1.1", 32 | "pagerfanta/pagerfanta": "~1.0.2|^2", 33 | "ramsey/uuid": "^3.8" 34 | }, 35 | "require-dev": { 36 | "phpunit/phpunit": "^7.2", 37 | "mockery/mockery": "^1.2", 38 | "symfony/form": "^3.4|^4", 39 | "predis/predis": "^1.0", 40 | "phpstan/phpstan-shim": "^0.11.8" 41 | }, 42 | "suggest": { 43 | "ricbra/rabbitmq-cli-consumer": "To consume jobs with Go instead of PHP" 44 | }, 45 | "autoload": { 46 | "psr-4": { "Markup\\JobQueueBundle\\": "" } 47 | }, 48 | "extra": { 49 | "branch-alias": { 50 | "dev-master": "2.0-dev" 51 | } 52 | }, 53 | "config": { 54 | "bin-dir": "bin" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | excludes_analyse: 3 | - %currentWorkingDirectory%/DependencyInjection/Configuration.php 4 | - %currentWorkingDirectory%/Tests/* 5 | - %currentWorkingDirectory%/vendor/* 6 | - %currentWorkingDirectory%/bin/* 7 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ./Tests 8 | 9 | 10 | 11 | 12 | 13 | ./ 14 | 15 | ./Resources 16 | ./Tests 17 | ./vendor 18 | 19 | 20 | 21 | --------------------------------------------------------------------------------