* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Scheduler\Administrator\Task; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Event\AbstractEvent; use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\Log\Log; use Joomla\CMS\Plugin\PluginHelper; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Helper\ExecRuleHelper; use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper; use Joomla\Component\Scheduler\Administrator\Scheduler\Scheduler; use Joomla\Component\Scheduler\Administrator\Table\TaskTable; use Joomla\Database\DatabaseDriver; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; use Joomla\Registry\Registry; use Joomla\Utilities\ArrayHelper; use Psr\Log\InvalidArgumentException; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * The Task class defines methods for the execution, logging and * related properties of Tasks as supported by `com_scheduler`, * a Task Scheduling component. * * @since 4.1.0 */ class Task implements LoggerAwareInterface { use LoggerAwareTrait; /** * Enumerated state for enabled tasks. * * @since 4.1.0 */ public const STATE_ENABLED = 1; /** * Enumerated state for disabled tasks. * * @since 4.1.0 */ public const STATE_DISABLED = 0; /** * Enumerated state for trashed tasks. * * @since 4.1.0 */ public const STATE_TRASHED = -2; /** * Map state enumerations to logical language adjectives. * * @since 4.1.0 */ public const STATE_MAP = [ self::STATE_TRASHED => 'trashed', self::STATE_DISABLED => 'disabled', self::STATE_ENABLED => 'enabled', ]; /** * The task snapshot * * @var array * @since 4.1.0 */ protected $snapshot = []; /** * @var Registry * @since 4.1.0 */ protected $taskRegistry; /** * @var string * @since 4.1.0 */ public $logCategory; /** * @var CMSApplication * @since 4.1.0 */ protected $app; /** * @var DatabaseInterface * @since 4.1.0 */ protected $db; /** * Maps task exit codes to events which should be dispatched when the task finishes. * 'NA' maps to the event for general task failures. * * @var string[] * @since 4.1.0 */ protected const EVENTS_MAP = [ Status::OK => 'onTaskExecuteSuccess', Status::NO_ROUTINE => 'onTaskRoutineNotFound', Status::WILL_RESUME => 'onTaskRoutineWillResume', 'NA' => 'onTaskExecuteFailure', ]; /** * Constructor for {@see Task}. * * @param object $record A task from {@see TaskTable}. * * @since 4.1.0 * @throws \Exception */ public function __construct(object $record) { // Workaround because Registry dumps private properties otherwise. $taskOption = $record->taskOption; $record->params = json_decode($record->params, true); $this->taskRegistry = new Registry($record); $this->set('taskOption', $taskOption); $this->app = Factory::getApplication(); $this->db = Factory::getContainer()->get(DatabaseDriver::class); $this->setLogger(Log::createDelegatedLogger()); $this->logCategory = 'task' . $this->get('id'); if ($this->get('params.individual_log')) { $logFile = $this->get('params.log_file') ?? 'task_' . $this->get('id') . '.log.php'; $options['text_entry_format'] = '{DATE} {TIME} {PRIORITY} {MESSAGE}'; $options['text_file'] = $logFile; Log::addLogger($options, Log::ALL, [$this->logCategory]); } } /** * Get the task as a data object that can be stored back in the database. * ! This method should be removed or changed as part of a better API implementation for the driver. * * @return object * * @since 4.1.0 */ public function getRecord(): object { // ! Probably, an array instead $recObject = $this->taskRegistry->toObject(); $recObject->cron_rules = (array) $recObject->cron_rules; return $recObject; } /** * Execute the task. * * @return boolean True if success * * @since 4.1.0 * @throws \Exception */ public function run(): bool { /** * We try to acquire the lock here, only if we don't already have one. * We do this, so we can support two ways of running tasks: * 1. Directly through {@see Scheduler}, which optimises acquiring a lock while fetching from the task queue. * 2. Running a task without a pre-acquired lock. * ! This needs some more thought, for whether it should be allowed or if the single-query optimisation * should be used everywhere, although it doesn't make sense in the context of fetching * a task when it doesn't need to be run. This might be solved if we force a re-fetch * with the lock or do it here ourselves (using acquireLock as a proxy to the model's * getter). */ if ($this->get('locked') === null) { $this->acquireLock(); } // Exit early if task routine is not available if (!SchedulerHelper::getTaskOptions()->findOption($this->get('type'))) { $this->snapshot['status'] = Status::NO_ROUTINE; $this->skipExecution(); $this->dispatchExitEvent(); return $this->isSuccess(); } $this->snapshot['status'] = Status::RUNNING; $this->snapshot['taskStart'] = $this->snapshot['taskStart'] ?? microtime(true); $this->snapshot['netDuration'] = 0; /** @var ExecuteTaskEvent $event */ $event = AbstractEvent::create( 'onExecuteTask', [ 'eventClass' => ExecuteTaskEvent::class, 'subject' => $this, 'routineId' => $this->get('type'), 'langConstPrefix' => $this->get('taskOption')->langConstPrefix, 'params' => $this->get('params'), ] ); PluginHelper::importPlugin('task'); try { $this->app->getDispatcher()->dispatch('onExecuteTask', $event); } catch (\Exception $e) { // Suppress the exception for now, we'll throw it again once it's safe $this->log(Text::sprintf('COM_SCHEDULER_TASK_ROUTINE_EXCEPTION', $e->getMessage()), 'error'); $this->snapshot['exception'] = $e; $this->snapshot['status'] = Status::KNOCKOUT; } $resultSnapshot = $event->getResultSnapshot(); $this->snapshot['taskEnd'] = microtime(true); $this->snapshot['netDuration'] = $this->snapshot['taskEnd'] - $this->snapshot['taskStart']; $this->snapshot = array_merge($this->snapshot, $resultSnapshot); // @todo make the ExecRuleHelper usage less ugly, perhaps it should be composed into Task // Update object state. $this->set('last_execution', Factory::getDate('@' . (int) $this->snapshot['taskStart'])->toSql()); $this->set('last_exit_code', $this->snapshot['status']); if ($this->snapshot['status'] !== Status::WILL_RESUME) { $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec()); $this->set('times_executed', $this->get('times_executed') + 1); } else { /** * Resumable tasks need special handling. * * They are rescheduled as soon as possible to let their next step to be executed without * a very large temporal gap to the previous step. * * Moreover, the times executed does NOT increase for each step. It will increase once, * after the last step, when they return Status::OK. */ $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new \DateInterval('PT1M'))->toSql()); } // The only acceptable "successful" statuses are either clean exit or resuming execution. if (!in_array($this->snapshot['status'], [Status::WILL_RESUME, Status::OK])) { $this->set('times_failed', $this->get('times_failed') + 1); } if (!$this->releaseLock()) { $this->snapshot['status'] = Status::NO_RELEASE; } $this->dispatchExitEvent(); if (!empty($this->snapshot['exception'])) { throw $this->snapshot['exception']; } return $this->isSuccess(); } /** * Get the task execution snapshot. * ! Access locations will need updates once a more robust Snapshot container is implemented. * * @return array * * @since 4.1.0 */ public function getContent(): array { return $this->snapshot; } /** * Acquire a pseudo-lock on the task record. * ! At the moment, this method is not used anywhere as task locks are already * acquired when they're fetched. As such this method is not functional and should * not be reviewed until it is updated. * * @return boolean * * @since 4.1.0 * @throws \Exception */ public function acquireLock(): bool { $db = $this->db; $query = $db->getQuery(true); $id = $this->get('id'); $now = Factory::getDate('now', 'GMT'); $timeout = ComponentHelper::getParams('com_scheduler')->get('timeout', 300); $timeout = new \DateInterval(sprintf('PT%dS', $timeout)); $timeoutThreshold = (clone $now)->sub($timeout)->toSql(); $now = $now->toSql(); // @todo update or remove this method $query->update($db->qn('#__scheduler_tasks')) ->set('locked = :now') ->where($db->qn('id') . ' = :taskId') ->extendWhere( 'AND', [ $db->qn('locked') . ' < :threshold', $db->qn('locked') . 'IS NULL', ], 'OR' ) ->bind(':taskId', $id, ParameterType::INTEGER) ->bind(':now', $now) ->bind(':threshold', $timeoutThreshold); try { $db->lockTable('#__scheduler_tasks'); $db->setQuery($query)->execute(); } catch (\RuntimeException $e) { return false; } finally { $db->unlockTables(); } if ($db->getAffectedRows() === 0) { return false; } $this->set('locked', $now); return true; } /** * Remove the pseudo-lock and optionally update the task record. * * @param bool $update If true, the record is updated with the snapshot * * @return boolean * * @since 4.1.0 * @throws \Exception */ public function releaseLock(bool $update = true): bool { $db = $this->db; $query = $db->getQuery(true); $id = $this->get('id'); $query->update($db->qn('#__scheduler_tasks', 't')) ->set('locked = NULL') ->where($db->qn('id') . ' = :taskId') ->where($db->qn('locked') . ' IS NOT NULL') ->bind(':taskId', $id, ParameterType::INTEGER); if ($update) { $exitCode = $this->get('last_exit_code'); $lastExec = $this->get('last_execution'); $nextExec = $this->get('next_execution'); $timesFailed = $this->get('times_failed'); $timesExecuted = $this->get('times_executed'); $query->set( [ 'last_exit_code = :exitCode', 'last_execution = :lastExec', 'next_execution = :nextExec', 'times_executed = :times_executed', 'times_failed = :times_failed', ] ) ->bind(':exitCode', $exitCode, ParameterType::INTEGER) ->bind(':lastExec', $lastExec) ->bind(':nextExec', $nextExec) ->bind(':times_executed', $timesExecuted) ->bind(':times_failed', $timesFailed); } try { $db->setQuery($query)->execute(); } catch (\RuntimeException $e) { return false; } if (!$db->getAffectedRows()) { return false; } $this->set('locked', null); return true; } /** * @param string $message Log message * @param string $priority Log level, defaults to 'info' * * @return void * * @since 4.1.0 * @throws InvalidArgumentException */ public function log(string $message, string $priority = 'info'): void { $this->logger->log($priority, $message, ['category' => $this->logCategory]); } /** * Advance the task entry's next calculated execution, effectively skipping the current execution. * * @return void * * @since 4.1.0 * @throws \Exception */ public function skipExecution(): void { $db = $this->db; $query = $db->getQuery(true); $id = $this->get('id'); $nextExec = (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec(true, true); $query->update($db->qn('#__scheduler_tasks', 't')) ->set('t.next_execution = :nextExec') ->where('t.id = :id') ->bind(':nextExec', $nextExec) ->bind(':id', $id); try { $db->setQuery($query)->execute(); } catch (\RuntimeException $e) { } $this->set('next_execution', $nextExec); } /** * Handles task exit (dispatch event). * * @return void * * @since 4.1.0 * * @throws \UnexpectedValueException|\BadMethodCallException */ protected function dispatchExitEvent(): void { $exitCode = $this->snapshot['status'] ?? 'NA'; $eventName = self::EVENTS_MAP[$exitCode] ?? self::EVENTS_MAP['NA']; $event = AbstractEvent::create( $eventName, [ 'subject' => $this, ] ); $this->app->getDispatcher()->dispatch($eventName, $event); } /** * Was the task successful? * * @return boolean True if the task was successful. * @since 4.1.0 */ public function isSuccess(): bool { return in_array(($this->snapshot['status'] ?? null), [Status::OK, Status::WILL_RESUME]); } /** * Set a task property. This method is a proxy to {@see Registry::set()}. * * @param string $path Registry path of the task property. * @param mixed $value The value to set to the property. * @param ?string $separator The key separator. * * @return mixed|null * * @since 4.1.0 */ protected function set(string $path, $value, string $separator = null) { return $this->taskRegistry->set($path, $value, $separator); } /** * Get a task property. This method is a proxy to {@see Registry::get()}. * * @param string $path Registry path of the task property. * @param mixed $default Default property to return, if the actual value is null. * * @return mixed The task property. * * @since 4.1.0 */ public function get(string $path, $default = null) { return $this->taskRegistry->get($path, $default); } /** * Static method to determine whether an enumerated task state (as a string) is valid. * * @param string $state The task state (enumerated, as a string). * * @return boolean * * @since 4.1.0 */ public static function isValidState(string $state): bool { if (!is_numeric($state)) { return false; } // Takes care of interpreting as float/int $state = $state + 0; return ArrayHelper::getValue(self::STATE_MAP, $state) !== null; } /** * Static method to determine whether a task id is valid. Note that this does not * validate ids against the database, but only verifies that an id may exist. * * @param string $id The task id (as a string). * * @return boolean * * @since 4.1.0 */ public static function isValidId(string $id): bool { $id = is_numeric($id) ? ($id + 0) : $id; if (!\is_int($id) || $id <= 0) { return false; } return true; } }