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