[BUGFIX] Add icon rendering for custom permissions options
[Packages/TYPO3.CMS.git] / typo3 / sysext / scheduler / Classes / Scheduler.php
1 <?php
2 namespace TYPO3\CMS\Scheduler;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Database\ConnectionPool;
18 use TYPO3\CMS\Core\Database\Query\QueryHelper;
19 use TYPO3\CMS\Core\Registry;
20 use TYPO3\CMS\Core\Utility\CommandUtility;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Utility\MathUtility;
23
24 /**
25 * TYPO3 Scheduler. This class handles scheduling and execution of tasks.
26 * Formerly known as "Gabriel TYPO3 arch angel"
27 */
28 class Scheduler implements \TYPO3\CMS\Core\SingletonInterface
29 {
30 /**
31 * @var array $extConf Settings from the extension manager
32 */
33 public $extConf = [];
34
35 /**
36 * Constructor, makes sure all derived client classes are included
37 *
38 * @return \TYPO3\CMS\Scheduler\Scheduler
39 */
40 public function __construct()
41 {
42 // Get configuration from the extension manager
43 $this->extConf = unserialize($GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf']['scheduler'], ['allowed_classes' => false]);
44 if (empty($this->extConf['maxLifetime'])) {
45 $this->extConf['maxLifetime'] = 1440;
46 }
47 if (empty($this->extConf['useAtdaemon'])) {
48 $this->extConf['useAtdaemon'] = 0;
49 }
50 // Clean up the serialized execution arrays
51 $this->cleanExecutionArrays();
52 }
53
54 /**
55 * Adds a task to the pool
56 *
57 * @param Task\AbstractTask $task The object representing the task to add
58 * @return bool TRUE if the task was successfully added, FALSE otherwise
59 */
60 public function addTask(Task\AbstractTask $task)
61 {
62 $taskUid = $task->getTaskUid();
63 if (empty($taskUid)) {
64 $fields = [
65 'crdate' => $GLOBALS['EXEC_TIME'],
66 'disable' => (int)$task->isDisabled(),
67 'description' => $task->getDescription(),
68 'task_group' => $task->getTaskGroup(),
69 'serialized_task_object' => 'RESERVED'
70 ];
71 $connection = GeneralUtility::makeInstance(ConnectionPool::class)
72 ->getConnectionForTable('tx_scheduler_task');
73 $result = $connection->insert('tx_scheduler_task', $fields);
74
75 if ($result) {
76 $task->setTaskUid($connection->lastInsertId('tx_scheduler_task'));
77 $task->save();
78 $result = true;
79 } else {
80 $result = false;
81 }
82 } else {
83 $result = false;
84 }
85 return $result;
86 }
87
88 /**
89 * Cleans the execution lists of the scheduled tasks, executions older than 24h are removed
90 * @todo find a way to actually kill the job
91 *
92 * @return void
93 */
94 protected function cleanExecutionArrays()
95 {
96 $tstamp = $GLOBALS['EXEC_TIME'];
97 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
98 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
99
100 // Select all tasks with executions
101 // NOTE: this cleanup is done for disabled tasks too,
102 // to avoid leaving old executions lying around
103 $result = $queryBuilder->select('uid', 'serialized_executions', 'serialized_task_object')
104 ->from('tx_scheduler_task')
105 ->where(
106 $queryBuilder->expr()->neq(
107 'serialized_executions',
108 $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
109 )
110 )
111 ->execute();
112 $maxDuration = $this->extConf['maxLifetime'] * 60;
113 while ($row = $result->fetch()) {
114 $executions = [];
115 if ($serialized_executions = unserialize($row['serialized_executions'])) {
116 foreach ($serialized_executions as $task) {
117 if ($tstamp - $task < $maxDuration) {
118 $executions[] = $task;
119 } else {
120 $task = unserialize($row['serialized_task_object']);
121 $logMessage = 'Removing logged execution, assuming that the process is dead. Execution of \'' . get_class($task) . '\' (UID: ' . $row['uid'] . ') was started at ' . date('Y-m-d H:i:s', $task->getExecutionTime());
122 $this->log($logMessage);
123 }
124 }
125 }
126 $executionCount = count($executions);
127 if (count($serialized_executions) !== $executionCount) {
128 if ($executionCount === 0) {
129 $value = '';
130 } else {
131 $value = serialize($executions);
132 }
133 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
134 'tx_scheduler_task',
135 ['serialized_executions' => $value],
136 ['uid' => (int)$row['uid']]
137 );
138 }
139 }
140 }
141
142 /**
143 * This method executes the given task and properly marks and records that execution
144 * It is expected to return FALSE if the task was barred from running or if it was not saved properly
145 *
146 * @param Task\AbstractTask $task The task to execute
147 * @return bool Whether the task was saved successfully to the database or not
148 * @throws FailedExecutionException
149 * @throws \Exception
150 */
151 public function executeTask(Task\AbstractTask $task)
152 {
153 // Trigger the saving of the task, as this will calculate its next execution time
154 // This should be calculated all the time, even if the execution is skipped
155 // (in case it is skipped, this pushes back execution to the next possible date)
156 $task->save();
157 // Set a scheduler object for the task again,
158 // as it was removed during the save operation
159 $task->setScheduler();
160 $result = true;
161 // Task is already running and multiple executions are not allowed
162 if (!$task->areMultipleExecutionsAllowed() && $task->isExecutionRunning()) {
163 // Log multiple execution error
164 $logMessage = 'Task is already running and multiple executions are not allowed, skipping! Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
165 $this->log($logMessage);
166 $result = false;
167 } else {
168 // Log scheduler invocation
169 $logMessage = 'Start execution. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
170 $this->log($logMessage);
171 // Register execution
172 $executionID = $task->markExecution();
173 $failure = null;
174 try {
175 // Execute task
176 $successfullyExecuted = $task->execute();
177 if (!$successfullyExecuted) {
178 throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
179 }
180 } catch (\Exception $e) {
181 // Store exception, so that it can be saved to database
182 $failure = $e;
183 }
184 // Un-register execution
185 $task->unmarkExecution($executionID, $failure);
186 // Log completion of execution
187 $logMessage = 'Task executed. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
188 $this->log($logMessage);
189 // Now that the result of the task execution has been handled,
190 // throw the exception again, if any
191 if ($failure instanceof \Exception) {
192 throw $failure;
193 }
194 }
195 return $result;
196 }
197
198 /**
199 * This method stores information about the last run of the Scheduler into the system registry
200 *
201 * @param string $type Type of run (manual or command-line (assumed to be cron))
202 * @return void
203 */
204 public function recordLastRun($type = 'cron')
205 {
206 // Validate input value
207 if ($type !== 'manual' && $type !== 'cli-by-id') {
208 $type = 'cron';
209 }
210 /** @var Registry $registry */
211 $registry = GeneralUtility::makeInstance(Registry::class);
212 $runInformation = ['start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type];
213 $registry->set('tx_scheduler', 'lastRun', $runInformation);
214 }
215
216 /**
217 * Removes a task completely from the system.
218 *
219 * @todo find a way to actually kill the existing jobs
220 *
221 * @param Task\AbstractTask $task The object representing the task to delete
222 * @return bool TRUE if task was successfully deleted, FALSE otherwise
223 */
224 public function removeTask(Task\AbstractTask $task)
225 {
226 $taskUid = $task->getTaskUid();
227 if (!empty($taskUid)) {
228 $result = GeneralUtility::makeInstance(ConnectionPool::class)
229 ->getConnectionForTable('tx_scheduler_task')
230 ->delete('tx_scheduler_task', ['uid' => $taskUid]);
231 } else {
232 $result = false;
233 }
234 if ($result) {
235 $this->scheduleNextSchedulerRunUsingAtDaemon();
236 }
237 return $result;
238 }
239
240 /**
241 * Updates a task in the pool
242 *
243 * @param Task\AbstractTask $task Scheduler task object
244 * @return bool False if submitted task was not of proper class
245 */
246 public function saveTask(Task\AbstractTask $task)
247 {
248 $taskUid = $task->getTaskUid();
249 if (!empty($taskUid)) {
250 try {
251 $executionTime = $task->getNextDueExecution();
252 $task->setExecutionTime($executionTime);
253 } catch (\Exception $e) {
254 $task->setDisabled(true);
255 $executionTime = 0;
256 }
257 $task->unsetScheduler();
258 $fields = [
259 'nextexecution' => $executionTime,
260 'disable' => (int)$task->isDisabled(),
261 'description' => $task->getDescription(),
262 'task_group' => $task->getTaskGroup(),
263 'serialized_task_object' => serialize($task)
264 ];
265 $result = GeneralUtility::makeInstance(ConnectionPool::class)
266 ->getConnectionForTable('tx_scheduler_task')
267 ->update('tx_scheduler_task', $fields, ['uid' => $taskUid]);
268 } else {
269 $result = false;
270 }
271 if ($result) {
272 $this->scheduleNextSchedulerRunUsingAtDaemon();
273 }
274 return $result;
275 }
276
277 /**
278 * Fetches and unserializes a task object from the db. If an uid is given the object
279 * with the uid is returned, else the object representing the next due task is returned.
280 * If there are no due tasks the method throws an exception.
281 *
282 * @param int $uid Primary key of a task
283 * @return Task\AbstractTask The fetched task object
284 * @throws \OutOfBoundsException
285 * @throws \UnexpectedValueException
286 */
287 public function fetchTask($uid = 0)
288 {
289 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
290 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
291
292 $queryBuilder->select('t.uid', 't.serialized_task_object')
293 ->from('tx_scheduler_task', 't')
294 ->setMaxResults(1);
295 // Define where clause
296 // If no uid is given, take any non-disabled task which has a next execution time in the past
297 if (empty($uid)) {
298 $queryBuilder->getRestrictions()->removeAll();
299 $queryBuilder->leftJoin(
300 't',
301 'tx_scheduler_task_group',
302 'g',
303 $queryBuilder->expr()->eq('t.task_group', $queryBuilder->quoteIdentifier('g.uid'))
304 );
305 $queryBuilder->where(
306 $queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
307 $queryBuilder->expr()->neq('t.nextexecution', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
308 $queryBuilder->expr()->lte(
309 't.nextexecution',
310 $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
311 ),
312 $queryBuilder->expr()->orX(
313 $queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
314 $queryBuilder->expr()->isNull('g.hidden')
315 )
316 );
317 } else {
318 $queryBuilder->where(
319 $queryBuilder->expr()->eq('t.uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
320 );
321 }
322
323 $row = $queryBuilder->execute()->fetch();
324 if ($row === false) {
325 throw new \OutOfBoundsException('Query could not be executed. Possible defect in tables tx_scheduler_task or tx_scheduler_task_group or DB server problems', 1422044826);
326 } elseif (empty($row)) {
327 // If there are no available tasks, thrown an exception
328 throw new \OutOfBoundsException('No task', 1247827244);
329 } else {
330 /** @var $task Task\AbstractTask */
331 $task = unserialize($row['serialized_task_object']);
332 if ($this->isValidTaskObject($task)) {
333 // The task is valid, return it
334 $task->setScheduler();
335 } else {
336 // Forcibly set the disable flag to 1 in the database,
337 // so that the task does not come up again and again for execution
338 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
339 'tx_scheduler_task',
340 ['disable' => 1],
341 ['uid' => (int)$row['uid']]
342 );
343 // Throw an exception to raise the problem
344 throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
345 }
346 }
347 return $task;
348 }
349
350 /**
351 * This method is used to get the database record for a given task
352 * It returns the database record and not the task object
353 *
354 * @param int $uid Primary key of the task to get
355 * @return array Database record for the task
356 * @see \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
357 * @throws \OutOfBoundsException
358 */
359 public function fetchTaskRecord($uid)
360 {
361 $row = GeneralUtility::makeInstance(ConnectionPool::class)
362 ->getConnectionForTable('tx_scheduler_task')
363 ->select(['*'], 'tx_scheduler_task', ['uid' => (int)$uid])
364 ->fetch();
365
366 // If the task is not found, throw an exception
367 if (empty($row)) {
368 throw new \OutOfBoundsException('No task', 1247827245);
369 }
370
371 return $row;
372 }
373
374 /**
375 * Fetches and unserializes task objects selected with some (SQL) condition
376 * Objects are returned as an array
377 *
378 * @param string $where Part of a SQL where clause (without the "WHERE" keyword)
379 * @param bool $includeDisabledTasks TRUE if disabled tasks should be fetched too, FALSE otherwise
380 * @return array List of task objects
381 */
382 public function fetchTasksWithCondition($where, $includeDisabledTasks = false)
383 {
384 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
385 ->getQueryBuilderForTable('tx_scheduler_task');
386
387 $constraints = [];
388 $tasks = [];
389
390 if (!$includeDisabledTasks) {
391 $constraints[] = $queryBuilder->expr()->eq(
392 'disable',
393 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
394 );
395 } else {
396 $constraints[] = '1=1';
397 }
398
399 if (!empty($where)) {
400 $constraints[] = QueryHelper::stripLogicalOperatorPrefix($where);
401 }
402
403 $result = $queryBuilder->select('serialized_task_object')
404 ->from('tx_scheduler_task')
405 ->where(...$constraints)
406 ->execute();
407
408 while ($row = $result->fetch()) {
409 /** @var Task\AbstractTask $task */
410 $task = unserialize($row['serialized_task_object']);
411 // Add the task to the list only if it is valid
412 if ($this->isValidTaskObject($task)) {
413 $task->setScheduler();
414 $tasks[] = $task;
415 }
416 }
417
418 return $tasks;
419 }
420
421 /**
422 * This method encapsulates a very simple test for the purpose of clarity.
423 * Registered tasks are stored in the database along with a serialized task object.
424 * When a registered task is fetched, its object is unserialized.
425 * At that point, if the class corresponding to the object is not available anymore
426 * (e.g. because the extension providing it has been uninstalled),
427 * the unserialization will produce an incomplete object.
428 * This test checks whether the unserialized object is of the right (parent) class or not.
429 *
430 * @param object $task The object to test
431 * @return bool TRUE if object is a task, FALSE otherwise
432 */
433 public function isValidTaskObject($task)
434 {
435 return $task instanceof Task\AbstractTask;
436 }
437
438 /**
439 * This is a utility method that writes some message to the BE Log
440 * It could be expanded to write to some other log
441 *
442 * @param string $message The message to write to the log
443 * @param int $status Status (0 = message, 1 = error)
444 * @param mixed $code Key for the message
445 * @return void
446 */
447 public function log($message, $status = 0, $code = 'scheduler')
448 {
449 // Log only if enabled
450 if (!empty($this->extConf['enableBELog'])) {
451 $GLOBALS['BE_USER']->writelog(4, 0, $status, $code, '[scheduler]: ' . $message, []);
452 }
453 }
454
455 /**
456 * Schedule the next run of scheduler
457 * For the moment only the "at"-daemon is used, and only if it is enabled
458 *
459 * @return bool Successfully scheduled next execution using "at"-daemon
460 * @see tx_scheduler::fetchTask()
461 */
462 public function scheduleNextSchedulerRunUsingAtDaemon()
463 {
464 if ((int)$this->extConf['useAtdaemon'] !== 1) {
465 return false;
466 }
467 /** @var $registry Registry */
468 $registry = GeneralUtility::makeInstance(Registry::class);
469 // Get at job id from registry and remove at job
470 $atJobId = $registry->get('tx_scheduler', 'atJobId');
471 if (MathUtility::canBeInterpretedAsInteger($atJobId)) {
472 shell_exec('atrm ' . (int)$atJobId . ' 2>&1');
473 }
474 // Can not use fetchTask() here because if tasks have just executed
475 // they are not in the list of next executions
476 $tasks = $this->fetchTasksWithCondition('');
477 $nextExecution = false;
478 foreach ($tasks as $task) {
479 try {
480 /** @var $task Task\AbstractTask */
481 $tempNextExecution = $task->getNextDueExecution();
482 if ($nextExecution === false || $tempNextExecution < $nextExecution) {
483 $nextExecution = $tempNextExecution;
484 }
485 } catch (\OutOfBoundsException $e) {
486 // The event will not be executed again or has already ended - we don't have to consider it for
487 // scheduling the next "at" run
488 }
489 }
490 if ($nextExecution !== false) {
491 if ($nextExecution > $GLOBALS['EXEC_TIME']) {
492 $startTime = strftime('%H:%M %F', $nextExecution);
493 } else {
494 $startTime = 'now+1minute';
495 }
496 $cliDispatchPath = PATH_site . 'typo3/cli_dispatch.phpsh';
497 list($cliDispatchPathEscaped, $startTimeEscaped) =
498 CommandUtility::escapeShellArguments([$cliDispatchPath, $startTime]);
499 $cmd = 'echo ' . $cliDispatchPathEscaped . ' scheduler | at ' . $startTimeEscaped . ' 2>&1';
500 $output = shell_exec($cmd);
501 $outputParts = '';
502 foreach (explode(LF, $output) as $outputLine) {
503 if (GeneralUtility::isFirstPartOfStr($outputLine, 'job')) {
504 $outputParts = explode(' ', $outputLine, 3);
505 break;
506 }
507 }
508 if ($outputParts[0] === 'job' && MathUtility::canBeInterpretedAsInteger($outputParts[1])) {
509 $atJobId = (int)$outputParts[1];
510 $registry->set('tx_scheduler', 'atJobId', $atJobId);
511 }
512 }
513 return true;
514 }
515 }