<?php
declare(strict_types=1);
/**
* @file tools/jobs.php
*
* Copyright (c) 2014-2022 Simon Fraser University
* Copyright (c) 2003-2022 John Willinsky
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
*
* @class commandJobs
*
* @ingroup tools
*
* @brief CLI tool to list, iterate and purge queued jobs on database
*/
namespace PKP\tools;
use APP\core\Application;
use APP\facades\Repo;
use Carbon\Carbon;
use Illuminate\Console\Concerns\InteractsWithIO;
use Illuminate\Console\OutputStyle;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use PKP\cliTool\CommandLineTool;
use PKP\config\Config;
use PKP\job\models\Job as PKPJobModel;
use PKP\jobs\testJobs\TestJobFailure;
use PKP\jobs\testJobs\TestJobSuccess;
use PKP\queue\WorkerConfiguration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\Console\Exception\InvalidArgumentException as CommandInvalidArgumentException;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Helper\TableCell;
use Symfony\Component\Console\Helper\TableCellStyle;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\StreamOutput;
use Throwable;
define('APP_ROOT', dirname(__FILE__, 4));
require_once APP_ROOT . '/tools/bootstrap.php';
class commandInterface
{
use InteractsWithIO;
public function __construct()
{
$output = new OutputStyle(
new StringInput(''),
new StreamOutput(fopen('php://stdout', 'w'))
);
$this->setOutput($output);
}
public function errorBlock(array $messages = [], ?string $title = null): void
{
$this->getOutput()->block(
$messages,
$title,
'fg=white;bg=red',
' ',
true
);
}
}
class commandJobs extends CommandLineTool
{
protected const AVAILABLE_OPTIONS = [
'list' => 'admin.cli.tool.jobs.available.options.list.description',
'purge' => 'admin.cli.tool.jobs.available.options.purge.description',
'test' => 'admin.cli.tool.jobs.available.options.test.description',
'total' => 'admin.cli.tool.jobs.available.options.total.description',
'help' => 'admin.cli.tool.jobs.available.options.help.description',
'run' => 'admin.cli.tool.jobs.available.options.run.description',
'work' => 'admin.cli.tool.jobs.available.options.work.description',
'failed' => 'admin.cli.tool.jobs.available.options.failed.description',
'restart' => 'admin.cli.tool.jobs.available.options.restart.description',
'usage' => 'admin.cli.tool.jobs.available.options.usage.description',
];
protected const CURRENT_PAGE = 'current';
protected const PREVIOUS_PAGE = 'previous';
protected const NEXT_PAGE = 'next';
/**
* @var null|string Which option will be call?
*/
protected $option = null;
/**
* @var null|array Parameters and arguments from CLI
*/
protected $parameterList = null;
/**
* CLI interface, this object should extends InteractsWithIO
*/
protected $commandInterface = null;
/**
* Constructor
*/
public function __construct($argv = [])
{
parent::__construct($argv);
array_shift($argv);
$this->setParameterList($argv);
if (!isset($this->getParameterList()[0])) {
throw new CommandNotFoundException(
__('admin.cli.tool.jobs.empty.option'),
array_keys(self::AVAILABLE_OPTIONS)
);
}
$this->option = $this->getParameterList()[0];
$this->setCommandInterface(new commandInterface());
}
public function setCommandInterface(commandInterface $commandInterface): self
{
$this->commandInterface = $commandInterface;
return $this;
}
public function getCommandInterface(): commandInterface
{
return $this->commandInterface;
}
/**
* Save the parameter list passed on CLI
*
* @param array $items Array with parameters and arguments passed on CLI
*
*/
public function setParameterList(array $items): self
{
$parameters = [];
foreach ($items as $param) {
if (strpos($param, '=')) {
[$key, $value] = explode('=', ltrim($param, '-'));
$parameters[$key] = $value;
continue;
}
$parameters[] = $param;
}
$this->parameterList = $parameters;
return $this;
}
/**
* Get the parameter list passed on CLI
*
*/
public function getParameterList(): ?array
{
return $this->parameterList;
}
/**
* Get the value of a specific parameter
*
* @param mixed $default
*
*/
protected function getParameterValue(string $parameter, mixed $default = null): mixed
{
if (!isset($this->getParameterList()[$parameter])) {
return $default;
}
return $this->getParameterList()[$parameter];
}
/**
* Print command usage information.
*/
public function usage()
{
$this->getCommandInterface()->line('<comment>' . __('admin.cli.tool.usage.title') . '</comment>');
$this->getCommandInterface()->line(__('admin.cli.tool.usage.parameters') . PHP_EOL);
$this->getCommandInterface()->line('<comment>' . __('admin.cli.tool.available.commands', ['namespace' => 'jobs']) . '</comment>');
$this->printUsage(self::AVAILABLE_OPTIONS);
}
/**
* Alias for usage command
*/
public function help(): void
{
$this->usage();
}
/**
* Retrieve the columnWidth based on the commands text size
*/
protected function getColumnWidth(array $commands): int
{
$widths = [];
foreach ($commands as $command) {
$widths[] = Helper::width($command);
}
return $widths ? max($widths) + 2 : 0;
}
/**
* Failed jobs list/redispatch/remove
*/
protected function failed(): void
{
$parameterList = $this->getParameterList();
if (in_array('--redispatch', $parameterList) || ($jobIds = $this->getParameterValue('redispatch'))) {
$jobsCount = Repo::failedJob()->redispatchToQueue(
$this->getParameterValue('queue'),
collect(explode(',', $jobIds ?? ''))
->filter()
->map(fn ($item) => (int)$item)
->toArray()
);
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.failed.redispatch.successful', ['jobsCount' => $jobsCount]));
return;
}
if (in_array('--clear', $parameterList) || ($jobIds = $this->getParameterValue('clear'))) {
$jobsCount = Repo::failedJob()->deleteJobs(
$this->getParameterValue('queue'),
collect(explode(',', $jobIds ?? ''))
->filter()
->map(fn ($item) => (int)$item)
->toArray()
);
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.failed.clear.successful', ['jobsCount' => $jobsCount]));
return;
}
array_push($this->parameterList, '--failed');
$this->list();
}
/**
* Signal the queue worker to quit gracefully
*/
protected function restart(): void
{
$cache = app()->get("cache.store"); /** @var \Illuminate\Contracts\Cache\Repository $cache */
$cache->forever('illuminate:queue:restart', Carbon::now()->getTimestamp());
$this
->getCommandInterface()
->getOutput()
->info(__('admin.cli.tool.jobs.available.options.restart.confirm'));
}
/**
* List all queued jobs
*/
protected function list(): void
{
$perPage = $this->getParameterValue('perPage', '10');
$page = $this->getParameterValue('page', '1');
$parameterList = $this->getparameterList();
$repository = in_array('--failed', $parameterList) ? Repo::failedJob() : Repo::job();
$data = $repository
->setOutputFormat($repository::OUTPUT_CLI)
->perPage((int) $perPage)
->setPage((int) $page)
->showJobs();
$this->total();
$this->getCommandInterface()->table(
$this->getListTableFormat(),
$data
->map(fn(JsonResource $job) => $job->toArray(app('request')))
->toArray()
);
$pagination = [
'pagination' => [
self::CURRENT_PAGE => $data->currentPage(),
self::PREVIOUS_PAGE => ($data->currentPage() - 1) > 0 ? $data->currentPage() - 1 : 1,
self::NEXT_PAGE => $data->currentPage(),
],
];
if ($data->hasMorePages()) {
$pagination['pagination'][self::NEXT_PAGE] = $data->currentPage() + 1;
}
$this->getCommandInterface()
->table(
[
[
new TableCell(
__('admin.cli.tool.jobs.pagination'),
[
'colspan' => 3,
'style' => new TableCellStyle(['align' => 'center'])
]
)
],
[
__('admin.cli.tool.jobs.pagination.current'),
__('admin.cli.tool.jobs.pagination.previous'),
__('admin.cli.tool.jobs.pagination.next'),
]
],
$pagination
);
}
/**
* Get table format for list view
*/
protected function getListTableFormat(): array
{
$listForFailedJobs = in_array('--failed', $this->getParameterList());
return [
[
new TableCell(
$listForFailedJobs
? __('admin.cli.tool.jobs.queued.jobs.failed.title')
: __('admin.cli.tool.jobs.queued.jobs.title'),
[
'colspan' => $listForFailedJobs ? 6 : 7,
'style' => new TableCellStyle(['align' => 'center'])
]
)
],
array_merge([
__('admin.cli.tool.jobs.queued.jobs.fields.id'),
__('admin.cli.tool.jobs.queued.jobs.fields.queue'),
__('admin.cli.tool.jobs.queued.jobs.fields.job.display.name'),
], $listForFailedJobs ? [
__('admin.cli.tool.jobs.queued.jobs.fields.connection'),
__('admin.cli.tool.jobs.queued.jobs.fields.failed.at'),
__('admin.cli.tool.jobs.queued.jobs.fields.exception'),
] : [
__('admin.cli.tool.jobs.queued.jobs.fields.attempts'),
__('admin.cli.tool.jobs.queued.jobs.fields.reserved.at'),
__('admin.cli.tool.jobs.queued.jobs.fields.available.at'),
__('admin.cli.tool.jobs.queued.jobs.fields.created.at')
])
];
}
/**
* Run daemon worker process to continue handle jobs
*/
protected function work(): void
{
$parameterList = $this->getParameterList();
if (in_array('--help', $parameterList)) {
$this->workerOptionsHelp();
return;
}
if (Application::isUnderMaintenance()) {
$this->getCommandInterface()->getOutput()->error(__('admin.cli.tool.jobs.maintenance.message'));
return;
}
if (Config::getVar('general', 'sandbox', false)) {
$this->getCommandInterface()->getOutput()->error(__('admin.cli.tool.jobs.sandbox.message'));
error_log(__('admin.cli.tool.jobs.sandbox.message'));
return;
}
$connection = $parameterList['connection'] ?? Config::getVar('queues', 'default_connection', 'database');
$queue = $parameterList['queue'] ?? Config::getVar('queues', 'default_queue', 'queue');
if (in_array('--test', $parameterList)) {
$queue = PKPJobModel::TESTING_QUEUE;
}
$this->listenForEvents();
app('pkpJobQueue')->runJobsViaDaemon(
$connection,
$queue,
$this->gatherWorkerOptions($parameterList)
);
}
/**
* Dispatch jobs into the queue
*/
protected function run(): void
{
if (Application::isUnderMaintenance()) {
$this->getCommandInterface()->getOutput()->error(__('admin.cli.tool.jobs.maintenance.message'));
return;
}
if (Config::getVar('general', 'sandbox', false)) {
$this->getCommandInterface()->getOutput()->error(__('admin.cli.tool.jobs.sandbox.message'));
error_log(__('admin.cli.tool.jobs.sandbox.message'));
return;
}
$parameterList = $this->getParameterList();
$queue = $parameterList['queue'] ?? Config::getVar('queues', 'default_queue', 'queue');
if (in_array('--test', $parameterList)) {
$queue = PKPJobModel::TESTING_QUEUE;
}
$jobQueue = app('pkpJobQueue');
if ($queue && is_string($queue)) {
$jobQueue = $jobQueue->forQueue($queue);
}
$jobBuilder = $jobQueue->getJobModelBuilder();
if (($jobCount = $jobBuilder->count()) <= 0) {
$this->getCommandInterface()->getOutput()->info(
__(
'admin.cli.tool.jobs.available.options.run.empty.description',
['queueName' => $queue,]
)
);
return;
}
$this->listenForEvents();
while ($jobBuilder->count()) {
$jobQueue->runJobInQueue();
if (in_array('--once', $parameterList)) {
$jobCount = 1;
break;
}
}
$this->getCommandInterface()->getOutput()->success(
__(
'admin.cli.tool.jobs.available.options.run.completed.description',
['jobCount' => $jobCount, 'queueName' => $queue,]
)
);
}
/**
* Purge queued jobs
*/
protected function purge(): void
{
if (!isset($this->getParameterList()['queue']) && !isset($this->getParameterList()[1])) {
throw new CommandInvalidArgumentException(__('admin.cli.tool.jobs.purge.without.id'));
}
$parameterList = $this->getParameterList();
if (in_array('--all', $parameterList) || ($queue = $this->getParameterValue('queue'))) {
if (!Repo::job()->deleteJobs($queue ?? null)) {
$this->getCommandInterface()->getOutput()->warning(__('admin.cli.tool.jobs.purge.impossible.to.purge.empty'));
return;
}
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.purge.successful.all'));
return;
}
$deleted = Repo::job()->delete((int) $this->getParameterList()[1]);
if (!$deleted) {
throw new CommandInvalidArgumentException(__('admin.cli.tool.jobs.purge.invalid.id'));
}
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.purge.successful'));
}
/**
* Create a test queued job
*/
protected function test(): void
{
$queue = PKPJobModel::TESTING_QUEUE;
$runnableJob = $this->getParameterList()['only'] ?? null;
if ($runnableJob && !in_array($runnableJob, ['failed', 'success'])) {
throw new CommandInvalidArgumentException(__('admin.cli.tool.jobs.test.invalid.option'));
}
if (!$runnableJob || $runnableJob === 'failed') {
dispatch(new TestJobFailure());
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.test.job.failed.dispatch.message', ['queueName' => $queue]));
}
if (!$runnableJob || $runnableJob === 'success') {
dispatch(new TestJobSuccess());
$this->getCommandInterface()->getOutput()->success(__('admin.cli.tool.jobs.test.job.success.dispatch.message', ['queueName' => $queue]));
}
}
/**
* Gather worker daemon options
*
*/
protected function gatherWorkerOptions(array $parameters = []): array
{
$workerConfig = new WorkerConfiguration();
return [
'name' => $this->getParameterValue('name', $workerConfig->getName()),
'backoff' => $this->getParameterValue('backoff', $workerConfig->getBackoff()),
'memory' => $this->getParameterValue('memory', $workerConfig->getMemory()),
'timeout' => $this->getParameterValue('timeout', $workerConfig->getTimeout()),
'sleep' => $this->getParameterValue('sleep', $workerConfig->getSleep()),
'maxTries' => $this->getParameterValue('tries', $workerConfig->getMaxTries()),
'force' => $this->getParameterValue('force', in_array('force', $parameters) ? true : $workerConfig->getForce()),
'stopWhenEmpty' => $this->getParameterValue('stop-when-empty', in_array('stop-when-empty', $parameters) ? true : $workerConfig->getStopWhenEmpty()),
'maxJobs' => $this->getParameterValue('max-jobs', $workerConfig->getMaxJobs()),
'maxTime' => $this->getParameterValue('max-time', $workerConfig->getMaxTime()),
'rest' => $this->getParameterValue('rest', $workerConfig->getRest()),
];
}
/**
* Listen for the queue events in order to update the console output.
*
*/
protected function listenForEvents(): void
{
$events = app()['events'];
$events->listen(JobProcessing::class, function ($event) {
$this->writeOutput($event->job, 'starting');
});
$events->listen(JobProcessed::class, function ($event) {
$this->writeOutput($event->job, 'success');
});
$events->listen(JobFailed::class, function ($event) {
$this->writeOutput($event->job, 'failed');
});
}
/**
* Write the status output for the queue worker.
*
* @param string $status
*
*/
protected function writeOutput(Job $job, $status): void
{
match ($status) {
'starting' => $this->writeStatus($job, 'Processing', 'comment'),
'success' => $this->writeStatus($job, 'Processed', 'info'),
'failed' => $this->writeStatus($job, 'Failed', 'error'),
};
}
/**
* Format the status output for the queue worker.
*
* @param string $status
* @param string $type
*/
protected function writeStatus(Job $job, $status, $type): void
{
$this->getCommandInterface()->getOutput()->writeln(sprintf(
"<{$type}>[%s][%s] %s</{$type}> %s",
Carbon::now()->format('Y-m-d H:i:s'),
$job->getJobId(),
str_pad("{$status}:", 11),
$job->resolveName()
));
}
/**
* Print work command options information.
*/
protected function workerOptionsHelp(): void
{
$this->getCommandInterface()->line('<comment>' . __('admin.cli.tool.jobs.work.options.title') . '</comment>');
$this->getCommandInterface()->line(__('admin.cli.tool.jobs.work.options.usage') . PHP_EOL);
$this->getCommandInterface()->line('<comment>' . __('admin.cli.tool.jobs.work.options.description') . '</comment>');
$workerConfig = new WorkerConfiguration();
$options = [
'--connection[=CONNECTION]' => __('admin.cli.tool.jobs.work.option.connection.description', ['default' => Config::getVar('queue', 'default_connection', 'database')]),
'--queue[=QUEUE]' => __('admin.cli.tool.jobs.work.option.queue.description', ['default' => Config::getVar('queue', 'default_queue', 'queue')]),
'--name[=NAME]' => __('admin.cli.tool.jobs.work.option.name.description', ['default' => $workerConfig->getName()]),
'--backoff[=BACKOFF]' => __('admin.cli.tool.jobs.work.option.backoff.description', ['default' => $workerConfig->getBackoff()]),
'--memory[=MEMORY]' => __('admin.cli.tool.jobs.work.option.memory.description', ['default' => $workerConfig->getMemory()]),
'--timeout[=TIMEOUT]' => __('admin.cli.tool.jobs.work.option.timeout.description', ['default' => $workerConfig->getTimeout()]),
'--sleep[=SLEEP]' => __('admin.cli.tool.jobs.work.option.sleep.description', ['default' => $workerConfig->getSleep()]),
'--tries[=TRIES]' => __('admin.cli.tool.jobs.work.option.tries.description', ['default' => $workerConfig->getMaxTries()]),
'--force' => __('admin.cli.tool.jobs.work.option.force.description', ['default' => $workerConfig->getForce() ? 'true' : 'false']),
'--stop-when-empty' => __('admin.cli.tool.jobs.work.option.stopWhenEmpty.description', ['default' => $workerConfig->getStopWhenEmpty() ? 'true' : 'false']),
'--max-jobs[=MAX-JOBS]' => __('admin.cli.tool.jobs.work.option.maxJobs.description', ['default' => $workerConfig->getMaxJobs()]),
'--max-time[=MAX-TIME]' => __('admin.cli.tool.jobs.work.option.maxTime.description', ['default' => $workerConfig->getMaxTime()]),
'--rest[=REST]' => __('admin.cli.tool.jobs.work.option.rest.description', ['default' => $workerConfig->getRest()]),
'--test' => __('admin.cli.tool.jobs.work.option.test.description'),
];
$this->printUsage($options, false);
}
/**
* Print given options in a pretty way.
*/
protected function printUsage(array $options, bool $shouldTranslate = true): void
{
$width = $this->getColumnWidth(array_keys($options));
foreach ($options as $commandName => $description) {
$spacingWidth = $width - Helper::width($commandName);
$this->getCommandInterface()->line(
sprintf(
' <info>%s</info>%s%s',
$commandName,
str_repeat(' ', $spacingWidth),
$shouldTranslate ? __($description) : $description
)
);
}
}
/**
* Display the queued/failed jobs quantity
*/
protected function total(): void
{
$parameterList = $this->getParameterList();
$total = in_array('--failed', $parameterList)
? Repo::failedJob()->total()
: Repo::job()->total();
$outputInterface = $this->getCommandInterface()->getOutput();
if (in_array('--failed', $parameterList)) {
$method = $total > 0 ? 'error' : 'success';
$outputInterface->{$method}(__('admin.cli.tool.jobs.total.failed.jobs', ['total' => $total]));
return;
}
$outputInterface->warning(__('admin.cli.tool.jobs.total.jobs', ['total' => $total]));
}
/**
* Parse and execute the command
*/
public function execute()
{
if (!isset(self::AVAILABLE_OPTIONS[$this->option])) {
throw new CommandNotFoundException(
__('admin.cli.tool.jobs.option.doesnt.exists', ['option' => $this->option]),
array_keys(self::AVAILABLE_OPTIONS)
);
}
$this->{$this->option}();
}
}
try {
$tool = new commandJobs($argv ?? []);
$tool->execute();
} catch (Throwable $e) {
$output = new commandInterface();
if ($e instanceof CommandInvalidArgumentException) {
$output->errorBlock([$e->getMessage()]);
return;
}
if ($e instanceof CommandNotFoundException) {
$alternatives = $e->getAlternatives();
$message = __('admin.cli.tool.jobs.mean.those') . PHP_EOL . implode(PHP_EOL, $alternatives);
$output->errorBlock([$e->getMessage(), $message]);
return;
}
throw $e;
}
|