diff --git a/_config/cli.yml b/_config/cli.yml new file mode 100644 index 00000000..932891f0 --- /dev/null +++ b/_config/cli.yml @@ -0,0 +1,6 @@ +--- +Name: queuedjobs-cli +--- +SilverStripe\Cli\Sake: + commands: + - 'Symbiote\QueuedJobs\Cli\ProcessJobQueueChildCommand' diff --git a/_config/taskrunner.yml b/_config/taskrunner.yml index 7cdfa26b..c4469503 100644 --- a/_config/taskrunner.yml +++ b/_config/taskrunner.yml @@ -4,8 +4,7 @@ After: - DevelopmentAdmin --- SilverStripe\Dev\DevelopmentAdmin: - registered_controllers: + controllers: tasks: - controller: Symbiote\QueuedJobs\Controllers\QueuedTaskRunner - links: - tasks: 'See a list of build tasks to run (QueuedJobs version)' + class: Symbiote\QueuedJobs\Controllers\QueuedTaskRunner + description: 'See a list of build tasks to run (QueuedJobs version)' diff --git a/src/Cli/ProcessJobQueueChildCommand.php b/src/Cli/ProcessJobQueueChildCommand.php new file mode 100644 index 00000000..f75b103e --- /dev/null +++ b/src/Cli/ProcessJobQueueChildCommand.php @@ -0,0 +1,38 @@ +getArgument('base64-task'))); + if ($task) { + $this->getService()->runJob($task->getDescriptor()->ID); + } + return Command::SUCCESS; + } + + /** + * Returns an instance of the QueuedJobService. + * + * @return QueuedJobService + */ + protected function getService() + { + return QueuedJobService::singleton(); + } + + protected function configure() + { + $this->addArgument('base64-task', InputArgument::REQUIRED); + } +} diff --git a/src/Controllers/QueuedTaskRunner.php b/src/Controllers/QueuedTaskRunner.php index 57996535..0660dd7a 100644 --- a/src/Controllers/QueuedTaskRunner.php +++ b/src/Controllers/QueuedTaskRunner.php @@ -19,7 +19,6 @@ use Symbiote\QueuedJobs\Services\QueuedJobService; use Symbiote\QueuedJobs\Tasks\CreateQueuedJobTask; use Symbiote\QueuedJobs\Tasks\DeleteAllJobsTask; -use Symbiote\QueuedJobs\Tasks\ProcessJobQueueChildTask; use Symbiote\QueuedJobs\Tasks\ProcessJobQueueTask; /** @@ -29,55 +28,34 @@ */ class QueuedTaskRunner extends TaskRunner { - /** - * @var array - */ - private static $url_handlers = [ + private static array $url_handlers = [ 'queue/$TaskName' => 'queueTask', ]; - /** - * @var array - */ - private static $allowed_actions = [ + private static array $allowed_actions = [ 'queueTask', ]; - /** - * @var array - */ - private static $css = [ + private static array $css = [ 'symbiote/silverstripe-queuedjobs:client/styles/task-runner.css', ]; /** - * Tasks on this list will be available to be run only via browser - * - * @config - * @var array + * Tasks on this list will not be available to run via the jobs queue */ - private static $task_blacklist = [ + private static array $task_blacklist = [ ProcessJobQueueTask::class, - ProcessJobQueueChildTask::class, CreateQueuedJobTask::class, DeleteAllJobsTask::class, ]; /** * Tasks on this list will be available to be run only via jobs queue - * - * @config - * @var array */ - private static $queued_only_tasks = []; + private static array $queued_only_tasks = []; public function index() { - if (Director::is_cli()) { - // CLI mode - revert to default behaviour - return parent::index(); - } - $baseUrl = Director::absoluteBaseURL(); $tasks = $this->getTasks(); @@ -108,6 +86,8 @@ public function index() 'Title' => $task['title'], 'Description' => $task['description'], 'Type' => 'universal', + 'Parameters' => $task['parameters'], + 'Help' => $task['help'], ])); } @@ -118,18 +98,20 @@ public function index() 'Title' => $task['title'], 'Description' => $task['description'], 'Type' => 'immediate', + 'Parameters' => $task['parameters'], + 'Help' => $task['help'], ])); } // Queue only tasks - $queueOnlyTaskList = ArrayList::create(); - foreach ($queuedOnlyTasks as $task) { $taskList->push(ArrayData::create([ 'QueueLink' => Controller::join_links($baseUrl, 'dev/tasks/queue', $task['segment']), 'Title' => $task['title'], 'Description' => $task['description'], 'Type' => 'queue-only', + 'Parameters' => $task['parameters'], + 'Help' => $task['help'], ])); } diff --git a/src/Jobs/RunBuildTaskJob.php b/src/Jobs/RunBuildTaskJob.php index 775fb478..aea0e3eb 100644 --- a/src/Jobs/RunBuildTaskJob.php +++ b/src/Jobs/RunBuildTaskJob.php @@ -5,9 +5,11 @@ use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\BuildTask; +use SilverStripe\HybridExecution\HybridOutput; use SilverStripe\ORM\DataObject; use Symbiote\QueuedJobs\Services\AbstractQueuedJob; use Symbiote\QueuedJobs\Services\QueuedJob; +use Symfony\Component\Console\Input\ArrayInput; /** * A convenience wrapper for running BuildTask implementations. @@ -83,8 +85,10 @@ public function process() $getVars = []; parse_str($this->QueryString ?? '', $getVars); - $request = new HTTPRequest('GET', '/', $getVars); - $task->run($request); + $output = HybridOutput::create(HybridOutput::FORMAT_ANSI); + $input = new ArrayInput($getVars); + $input->setInteractive(false); + $task->run($input, $output); $this->currentStep = 1; $this->isComplete = true; diff --git a/src/Services/AbstractQueuedJob.php b/src/Services/AbstractQueuedJob.php index f47604bb..6716893a 100644 --- a/src/Services/AbstractQueuedJob.php +++ b/src/Services/AbstractQueuedJob.php @@ -271,4 +271,30 @@ public function __get($name) { return isset($this->jobData->$name) ? $this->jobData->$name : null; } + + /** + * Resolves a queue name to one of the queue constants. + * If $queue is already an int representing a queue, that int will be returned. + * If the queue is unknown, `null` will be returned. + */ + public static function getQueue(string|int $queue): ?int + { + switch (strtolower($queue)) { + case 'immediate': + $queue = QueuedJob::IMMEDIATE; + break; + case 'queued': + $queue = QueuedJob::QUEUED; + break; + case 'large': + $queue = QueuedJob::LARGE; + break; + default: + $queues = [QueuedJob::IMMEDIATE, QueuedJob::QUEUED, QueuedJob::LARGE]; + if (!ctype_digit($queue) || !in_array((int) $queue, $queues)) { + return null; + } + } + return $queue; + } } diff --git a/src/Tasks/CheckJobHealthTask.php b/src/Tasks/CheckJobHealthTask.php index d5544a2a..3717dea6 100644 --- a/src/Tasks/CheckJobHealthTask.php +++ b/src/Tasks/CheckJobHealthTask.php @@ -2,25 +2,21 @@ namespace Symbiote\QueuedJobs\Tasks; -use Exception; -use SilverStripe\Control\HTTPRequest; +use Composer\Console\Input\InputOption; +use Psr\Log\LoggerInterface; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\BuildTask; -use Symbiote\QueuedJobs\Services\QueuedJob; +use SilverStripe\HybridExecution\HybridOutput; +use Symbiote\QueuedJobs\Services\AbstractQueuedJob; use Symbiote\QueuedJobs\Services\QueuedJobService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; class CheckJobHealthTask extends BuildTask { - /** - * {@inheritDoc} - * @var string - */ - private static $segment = 'CheckJobHealthTask'; + protected static string $commandName = 'CheckJobHealthTask'; - /** - * {@inheritDoc} - * @return string - */ - public function getDescription() + public static function getDescription(): string { return _t( __CLASS__ . '.Description', @@ -32,30 +28,49 @@ public function getDescription() /** * Implement this method in the task subclass to * execute via the TaskRunner - * - * @param HTTPRequest $request - * @return - * - * @throws Exception */ - public function run($request) + protected function execute(InputInterface $input, HybridOutput $output): int { - $queue = $request->requestVar('queue') ?: QueuedJob::QUEUED; + $queue = AbstractQueuedJob::getQueue($input->getOption('queue')); + if ($queue === null) { + $output->writeln('queue must be one of "immediate", "queued", or "large"'); + return Command::INVALID; + } $jobHealth = $this->getService()->checkJobHealth($queue); $unhealthyJobCount = 0; foreach ($jobHealth as $type => $IDs) { $count = count($IDs ?? []); - echo 'Detected and attempted restart on ' . $count . ' ' . $type . ' jobs'; + $output->writeln('Detected and attempted restart on ' . $count . ' ' . $type . ' jobs'); $unhealthyJobCount = $unhealthyJobCount + $count; } if ($unhealthyJobCount > 0) { - throw new Exception("$unhealthyJobCount jobs are unhealthy"); + $msg = "$unhealthyJobCount jobs are unhealthy"; + /** @var LoggerInterface $logger */ + $Logger = Injector::inst()->get(LoggerInterface::class . '.errorhandler'); + $Logger->error($msg); + $output->writeln($msg); + return Command::FAILURE; } - echo 'All jobs are healthy'; + $output->writeln('All jobs are healthy'); + return Command::SUCCESS; + } + + public function getOptions(): array + { + return [ + new InputOption( + 'queue', + null, + InputOption::VALUE_REQUIRED, + 'The queue to check', + 'queued', + ['immediate', 'queued', 'large'] + ), + ]; } protected function getService() diff --git a/src/Tasks/CreateQueuedJobTask.php b/src/Tasks/CreateQueuedJobTask.php index fa73f882..6d3e3ae1 100644 --- a/src/Tasks/CreateQueuedJobTask.php +++ b/src/Tasks/CreateQueuedJobTask.php @@ -2,11 +2,16 @@ namespace Symbiote\QueuedJobs\Tasks; -use SilverStripe\Control\HTTPRequest; +use Closure; use SilverStripe\Core\ClassInfo; use SilverStripe\Dev\BuildTask; +use SilverStripe\HybridExecution\HybridOutput; use SilverStripe\ORM\FieldType\DBDatetime; +use Symbiote\QueuedJobs\Services\QueuedJob; use Symbiote\QueuedJobs\Services\QueuedJobService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * A task that can be used to create a queued job. @@ -21,16 +26,9 @@ */ class CreateQueuedJobTask extends BuildTask { - /** - * {@inheritDoc} - * @var string - */ - private static $segment = 'CreateQueuedJobTask'; + protected static string $commandName = 'CreateQueuedJobTask'; - /** - * @return string - */ - public function getDescription() + public static function getDescription(): string { return _t( __CLASS__ . '.Description', @@ -39,31 +37,57 @@ public function getDescription() ); } - /** - * @param HTTPRequest $request - */ - public function run($request) + protected function execute(InputInterface $input, HybridOutput $output): int { - if (isset($request['name']) && ClassInfo::exists($request['name'])) { - $clz = $request['name']; + $name = $input->getOption('name'); + if ($name && ClassInfo::exists($name)) { + $clz = $name; $job = new $clz(); } else { $job = new DummyQueuedJob(mt_rand(10, 100)); } - if (isset($request['start'])) { - $start = strtotime($request['start'] ?? ''); + $start = $input->getOption('start'); + if ($start) { + $start = strtotime($start); $now = DBDatetime::now()->getTimestamp(); if ($start >= $now) { $friendlyStart = DBDatetime::create()->setValue($start)->Rfc2822(); - echo 'Job queued to start at: ' . $friendlyStart . ''; + $output->writeln('Job queued to start at: ' . $friendlyStart . ''); QueuedJobService::singleton()->queueJob($job, $start); } else { - echo "'start' parameter must be a date/time in the future, parseable with strtotime"; + $output->writeln("'start' parameter must be a date/time in the future, parseable with strtotime"); } } else { - echo "Job Queued"; + $output->writeln('Job Queued'); QueuedJobService::singleton()->queueJob($job); } + return Command::SUCCESS; + } + + public function getOptions(): array + { + return [ + new InputOption( + 'name', + null, + InputOption::VALUE_REQUIRED, + 'Fully qualified classname for the job to queue', + suggestedValues: Closure::fromCallable([static::class, 'getAllQueuedJobClasses']) + ), + new InputOption( + 'start', + null, + InputOption::VALUE_REQUIRED, + 'When to start the job. Must be parsable by ' + . 'strtotime' + ), + ]; + } + + public static function getAllQueuedJobClasses(): array + { + $classes = ClassInfo::implementorsOf(QueuedJob::class); + return $classes; } } diff --git a/src/Tasks/DeleteAllJobsTask.php b/src/Tasks/DeleteAllJobsTask.php index 4befd0c9..b773d312 100644 --- a/src/Tasks/DeleteAllJobsTask.php +++ b/src/Tasks/DeleteAllJobsTask.php @@ -2,10 +2,14 @@ namespace Symbiote\QueuedJobs\Tasks; -use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\Director; use SilverStripe\Dev\BuildTask; +use SilverStripe\HybridExecution\HybridOutput; use SilverStripe\ORM\DataObject; use Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * An administrative task to delete all queued jobs records from the database. @@ -13,41 +17,39 @@ */ class DeleteAllJobsTask extends BuildTask { - /** - * @inheritdoc - * @return string - */ - public function getTitle() + public function getTitle(): string { return "Delete all queued jobs."; } - /** - * @inheritdoc - * @return string - */ - public function getDescription() + public static function getDescription(): string { return "Remove all queued jobs from the database. Use with caution!"; } - /** - * Run the task - * @param HTTPRequest $request - */ - public function run($request) + protected function execute(InputInterface $input, HybridOutput $output): int { - $confirm = $request->getVar('confirm'); - $jobs = DataObject::get(QueuedJobDescriptor::class); - if (!$confirm) { - echo "Really delete " . $jobs->count() . " jobs? Please add ?confirm=1 to the URL to confirm."; - return; + if (!$input->getOption('confirm')) { + if (Director::is_cli()) { + $confirmText = '?confirm=1 to the URL'; + } else { + $confirmText = '--confirm'; + } + $output->writeln('Really delete ' . $jobs->count() . " jobs? Please add $confirmText to confirm."); + return Command::INVALID; } - echo "Deleting " . $jobs->count() . " jobs...
\n"; + $output->writeln('Deleting ' . $jobs->count() . ' jobs...'); $jobs->removeAll(); - echo "Done."; + return Command::SUCCESS; + } + + public function getOptions(): array + { + return [ + new InputOption('confirm', null, InputOption::VALUE_NONE, ''), + ]; } } diff --git a/src/Tasks/Engines/DoormanRunner.php b/src/Tasks/Engines/DoormanRunner.php index 41981e44..6483d745 100644 --- a/src/Tasks/Engines/DoormanRunner.php +++ b/src/Tasks/Engines/DoormanRunner.php @@ -2,7 +2,6 @@ namespace Symbiote\QueuedJobs\Tasks\Engines; -use SilverStripe\Dev\Deprecation; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Environment; @@ -11,7 +10,6 @@ use Symbiote\QueuedJobs\Jobs\DoormanQueuedJobTask; use Symbiote\QueuedJobs\Services\ProcessManager; use Symbiote\QueuedJobs\Services\QueuedJob; -use Symbiote\QueuedJobs\Services\QueuedJobService; /** * Runs all jobs through the doorman engine @@ -38,12 +36,12 @@ class DoormanRunner extends BaseRunner implements TaskRunnerEngine private static $tick_interval = 1; /** - * Name of the dev task used to run the child process + * Name of the command used to run the child process * * @config * @var string */ - private static $child_runner = 'ProcessJobQueueChildTask'; + private static $child_runner = 'queuedjobs:process-queue-child'; /** * @var string[] @@ -92,7 +90,7 @@ public function runQueue($queue) $manager = Injector::inst()->create(ProcessManager::class); $manager->setWorker( sprintf( - '%s/vendor/silverstripe/framework/cli-script.php dev/tasks/%s', + '%s/vendor/bin/sake %s', BASE_PATH, $this->getChildRunner() ) diff --git a/src/Tasks/ProcessJobQueueChildTask.php b/src/Tasks/ProcessJobQueueChildTask.php deleted file mode 100644 index e6c5e51f..00000000 --- a/src/Tasks/ProcessJobQueueChildTask.php +++ /dev/null @@ -1,43 +0,0 @@ -getService()->runJob($task->getDescriptor()->ID); - } - } - - /** - * Returns an instance of the QueuedJobService. - * - * @return QueuedJobService - */ - protected function getService() - { - return QueuedJobService::singleton(); - } -} diff --git a/src/Tasks/ProcessJobQueueTask.php b/src/Tasks/ProcessJobQueueTask.php index 84df6ac8..5efe8945 100644 --- a/src/Tasks/ProcessJobQueueTask.php +++ b/src/Tasks/ProcessJobQueueTask.php @@ -2,14 +2,17 @@ namespace Symbiote\QueuedJobs\Tasks; -use Monolog\Handler\FilterHandler; -use Monolog\Handler\StreamHandler; use Monolog\Logger; -use SilverStripe\Control\HTTPRequest; -use SilverStripe\Core\Environment; +use SilverStripe\Control\Director; use SilverStripe\Dev\BuildTask; +use SilverStripe\HybridExecution\HybridOutput; +use SilverStripe\HybridExecution\HybridOutputLogHandler; +use Symbiote\QueuedJobs\Services\AbstractQueuedJob; use Symbiote\QueuedJobs\Services\QueuedJob; use Symbiote\QueuedJobs\Services\QueuedJobService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * Task used to process the job queue @@ -19,16 +22,9 @@ */ class ProcessJobQueueTask extends BuildTask { - /** - * {@inheritDoc} - * @var string - */ - private static $segment = 'ProcessJobQueueTask'; + protected static string $commandName = 'ProcessJobQueueTask'; - /** - * @return string - */ - public function getDescription() + public static function getDescription(): string { return _t( __CLASS__ . '.Description', @@ -36,88 +32,49 @@ public function getDescription() ); } - /** - * @param HTTPRequest $request - */ - public function run($request) + protected function execute(InputInterface $input, HybridOutput $output): int { if (QueuedJobService::singleton()->isMaintenanceLockActive()) { - return; + return Command::FAILURE; + } + $queue = AbstractQueuedJob::getQueue($input->getOption('queue')); + if ($queue === null) { + $output->writeln('queue must be one of "immediate", "queued", or "large"'); + return Command::INVALID; } $service = $this->getService(); - // Ensure that log messages are visible when executing this task on CLI. - // Could be replaced with BuildTask logger: https://github.com/silverstripe/silverstripe-framework/issues/9183 - if (Environment::isCli()) { + // Ensure that log messages are visible when executing this task in CLI. + // Running the task via browser doesn't need this output because you can check the job in the CMS. + // Note that if we want to output this to the browser in the future, simply removing this condition + // isn't enough, because it'll end up double-logging in the job messages tab. + if (Director::is_cli()) { $logger = $service->getLogger(); - - // Assumes that general purpose logger usually doesn't already contain a stream handler. - $errorHandler = new StreamHandler('php://stderr', Logger::ERROR); - $standardHandler = new StreamHandler('php://stdout'); - - // Avoid double logging of errors - $standardFilterHandler = new FilterHandler( - $standardHandler, - Logger::DEBUG, - Logger::WARNING - ); - - $logger->pushHandler($standardFilterHandler); - $logger->pushHandler($errorHandler); + if ($logger instanceof Logger) { + $logger->pushHandler(HybridOutputLogHandler::create($output)); + } } - if ($request->getVar('list')) { + if ($input->getOption('list')) { // List helper $service->queueRunner->listJobs(); - return; + return Command::SUCCESS; } // Check if there is a job to run - if (($job = $request->getVar('job')) && strpos($job ?? '', '-')) { - // Run from a isngle job + $job = $input->getOption('job'); + if ($job && strpos($job, '-')) { + // Run from a single job $parts = explode('-', $job ?? ''); $id = $parts[1]; $service->runJob($id); - return; + return Command::SUCCESS; } // Run the queue - $queue = $this->getQueue($request); $service->runQueue($queue); - } - - /** - * Resolves the queue name to one of a few aliases. - * - * @todo Solve the "Queued"/"queued" mystery! - * - * @param HTTPRequest $request - * @return string - */ - protected function getQueue($request) - { - $queue = $request->getVar('queue'); - - if (!$queue) { - $queue = 'Queued'; - } - - switch (strtolower($queue ?? '')) { - case 'immediate': - $queue = QueuedJob::IMMEDIATE; - break; - case 'queued': - $queue = QueuedJob::QUEUED; - break; - case 'large': - $queue = QueuedJob::LARGE; - break; - default: - break; - } - - return $queue; + return Command::SUCCESS; } /** @@ -129,4 +86,20 @@ public function getService() { return QueuedJobService::singleton(); } + + public function getOptions(): array + { + return [ + new InputOption('list', null, InputOption::VALUE_NONE, 'List jobs instead of processing a queue'), + new InputOption('job', null, InputOption::VALUE_REQUIRED, 'A specific job to run'), + new InputOption( + 'queue', + null, + InputOption::VALUE_REQUIRED, + 'The queue to process', + 'queued', + ['immediate', 'queued', 'large'] + ), + ]; + } } diff --git a/src/Tasks/PublishItemsTask.php b/src/Tasks/PublishItemsTask.php index b486543e..5c4d9807 100644 --- a/src/Tasks/PublishItemsTask.php +++ b/src/Tasks/PublishItemsTask.php @@ -4,8 +4,12 @@ use Exception; use SilverStripe\Dev\BuildTask; +use SilverStripe\HybridExecution\HybridOutput; use SilverStripe\ORM\DataObject; use Symbiote\QueuedJobs\Jobs\PublishItemsJob; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * An example build task that publishes a bunch of pages - this demonstrates a realworld example of how the @@ -16,21 +20,13 @@ */ class PublishItemsTask extends BuildTask { - /** - * {@inheritDoc} - * @var string - */ - private static $segment = 'PublishItemsTask'; + protected static string $commandName = 'PublishItemsTask'; - /** - * @throws Exception - * @param HTTPRequest $request - */ - public function run($request) + protected function execute(InputInterface $input, HybridOutput $output): int { - $root = $request->getVar('parent'); + $root = $input->getOption('parent'); if (!$root) { - throw new Exception("Sorry, you must provide a parent node to publish from"); + $output->writeln('Sorry, you must provide a parent node to publish from'); } $item = DataObject::get_by_id('Page', $root); @@ -39,5 +35,18 @@ public function run($request) $job = new PublishItemsJob($root); singleton('Symbiote\\QueuedJobs\\Services\\QueuedJobService')->queueJob($job); } + return Command::SUCCESS; + } + + public function getOptions(): array + { + return [ + new InputOption( + 'parent', + null, + InputOption::VALUE_REQUIRED, + 'The ID of the page you want to publish. This page and its children will be published' + ), + ]; } } diff --git a/templates/Symbiote/QueuedJobs/Controllers/QueuedTaskRunner.ss b/templates/Symbiote/QueuedJobs/Controllers/QueuedTaskRunner.ss index fb11b192..4532f7f2 100644 --- a/templates/Symbiote/QueuedJobs/Controllers/QueuedTaskRunner.ss +++ b/templates/Symbiote/QueuedJobs/Controllers/QueuedTaskRunner.ss @@ -29,7 +29,19 @@ $Info.RAW

$Title

-
$Description
+
+ $Description + <% if $Help %> +
+ Display additional information + $Help +
+ <% end_if %> +
+ <% if $Parameters %> + Parameters: + <% include SilverStripe/Dev/Parameters %> + <% end_if %>
<% if $TaskLink %>