[BUGFIX] Use different field for logging in EXT:scheduler
[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 $taskUid = $task->getTaskUid();
253 if (!empty($taskUid)) {
254 try {
255 if ($task->getRunOnNextCronJob()) {
256 $executionTime = time();
257 } else {
258 $executionTime = $task->getNextDueExecution();
259 }
260 $task->setExecutionTime($executionTime);
261 } catch (\Exception $e) {
262 $task->setDisabled(true);
263 $executionTime = 0;
264 }
265 $task->unsetScheduler();
266 $fields = [
267 'nextexecution' => $executionTime,
268 'disable' => (int)$task->isDisabled(),
269 'description' => $task->getDescription(),
270 'task_group' => $task->getTaskGroup(),
271 'serialized_task_object' => serialize($task)
272 ];
273 $result = GeneralUtility::makeInstance(ConnectionPool::class)
274 ->getConnectionForTable('tx_scheduler_task')
275 ->update(
276 'tx_scheduler_task',
277 $fields,
278 ['uid' => $taskUid],
279 ['serialized_task_object' => Connection::PARAM_LOB]
280 );
281 } else {
282 $result = false;
283 }
284 if ($result) {
285 $this->scheduleNextSchedulerRunUsingAtDaemon();
286 }
287 return $result;
288 }
289
290 /**
291 * Fetches and unserializes a task object from the db. If an uid is given the object
292 * with the uid is returned, else the object representing the next due task is returned.
293 * If there are no due tasks the method throws an exception.
294 *
295 * @param int $uid Primary key of a task
296 * @return Task\AbstractTask The fetched task object
297 * @throws \OutOfBoundsException
298 * @throws \UnexpectedValueException
299 */
300 public function fetchTask($uid = 0)
301 {
302 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
303 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
304
305 $queryBuilder->select('t.uid', 't.serialized_task_object')
306 ->from('tx_scheduler_task', 't')
307 ->setMaxResults(1);
308 // Define where clause
309 // If no uid is given, take any non-disabled task which has a next execution time in the past
310 if (empty($uid)) {
311 $queryBuilder->getRestrictions()->removeAll();
312 $queryBuilder->leftJoin(
313 't',
314 'tx_scheduler_task_group',
315 'g',
316 $queryBuilder->expr()->eq('t.task_group', $queryBuilder->quoteIdentifier('g.uid'))
317 );
318 $queryBuilder->where(
319 $queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
320 $queryBuilder->expr()->neq('t.nextexecution', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
321 $queryBuilder->expr()->lte(
322 't.nextexecution',
323 $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
324 ),
325 $queryBuilder->expr()->orX(
326 $queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
327 $queryBuilder->expr()->isNull('g.hidden')
328 )
329 );
330 } else {
331 $queryBuilder->where(
332 $queryBuilder->expr()->eq('t.uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
333 );
334 }
335
336 $row = $queryBuilder->execute()->fetch();
337 if ($row === false) {
338 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);
339 } elseif (empty($row)) {
340 // If there are no available tasks, thrown an exception
341 throw new \OutOfBoundsException('No task', 1247827244);
342 } else {
343 /** @var $task Task\AbstractTask */
344 $task = unserialize($row['serialized_task_object']);
345 if ($this->isValidTaskObject($task)) {
346 // The task is valid, return it
347 $task->setScheduler();
348 } else {
349 // Forcibly set the disable flag to 1 in the database,
350 // so that the task does not come up again and again for execution
351 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
352 'tx_scheduler_task',
353 ['disable' => 1],
354 ['uid' => (int)$row['uid']]
355 );
356 // Throw an exception to raise the problem
357 throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
358 }
359 }
360 return $task;
361 }
362
363 /**
364 * This method is used to get the database record for a given task
365 * It returns the database record and not the task object
366 *
367 * @param int $uid Primary key of the task to get
368 * @return array Database record for the task
369 * @see \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
370 * @throws \OutOfBoundsException
371 */
372 public function fetchTaskRecord($uid)
373 {
374 $row = GeneralUtility::makeInstance(ConnectionPool::class)
375 ->getConnectionForTable('tx_scheduler_task')
376 ->select(['*'], 'tx_scheduler_task', ['uid' => (int)$uid])
377 ->fetch();
378
379 // If the task is not found, throw an exception
380 if (empty($row)) {
381 throw new \OutOfBoundsException('No task', 1247827245);
382 }
383
384 return $row;
385 }
386
387 /**
388 * Fetches and unserializes task objects selected with some (SQL) condition
389 * Objects are returned as an array
390 *
391 * @param string $where Part of a SQL where clause (without the "WHERE" keyword)
392 * @param bool $includeDisabledTasks TRUE if disabled tasks should be fetched too, FALSE otherwise
393 * @return array List of task objects
394 */
395 public function fetchTasksWithCondition($where, $includeDisabledTasks = false)
396 {
397 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
398 ->getQueryBuilderForTable('tx_scheduler_task');
399
400 $constraints = [];
401 $tasks = [];
402
403 if (!$includeDisabledTasks) {
404 $constraints[] = $queryBuilder->expr()->eq(
405 'disable',
406 $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
407 );
408 } else {
409 $constraints[] = '1=1';
410 }
411
412 if (!empty($where)) {
413 $constraints[] = QueryHelper::stripLogicalOperatorPrefix($where);
414 }
415
416 $result = $queryBuilder->select('serialized_task_object')
417 ->from('tx_scheduler_task')
418 ->where(...$constraints)
419 ->execute();
420
421 while ($row = $result->fetch()) {
422 /** @var Task\AbstractTask $task */
423 $task = unserialize($row['serialized_task_object']);
424 // Add the task to the list only if it is valid
425 if ($this->isValidTaskObject($task)) {
426 $task->setScheduler();
427 $tasks[] = $task;
428 }
429 }
430
431 return $tasks;
432 }
433
434 /**
435 * This method encapsulates a very simple test for the purpose of clarity.
436 * Registered tasks are stored in the database along with a serialized task object.
437 * When a registered task is fetched, its object is unserialized.
438 * At that point, if the class corresponding to the object is not available anymore
439 * (e.g. because the extension providing it has been uninstalled),
440 * the unserialization will produce an incomplete object.
441 * This test checks whether the unserialized object is of the right (parent) class or not.
442 *
443 * @param object $task The object to test
444 * @return bool TRUE if object is a task, FALSE otherwise
445 */
446 public function isValidTaskObject($task)
447 {
448 return $task instanceof Task\AbstractTask;
449 }
450
451 /**
452 * This is a utility method that writes some message to the BE Log
453 * It could be expanded to write to some other log
454 *
455 * @param string $message The message to write to the log
456 * @param int $status Status (0 = message, 1 = error)
457 * @param mixed $code Key for the message
458 */
459 public function log($message, $status = 0, $code = 'scheduler')
460 {
461 // Log only if enabled
462 if (!empty($this->extConf['enableBELog'])) {
463 $GLOBALS['BE_USER']->writelog(4, 0, $status, 0, '[scheduler]: ' . $code . ' - ' . $message, []);
464 }
465 }
466
467 /**
468 * Schedule the next run of scheduler
469 * For the moment only the "at"-daemon is used, and only if it is enabled
470 *
471 * @return bool Successfully scheduled next execution using "at"-daemon
472 * @see tx_scheduler::fetchTask()
473 */
474 public function scheduleNextSchedulerRunUsingAtDaemon()
475 {
476 if ((int)$this->extConf['useAtdaemon'] !== 1) {
477 return false;
478 }
479 /** @var $registry Registry */
480 $registry = GeneralUtility::makeInstance(Registry::class);
481 // Get at job id from registry and remove at job
482 $atJobId = $registry->get('tx_scheduler', 'atJobId');
483 if (MathUtility::canBeInterpretedAsInteger($atJobId)) {
484 shell_exec('atrm ' . (int)$atJobId . ' 2>&1');
485 }
486 // Can not use fetchTask() here because if tasks have just executed
487 // they are not in the list of next executions
488 $tasks = $this->fetchTasksWithCondition('');
489 $nextExecution = false;
490 foreach ($tasks as $task) {
491 try {
492 /** @var $task Task\AbstractTask */
493 $tempNextExecution = $task->getNextDueExecution();
494 if ($nextExecution === false || $tempNextExecution < $nextExecution) {
495 $nextExecution = $tempNextExecution;
496 }
497 } catch (\OutOfBoundsException $e) {
498 // The event will not be executed again or has already ended - we don't have to consider it for
499 // scheduling the next "at" run
500 }
501 }
502 if ($nextExecution !== false) {
503 if ($nextExecution > $GLOBALS['EXEC_TIME']) {
504 $startTime = strftime('%H:%M %F', $nextExecution);
505 } else {
506 $startTime = 'now+1minute';
507 }
508 $cliDispatchPath = PATH_site . 'typo3/sysext/core/bin/typo3';
509 list($cliDispatchPathEscaped, $startTimeEscaped) =
510 CommandUtility::escapeShellArguments([$cliDispatchPath, $startTime]);
511 $cmd = 'echo ' . $cliDispatchPathEscaped . ' scheduler:run | at ' . $startTimeEscaped . ' 2>&1';
512 $output = shell_exec($cmd);
513 $outputParts = '';
514 foreach (explode(LF, $output) as $outputLine) {
515 if (GeneralUtility::isFirstPartOfStr($outputLine, 'job')) {
516 $outputParts = explode(' ', $outputLine, 3);
517 break;
518 }
519 }
520 if ($outputParts[0] === 'job' && MathUtility::canBeInterpretedAsInteger($outputParts[1])) {
521 $atJobId = (int)$outputParts[1];
522 $registry->set('tx_scheduler', 'atJobId', $atJobId);
523 }
524 }
525 return true;
526 }
527 }