Files
cms-scheduler/Classes/Domain/Repository/SchedulerTaskRepository.php
T

683 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Scheduler\Domain\Repository;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\CommandLineUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\Field\StaticSelectFieldType;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Scheduler\Exception\InvalidTaskException;
use TYPO3\CMS\Scheduler\ProgressProviderInterface;
use TYPO3\CMS\Scheduler\Service\TaskService;
use TYPO3\CMS\Scheduler\Task\AbstractTask;
use TYPO3\CMS\Scheduler\Task\TaskSerializer;
use TYPO3\CMS\Scheduler\Task\TaskStatus;
use TYPO3\CMS\Scheduler\Validation\Validator\TaskValidator;
/**
* Repository class to fetch tasks available in the systems ready to be executed
*
* @internal not part of public TYPO3 Core API
*/
#[Autoconfigure(public: true)]
readonly class SchedulerTaskRepository
{
protected const TABLE_NAME = 'tx_scheduler_task';
public function __construct(
protected TaskSerializer $taskSerializer,
protected TaskService $taskService,
protected TcaSchemaFactory $tcaSchemaFactory,
protected Context $context,
protected ConnectionPool $connectionPool,
) {}
/**
* Adds a task to the pool.
*
* @param AbstractTask $task The object representing the task to add
* @return bool TRUE if the task was successfully added, FALSE otherwise
*/
public function add(AbstractTask $task): bool
{
$taskUid = $task->getTaskUid();
if (!empty($taskUid)) {
return false;
}
$fields = $this->taskService->getFieldsForRecord($task);
$fields['pid'] = 0;
$newId = uniqid('NEW');
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
self::TABLE_NAME => [
$newId => $fields,
],
], []);
$dataHandler->process_datamap();
$taskUid = (int)$dataHandler->substNEWwithIDs[$newId];
if ($taskUid) {
$task->setTaskUid($taskUid);
return true;
}
return false;
}
/**
* Removes a task completely from the system.
*
* @param int|AbstractTask $task The object representing the task to delete
* @return bool TRUE if the task was successfully deleted, FALSE otherwise
*/
public function remove(int|AbstractTask $task): bool
{
$taskUid = is_int($task) ? $task : $task->getTaskUid();
if (empty($taskUid)) {
return false;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], [
self::TABLE_NAME => [
$taskUid => [
'delete' => 1,
],
],
]);
$dataHandler->process_cmdmap();
return $dataHandler->errorLog === [];
}
/**
* Update a task in the pool.
*/
public function update(AbstractTask $task, ?array $fields = null): bool
{
$taskUid = $task->getTaskUid();
if (empty($taskUid)) {
return false;
}
$fields = $fields ?? $this->taskService->getFieldsForRecord($task);
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([
self::TABLE_NAME => [
$taskUid => $fields,
],
], []);
$dataHandler->process_datamap();
return true;
}
/**
* Update a task in the pool but only the execution information.
*/
public function updateExecution(AbstractTask $task, bool $forceDisablingTask = false): void
{
$taskUid = $task->getTaskUid();
if (empty($taskUid)) {
return;
}
$fields = $this->taskService->getFieldsForRecord($task);
$fields = [
'nextexecution' => $fields['nextexecution'],
'disable' => $forceDisablingTask ? true : $fields['disable'],
'execution_details' => $fields['execution_details'],
];
$backendUser = $GLOBALS['BE_USER'] ?? null;
if ($backendUser === null && Environment::isCli()) {
/** @var CommandLineUserAuthentication $backendUser */
$backendUser = Bootstrap::initializeBackendUser(CommandLineUserAuthentication::class);
$backendUser->authenticate();
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
// We don't want every execution information logged
$dataHandler->enableLogging = false;
$dataHandler->start([
self::TABLE_NAME => [
$taskUid => $fields,
],
], [], $backendUser);
$dataHandler->process_datamap();
}
/**
* Fetches a task object from the db with the given $uid. The object representing
* the next due task is returned.
* If there are no tasks due, the method throws an exception.
*
* @param int $uid Primary key of a task
* @throws \OutOfBoundsException
* @throws \UnexpectedValueException
*/
public function findByUid(int $uid): AbstractTask
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $uid);
if (empty($row)) {
// Although an uid was passed, no task with given was found
throw new \OutOfBoundsException('No task with id ' . $uid . ' found', 1422044826);
}
return $this->createValidTaskObjectOrDisableTask($row);
}
/**
* Fetches the DB record for a given task UID.
*
* @param int $uid Primary key of the task to get
* @return array|null Database record for the task
* @see findByUid()
*/
public function findRecordByUid(int $uid): ?array
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $uid);
if (empty($row)) {
return null;
}
return $row;
}
/**
* Fetch and unserialize a task object from the db. Returns the object representing the
* next due task is returned. If there are no due tasks, the method throws an exception.
*
* @throws \UnexpectedValueException
*/
public function findNextExecutableTask(): ?AbstractTask
{
// If no uid is given, take any non-disabled task that has a next execution time in the past
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->select(
't.*'
)
->from(self::TABLE_NAME, 't')
->setMaxResults(1);
// Define where clause
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->leftJoin(
't',
'tx_scheduler_task_group',
'g',
$queryBuilder->expr()->eq('g.uid', $queryBuilder->expr()->castInt($queryBuilder->quoteIdentifier('t.task_group')))
);
$queryBuilder->where(
$queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->neq(
't.nextexecution',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
),
$queryBuilder->expr()->lte(
't.nextexecution',
$queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT)
),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->isNull('g.hidden')
),
$queryBuilder->expr()->eq('t.deleted', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
);
$queryBuilder->orderBy('t.priority', 'DESC')->addOrderBy('t.nextexecution', 'ASC');
$row = $queryBuilder->executeQuery()->fetchAssociative();
if (empty($row)) {
return null;
}
return $this->createValidTaskObjectOrDisableTask($row);
}
/**
* @todo This will get split up into errored classes
*/
public function getGroupedTasks(): array
{
// Get all registered tasks
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()->removeAll();
$result = $queryBuilder->select('t.*')
->addSelect(
'g.groupName AS taskGroupName',
'g.description AS taskGroupDescription',
'g.uid AS taskGroupId',
'g.color AS taskGroupColor',
'g.deleted AS isTaskGroupDeleted',
'g.hidden AS isTaskGroupHidden',
)
->from(self::TABLE_NAME, 't')
->leftJoin(
't',
'tx_scheduler_task_group',
'g',
$queryBuilder->expr()->eq('g.uid', $queryBuilder->expr()->castInt($queryBuilder->quoteIdentifier('t.task_group')))
)
->where(
$queryBuilder->expr()->eq('t.deleted', 0)
)
->orderBy('g.sorting')
->executeQuery();
$taskGroupsWithTasks = [];
$errorClasses = [];
while ($row = $result->fetchAssociative()) {
$taskData = [
'uid' => (int)$row['uid'],
'lastExecutionTime' => (int)$row['lastexecution_time'],
'lastExecutionContext' => $row['lastexecution_context'],
'errorMessage' => '',
'description' => $row['description'],
];
try {
$taskObject = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException $e) {
$taskData['errorMessage'] = $e->getMessage();
$taskData['taskType'] = $row['tasktype'] ?: $this->taskSerializer->extractClassName($row['serialized_task_object']);
$errorClasses[] = $taskData;
continue;
}
$taskData['taskType'] = $taskObject->getTaskType();
if (!$this->isValidTaskObject($taskObject)) {
$taskData['errorMessage'] = 'The task "' . $taskObject->getTaskType() . ' is not a valid task';
$errorClasses[] = $taskData;
continue;
}
$taskInformation = $this->taskService->getTaskDetailsFromTask($taskObject);
if ($taskInformation === null) {
$taskData['errorMessage'] = 'The task ' . $taskObject->getTaskType() . ' is not a registered task';
$errorClasses[] = $taskData;
continue;
}
if ($taskObject instanceof ProgressProviderInterface) {
$taskData['progress'] = round((float)$taskObject->getProgress(), 2);
}
$taskData['fullTitle'] = $taskInformation['fullTitle'];
$taskData['additionalInformation'] = $taskObject->getAdditionalInformation();
$taskData['disabled'] = (bool)$row['disable'];
$taskData['isRunning'] = !empty($row['serialized_executions']);
$taskData['nextExecution'] = (int)$row['nextexecution'];
$taskData['runningType'] = 'single';
$taskData['frequency'] = '';
if ($taskObject->getExecution()->isRecurring()) {
$taskData['runningType'] = 'recurring';
$taskData['frequency'] = $taskObject->getExecution()->getCronCmd() ?: $taskObject->getExecution()->getInterval();
}
$taskData['multiple'] = (bool)$taskObject->getExecution()->isParallelExecutionAllowed();
$taskData['priority'] = (int)$row['priority'];
$taskData['priorityLabel'] = $this->resolvePriorityLabel((int)$row['priority']);
$taskData['lastExecutionFailure'] = false;
if (!empty($row['lastexecution_failure'])) {
$taskData['lastExecutionFailure'] = true;
// only scalars are serialized in \TYPO3\CMS\Scheduler\Scheduler::executeTask
$exceptionArray = @unserialize($row['lastexecution_failure'], ['allowed_classes' => false]);
$taskData['lastExecutionFailureCode'] = '';
$taskData['lastExecutionFailureMessage'] = '';
if (is_array($exceptionArray)) {
$taskData['lastExecutionFailureCode'] = $exceptionArray['code'];
$taskData['lastExecutionFailureMessage'] = $exceptionArray['message'];
}
}
$taskData['statuses'] = $this->buildTaskStatuses($taskData, (bool)$row['isTaskGroupHidden']);
// If a group is deleted or no group is set it needs to go into "not assigned groups"
$groupIndex = $row['isTaskGroupDeleted'] === 1 || $row['isTaskGroupDeleted'] === null ? 0 : (int)$row['task_group'];
if (!isset($taskGroupsWithTasks[$groupIndex])) {
$taskGroupsWithTasks[$groupIndex] = [
'uid' => $row['taskGroupId'],
'groupName' => $row['taskGroupName'],
'description' => $row['taskGroupDescription'],
'color' => $row['taskGroupColor'],
'hidden' => $row['isTaskGroupHidden'],
'tasks' => [],
];
}
$taskGroupsWithTasks[$groupIndex]['tasks'][] = $taskData;
}
return [
'taskGroupsWithTasks' => $taskGroupsWithTasks,
'errorClasses' => $errorClasses,
];
}
protected function createValidTaskObjectOrDisableTask(array $row): AbstractTask
{
$isInvalidTask = false;
$task = null;
try {
$task = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException) {
$isInvalidTask = true;
}
if ($isInvalidTask || !$this->isValidTaskObject($task)) {
$fieldName = $this->tcaSchemaFactory
->get(self::TABLE_NAME)
->getCapability(TcaSchemaCapability::RestrictionDisabledField)
->getFieldName();
if ((bool)$row[$fieldName] !== true) {
// Forcibly set the disabled flag to 1 in the database (if not already set), so that the
// task does not come up again and again for execution. Execute a simple update statement
// to avoid triggering any DH hook again, which would lead to an infinity loop.
$this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(self::TABLE_NAME, [$fieldName => 1], ['uid' => (int)$row['uid']]);
}
// Throw an exception to raise the problem
// @todo: This should most likely be changed to a specific exception.
throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
}
// The task is valid, return it
if ($task->getTaskGroup() === null) {
// Fix invalid task_group=NULL settings in order to avoid exceptions when saving on PostgreSQL
$task->setTaskGroup(0);
}
return $task;
}
/**
* Fetch and unserialize task objects selected with some (SQL) condition
*/
public function findNextExecutableTaskForUid(int $uid): ?AbstractTask
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(HiddenRestriction::class));
$queryBuilder
->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)),
$queryBuilder->expr()->neq('nextexecution', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)),
$queryBuilder->expr()->lte('nextexecution', $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], Connection::PARAM_INT)),
);
$result = $queryBuilder->executeQuery();
while ($row = $result->fetchAssociative()) {
try {
$task = $this->taskSerializer->deserialize($row);
} catch (InvalidTaskException) {
continue;
}
// Add the task to the list only if it is valid
if ($this->isValidTaskObject($task)) {
return $task;
}
}
return null;
}
public function isTaskMarkedAsRunning(AbstractTask $task): bool
{
$row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid());
return !empty($row['serialized_executions'] ?? null);
}
/**
* This method adds current execution to the execution list.
* It also logs the execution time and mode
*
* The execution id is guaranteed to start from zero if the task has no
* currently running execution at the time of id allocation.
*
* @return int Execution id
*/
public function addExecutionToTask(AbstractTask $task): int
{
while (true) {
$row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid());
if ($row === null) {
throw new \InvalidArgumentException(
'Given task must have a persistence record associated with it',
1741257045
);
}
$previousExecutions = isset($row['serialized_executions'])
? (string)$row['serialized_executions']
: null;
$runningExecutions = $previousExecutions !== null
&& $previousExecutions !== ''
// serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>`
? unserialize($previousExecutions, ['allowed_classes' => false])
: [];
// Count the number of existing executions and use that number as a key
// (we need to know that number, because it is returned at the end of the method)
$numExecutions = count($runningExecutions);
$runningExecutions[$numExecutions] = time();
$updateCount = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
[
'serialized_executions' => serialize($runningExecutions),
'lastexecution_time' => time(),
// Define the context in which the script is running
'lastexecution_context' => Environment::isCli() ? 'CLI' : 'BE',
],
[
'uid' => $task->getTaskUid(),
'serialized_executions' => $previousExecutions,
],
[
'serialized_executions' => Connection::PARAM_LOB,
]
);
if ($updateCount === 1) {
return $numExecutions;
}
}
}
/**
* Removes a given execution from the list
*
* @param int $executionID Id of the execution to remove.
* @param string|array|null $failureReason Details of an exception to signal a failed execution.
*/
public function removeExecutionOfTask(AbstractTask $task, int $executionID, array|string|null $failureReason = null): void
{
while ($row = BackendUtility::getRecord(self::TABLE_NAME, $task->getTaskUid())) {
$previousExecutions = (string)($row['serialized_executions'] ?? '');
if ($previousExecutions === '') {
break;
}
// serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>`
$runningExecutions = unserialize($previousExecutions, ['allowed_classes' => false]);
// Remove the selected execution
unset($runningExecutions[$executionID]);
if (!empty($runningExecutions)) {
// Re-serialize the updated executions list (if necessary)
$runningExecutionsSerialized = serialize($runningExecutions);
} else {
$runningExecutionsSerialized = '';
}
if (is_array($failureReason)) {
$failureReason = json_encode($failureReason);
}
// Save the updated executions list
$fieldUpdates = [
'serialized_executions' => $runningExecutionsSerialized,
];
if ($failureReason !== null) {
$fieldUpdates['lastexecution_failure'] = (string)$failureReason;
}
$updateCount = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
$fieldUpdates,
[
'uid' => $task->getTaskUid(),
'serialized_executions' => $previousExecutions,
],
[
'serialized_executions' => Connection::PARAM_LOB,
]
);
if ($updateCount === 1) {
break;
}
}
}
/**
* Clears all marked executions
*
* @return bool TRUE if the clearing succeeded, FALSE otherwise
*/
public function removeAllRegisteredExecutionsForTask(AbstractTask $task): bool
{
// Set the serialized executions field to empty
$result = $this->connectionPool
->getConnectionForTable(self::TABLE_NAME)
->update(
self::TABLE_NAME,
['serialized_executions' => ''],
['uid' => $task->getTaskUid()],
['serialized_executions' => Connection::PARAM_LOB]
);
return (bool)$result;
}
/**
* See if there are any tasks configured at all.
*/
public function hasTasks(): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class));
$queryBuilder
->count('*')
->from(self::TABLE_NAME);
return $queryBuilder->executeQuery()->fetchOne() > 0;
}
protected function isValidTaskObject($task): bool
{
return (new TaskValidator())->isValid($task);
}
/**
* Resolves the status flags of a single task into an ordered list, shared as a single
* source of truth by the backend module listing and the "scheduler:list" CLI command.
*
* @return list<TaskStatus>
*/
private function buildTaskStatuses(array $task, bool $groupHidden): array
{
$now = $this->context->getAspect('date')->get('timestamp');
$statuses = [];
if ($task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'running',
severity: ContextualFeedbackSeverity::INFO,
state: 'running',
label: 'scheduler.messages:status.running',
);
}
if ($task['nextExecution'] && $task['nextExecution'] < $now && !$groupHidden && !$task['disabled']) {
$statuses[] = new TaskStatus(
type: 'late',
severity: ContextualFeedbackSeverity::WARNING,
state: 'warning',
label: 'scheduler.messages:status.late',
);
}
if ($task['disabled'] && !$task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'disabled',
severity: ContextualFeedbackSeverity::NOTICE,
state: 'disabled',
label: 'scheduler.messages:status.disabled',
);
}
if ($groupHidden && !$task['isRunning']) {
$statuses[] = new TaskStatus(
type: 'disabledByGroup',
severity: ContextualFeedbackSeverity::NOTICE,
state: 'disabled',
label: 'scheduler.messages:status.disabledByGroup',
);
}
if ($task['lastExecutionFailure'] ?? false) {
if (($task['lastExecutionFailureMessage'] ?? '') !== '') {
$statuses[] = new TaskStatus(
type: 'failure',
severity: ContextualFeedbackSeverity::ERROR,
state: 'danger',
label: 'scheduler.messages:status.failure',
message: 'scheduler.messages:msg.executionFailureReport',
messageArguments: [
$task['lastExecutionFailureCode'],
$task['lastExecutionFailureMessage'],
],
);
} else {
$statuses[] = new TaskStatus(
type: 'failure',
severity: ContextualFeedbackSeverity::ERROR,
state: 'default',
label: 'scheduler.messages:status.failure',
message: 'scheduler.messages:msg.executionFailureDefault',
);
}
}
return $statuses;
}
private function resolvePriorityLabel(int $priority): string
{
$field = $this->tcaSchemaFactory->get(self::TABLE_NAME)->getField('priority');
if ($field instanceof StaticSelectFieldType) {
foreach ($field->getItems() as $item) {
if ((int)$item->getValue() === $priority) {
return $item->getLabel();
}
}
}
return (string)$priority;
}
}