* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Scheduler\Administrator\Scheduler; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\Component\Scheduler\Administrator\Extension\SchedulerComponent; use Joomla\Component\Scheduler\Administrator\Model\TaskModel; use Joomla\Component\Scheduler\Administrator\Model\TasksModel; use Joomla\Component\Scheduler\Administrator\Task\Status; use Joomla\Component\Scheduler\Administrator\Task\Task; use Symfony\Component\OptionsResolver\Exception\AccessException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; use Symfony\Component\OptionsResolver\OptionsResolver; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * The Scheduler class provides the core functionality of ComScheduler. * Currently, this includes fetching scheduled tasks from the database * and execution of any or the next due task. * It is planned that this class is extended with C[R]UD methods for * scheduled tasks. * * @since 4.1.0 * @todo A global instance? */ class Scheduler { private const LOG_TEXT = [ Status::OK => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE', Status::WILL_RESUME => 'COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME', Status::NO_LOCK => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED', Status::NO_RUN => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED', Status::NO_ROUTINE => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA', ]; /** * Filters for the task queue. Can be used with fetchTaskRecords(). * * @since 4.1.0 * @todo remove? */ public const TASK_QUEUE_FILTERS = [ 'due' => 1, 'locked' => -1, ]; /** * List config for the task queue. Can be used with fetchTaskRecords(). * * @since 4.1.0 * @todo remove? */ public const TASK_QUEUE_LIST_CONFIG = [ 'multi_ordering' => ['a.priority DESC ', 'a.next_execution ASC'], ]; /** * Run a scheduled task. * Runs a single due task from the task queue by default if $id and $title are not passed. * * @param array $options Array with options to configure the method's behavior. Supports: * 1. `id`: (Optional) ID of the task to run. * 2. `allowDisabled`: Allow running disabled tasks. * 3. `allowConcurrent`: Allow concurrent execution, i.e., running the task when another * task may be running. * * @return ?Task The task executed or null if not exists * * @since 4.1.0 * @throws \RuntimeException */ public function runTask(array $options): ?Task { $resolver = new OptionsResolver(); try { $this->configureTaskRunnerOptions($resolver); } catch (\Exception $e) { } try { $options = $resolver->resolve($options); } catch (\Exception $e) { if ($e instanceof UndefinedOptionsException || $e instanceof InvalidOptionsException) { throw $e; } } /** @var CMSApplication $app */ $app = Factory::getApplication(); // ? Sure about inferring scheduling bypass? $task = $this->getTask( [ 'id' => (int) $options['id'], 'allowDisabled' => $options['allowDisabled'], 'bypassScheduling' => (int) $options['id'] !== 0, 'allowConcurrent' => $options['allowConcurrent'], 'includeCliExclusive' => ($app->isClient('cli')), ] ); // ? Should this be logged? (probably, if an ID is passed?) if (empty($task)) { return null; } $app->getLanguage()->load('com_scheduler', JPATH_ADMINISTRATOR); $options['text_entry_format'] = '{DATE} {TIME} {PRIORITY} {MESSAGE}'; $options['text_file'] = 'joomla_scheduler.php'; Log::addLogger($options, Log::ALL, $task->logCategory); $taskId = $task->get('id'); $taskTitle = $task->get('title'); $task->log(Text::sprintf('COM_SCHEDULER_SCHEDULER_TASK_START', $taskId, $taskTitle), 'info'); // Let's try to avoid time-outs if (\function_exists('set_time_limit')) { set_time_limit(0); } try { $task->run(); } catch (\Exception $e) { // We suppress the exception here, it's still accessible with `$task->getContent()['exception']`. } $executionSnapshot = $task->getContent(); $exitCode = $executionSnapshot['status'] ?? Status::NO_EXIT; $netDuration = $executionSnapshot['netDuration'] ?? 0; $duration = $executionSnapshot['duration'] ?? 0; if (\array_key_exists($exitCode, self::LOG_TEXT)) { $level = in_array($exitCode, [Status::OK, Status::WILL_RESUME]) ? 'info' : 'warning'; $task->log(Text::sprintf(self::LOG_TEXT[$exitCode], $taskId, $duration, $netDuration), $level); return $task; } $task->log( Text::sprintf('COM_SCHEDULER_SCHEDULER_TASK_UNKNOWN_EXIT', $taskId, $duration, $netDuration, $exitCode), 'warning' ); return $task; } /** * Set up an {@see OptionsResolver} to resolve options compatible with {@see runTask}. * * @param OptionsResolver $resolver The {@see OptionsResolver} instance to set up. * * @return void * * @since 4.1.0 * @throws AccessException */ protected function configureTaskRunnerOptions(OptionsResolver $resolver): void { $resolver->setDefaults( [ 'id' => 0, 'allowDisabled' => false, 'allowConcurrent' => false, ] ) ->setAllowedTypes('id', 'numeric') ->setAllowedTypes('allowDisabled', 'bool') ->setAllowedTypes('allowConcurrent', 'bool'); } /** * Get the next task which is due to run, limit to a specific task when ID is given * * @param array $options Options for the getter, see {@see TaskModel::getTask()}. * ! should probably also support a non-locking getter. * * @return Task $task The task to execute * * @since 4.1.0 * @throws \RuntimeException */ public function getTask(array $options = []): ?Task { $resolver = new OptionsResolver(); try { TaskModel::configureTaskGetterOptions($resolver); } catch (\Exception $e) { } try { $options = $resolver->resolve($options); } catch (\Exception $e) { if ($e instanceof UndefinedOptionsException || $e instanceof InvalidOptionsException) { throw $e; } } try { /** @var SchedulerComponent $component */ $component = Factory::getApplication()->bootComponent('com_scheduler'); /** @var TaskModel $model */ $model = $component->getMVCFactory()->createModel('Task', 'Administrator', ['ignore_request' => true]); } catch (\Exception $e) { } if (!isset($model)) { throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_MODEL_CREATE')); } $task = $model->getTask($options); if (empty($task)) { return null; } return new Task($task); } /** * Fetches a single scheduled task in a Task instance. * If no id or title is specified, a due task is returned. * * @param int $id The task ID. * @param bool $allowDisabled Allow disabled/trashed tasks? * * @return ?object A matching task record, if it exists * * @since 4.1.0 * @throws \RuntimeException */ public function fetchTaskRecord(int $id = 0, bool $allowDisabled = false): ?object { $filters = []; $listConfig = ['limit' => 1]; if ($id > 0) { $filters['id'] = $id; } else { // Filters and list config for scheduled task queue $filters['due'] = 1; $filters['locked'] = -1; $listConfig['multi_ordering'] = [ 'a.priority DESC', 'a.next_execution ASC', ]; } if ($allowDisabled) { $filters['state'] = ''; } return $this->fetchTaskRecords($filters, $listConfig)[0] ?? null; } /** * @param array $filters The filters to set to the model * @param array $listConfig The list config (ordering, etc.) to set to the model * * @return array * * @since 4.1.0 * @throws \RunTimeException */ public function fetchTaskRecords(array $filters, array $listConfig): array { $model = null; try { /** @var SchedulerComponent $component */ $component = Factory::getApplication()->bootComponent('com_scheduler'); /** @var TasksModel $model */ $model = $component->getMVCFactory() ->createModel('Tasks', 'Administrator', ['ignore_request' => true]); } catch (\Exception $e) { } if (!$model) { throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_MODEL_CREATE')); } $model->setState('list.select', 'a.*'); // Default to only enabled tasks if (!isset($filters['state'])) { $model->setState('filter.state', 1); } // Default to including orphaned tasks $model->setState('filter.orphaned', 0); // Default to ordering by ID $model->setState('list.ordering', 'a.id'); $model->setState('list.direction', 'ASC'); // List options foreach ($listConfig as $key => $value) { $model->setState('list.' . $key, $value); } // Filter options foreach ($filters as $type => $filter) { $model->setState('filter.' . $type, $filter); } return $model->getItems() ?: []; } }