d8d8f2c649b0ddc08aec42e8676fe63fa9ea4242
[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 Psr\Log\LoggerAwareInterface;
18 use Psr\Log\LoggerAwareTrait;
19 use Psr\Log\LoggerInterface;
20 use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
21 use TYPO3\CMS\Core\Database\Connection;
22 use TYPO3\CMS\Core\Database\ConnectionPool;
23 use TYPO3\CMS\Core\Database\Query\QueryHelper;
24 use TYPO3\CMS\Core\Log\LogManager;
25 use TYPO3\CMS\Core\Registry;
26 use TYPO3\CMS\Core\SingletonInterface;
27 use TYPO3\CMS\Core\Utility\GeneralUtility;
28
29 /**
30 * TYPO3 Scheduler. This class handles scheduling and execution of tasks.
31 * Formerly known as "Gabriel TYPO3 arch angel"
32 */
33 class Scheduler implements SingletonInterface, LoggerAwareInterface
34 {
35 use LoggerAwareTrait;
36
37 /**
38 * @var array $extConf Settings from the extension manager
39 */
40 public $extConf = [];
41
42 /**
43 * Constructor, makes sure all derived client classes are included
44 */
45 public function __construct()
46 {
47 // Get configuration from the extension manager
48 $this->extConf = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('scheduler');
49 if (empty($this->extConf['maxLifetime'])) {
50 $this->extConf['maxLifetime'] = 1440;
51 }
52 // Clean up the serialized execution arrays
53 $this->cleanExecutionArrays();
54 }
55
56 /**
57 * Adds a task to the pool
58 *
59 * @param Task\AbstractTask $task The object representing the task to add
60 * @return bool TRUE if the task was successfully added, FALSE otherwise
61 */
62 public function addTask(Task\AbstractTask $task)
63 {
64 $taskUid = $task->getTaskUid();
65 if (empty($taskUid)) {
66 $fields = [
67 'crdate' => $GLOBALS['EXEC_TIME'],
68 'disable' => (int)$task->isDisabled(),
69 'description' => $task->getDescription(),
70 'task_group' => $task->getTaskGroup(),
71 'serialized_task_object' => 'RESERVED'
72 ];
73 $connection = GeneralUtility::makeInstance(ConnectionPool::class)
74 ->getConnectionForTable('tx_scheduler_task');
75 $result = $connection->insert(
76 'tx_scheduler_task',
77 $fields,
78 ['serialized_task_object' => Connection::PARAM_LOB]
79 );
80
81 if ($result) {
82 $task->setTaskUid($connection->lastInsertId('tx_scheduler_task'));
83 $task->save();
84 $result = true;
85 } else {
86 $result = false;
87 }
88 } else {
89 $result = false;
90 }
91 return $result;
92 }
93
94 /**
95 * Cleans the execution lists of the scheduled tasks, executions older than 24h are removed
96 * @todo find a way to actually kill the job
97 */
98 protected function cleanExecutionArrays()
99 {
100 $tstamp = $GLOBALS['EXEC_TIME'];
101 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
102 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
103
104 // Select all tasks with executions
105 // NOTE: this cleanup is done for disabled tasks too,
106 // to avoid leaving old executions lying around
107 $result = $queryBuilder->select('uid', 'serialized_executions', 'serialized_task_object')
108 ->from('tx_scheduler_task')
109 ->where(
110 $queryBuilder->expr()->neq(
111 'serialized_executions',
112 $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
113 )
114 )
115 ->execute();
116 $maxDuration = $this->extConf['maxLifetime'] * 60;
117 while ($row = $result->fetch()) {
118 $executions = [];
119 if ($serialized_executions = unserialize($row['serialized_executions'])) {
120 foreach ($serialized_executions as $task) {
121 if ($tstamp - $task < $maxDuration) {
122 $executions[] = $task;
123 } else {
124 $task = unserialize($row['serialized_task_object']);
125 $this->log('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()));
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->logger->info($logMessage);
171 $result = false;
172 } else {
173 // Log scheduler invocation
174 $this->logger->info('Start execution. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid());
175 // Register execution
176 $executionID = $task->markExecution();
177 $failure = null;
178 try {
179 // Execute task
180 $successfullyExecuted = $task->execute();
181 if (!$successfullyExecuted) {
182 throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
183 }
184 } catch (\Exception $e) {
185 // Store exception, so that it can be saved to database
186 $failure = $e;
187 }
188 // Un-register execution
189 $task->unmarkExecution($executionID, $failure);
190 // Log completion of execution
191 $this->logger->info('Task executed. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid());
192 // Now that the result of the task execution has been handled,
193 // throw the exception again, if any
194 if ($failure instanceof \Exception) {
195 throw $failure;
196 }
197 }
198 return $result;
199 }
200
201 /**
202 * This method stores information about the last run of the Scheduler into the system registry
203 *
204 * @param string $type Type of run (manual or command-line (assumed to be cron))
205 */
206 public function recordLastRun($type = 'cron')
207 {
208 // Validate input value
209 if ($type !== 'manual' && $type !== 'cli-by-id') {
210 $type = 'cron';
211 }
212 /** @var Registry $registry */
213 $registry = GeneralUtility::makeInstance(Registry::class);
214 $runInformation = ['start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type];
215 $registry->set('tx_scheduler', 'lastRun', $runInformation);
216 }
217
218 /**
219 * Removes a task completely from the system.
220 *
221 * @todo find a way to actually kill the existing jobs
222 *
223 * @param Task\AbstractTask $task The object representing the task to delete
224 * @return bool TRUE if task was successfully deleted, FALSE otherwise
225 */
226 public function removeTask(Task\AbstractTask $task)
227 {
228 $taskUid = $task->getTaskUid();
229 if (!empty($taskUid)) {
230 $result = GeneralUtility::makeInstance(ConnectionPool::class)
231 ->getConnectionForTable('tx_scheduler_task')
232 ->update('tx_scheduler_task', ['deleted' => 1], ['uid' => $taskUid]);
233 } else {
234 $result = false;
235 }
236 return $result;
237 }
238
239 /**
240 * Updates a task in the pool
241 *
242 * @param Task\AbstractTask $task Scheduler task object
243 * @return bool False if submitted task was not of proper class
244 */
245 public function saveTask(Task\AbstractTask $task)
246 {
247 $taskUid = $task->getTaskUid();
248 if (!empty($taskUid)) {
249 try {
250 if ($task->getRunOnNextCronJob()) {
251 $executionTime = time();
252 } else {
253 $executionTime = $task->getNextDueExecution();
254 }
255 $task->setExecutionTime($executionTime);
256 } catch (\Exception $e) {
257 $task->setDisabled(true);
258 $executionTime = 0;
259 }
260 $task->unsetScheduler();
261 $fields = [
262 'nextexecution' => $executionTime,
263 'disable' => (int)$task->isDisabled(),
264 'description' => $task->getDescription(),
265 'task_group' => $task->getTaskGroup(),
266 'serialized_task_object' => serialize($task)
267 ];
268 $result = GeneralUtility::makeInstance(ConnectionPool::class)
269 ->getConnectionForTable('tx_scheduler_task')
270 ->update(
271 'tx_scheduler_task',
272 $fields,
273 ['uid' => $taskUid],
274 ['serialized_task_object' => Connection::PARAM_LOB]
275 );
276 } else {
277 $result = false;
278 }
279 return $result;
280 }
281
282 /**
283 * Fetches and unserializes a task object from the db. If an uid is given the object
284 * with the uid is returned, else the object representing the next due task is returned.
285 * If there are no due tasks the method throws an exception.
286 *
287 * @param int $uid Primary key of a task
288 * @return Task\AbstractTask The fetched task object
289 * @throws \OutOfBoundsException
290 * @throws \UnexpectedValueException
291 */
292 public function fetchTask($uid = 0)
293 {
294 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
295 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
296
297 $queryBuilder->select('t.uid', 't.serialized_task_object')
298 ->from('tx_scheduler_task', 't')
299 ->setMaxResults(1);
300 // Define where clause
301 // If no uid is given, take any non-disabled task which has a next execution time in the past
302 if (empty($uid)) {
303 $queryBuilder->getRestrictions()->removeAll();
304 $queryBuilder->leftJoin(
305 't',
306 'tx_scheduler_task_group',
307 'g',
308 $queryBuilder->expr()->eq('t.task_group', $queryBuilder->quoteIdentifier('g.uid'))
309 );
310 $queryBuilder->where(
311 $queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
312 $queryBuilder->expr()->neq('t.nextexecution', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
313 $queryBuilder->expr()->lte(
314 't.nextexecution',
315 $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
316 ),
317 $queryBuilder->expr()->orX(
318 $queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
319 $queryBuilder->expr()->isNull('g.hidden')
320 )
321 );
322 } else {
323 $queryBuilder->where(
324 $queryBuilder->expr()->eq('t.uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
325 );
326 }
327
328 $row = $queryBuilder->execute()->fetch();
329 if ($row === false) {
330 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);
331 }
332 if (empty($row)) {
333 // If there are no available tasks, thrown an exception
334 throw new \OutOfBoundsException('No task', 1247827244);
335 }
336 /** @var $task Task\AbstractTask */
337 $task = unserialize($row['serialized_task_object']);
338 if ($this->isValidTaskObject($task)) {
339 // The task is valid, return it
340 $task->setScheduler();
341 } else {
342 // Forcibly set the disable flag to 1 in the database,
343 // so that the task does not come up again and again for execution
344 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
345 'tx_scheduler_task',
346 ['disable' => 1],
347 ['uid' => (int)$row['uid']]
348 );
349 // Throw an exception to raise the problem
350 throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
351 }
352
353 return $task;
354 }
355
356 /**
357 * This method is used to get the database record for a given task
358 * It returns the database record and not the task object
359 *
360 * @param int $uid Primary key of the task to get
361 * @return array Database record for the task
362 * @see \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
363 * @throws \OutOfBoundsException
364 */
365 public function fetchTaskRecord($uid)
366 {
367 $row = GeneralUtility::makeInstance(ConnectionPool::class)
368 ->getConnectionForTable('tx_scheduler_task')
369 ->select(['*'], 'tx_scheduler_task', ['uid' => (int)$uid])
370 ->fetch();
371
372 // If the task is not found, throw an exception
373 if (empty($row)) {
374 throw new \OutOfBoundsException('No task', 1247827245);
375 }
376
377 return $row;
378 }
379
380 /**
381 * Fetches and unserializes task objects selected with some (SQL) condition
382 * Objects are returned as an array
383 *
384 * @param string $where Part of a SQL where clause (without the "WHERE" keyword)
385 * @param bool $includeDisabledTasks TRUE if disabled tasks should be fetched too, FALSE otherwise
386 * @return array List of task objects
387 */
388 public function fetchTasksWithCondition($where, $includeDisabledTasks = false)
389 {
390 $tasks = [];
391 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
392 ->getQueryBuilderForTable('tx_scheduler_task');
393
394 $queryBuilder
395 ->select('serialized_task_object')
396 ->from('tx_scheduler_task')
397 ->where(
398 $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
399 );
400
401 if (!$includeDisabledTasks) {
402 $queryBuilder->andWhere(
403 $queryBuilder->expr()->eq('disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
404 );
405 }
406
407 if (!empty($where)) {
408 $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($where));
409 }
410
411 $result = $queryBuilder->execute();
412 while ($row = $result->fetch()) {
413 /** @var Task\AbstractTask $task */
414 $task = unserialize($row['serialized_task_object']);
415 // Add the task to the list only if it is valid
416 if ($this->isValidTaskObject($task)) {
417 $task->setScheduler();
418 $tasks[] = $task;
419 }
420 }
421
422 return $tasks;
423 }
424
425 /**
426 * This method encapsulates a very simple test for the purpose of clarity.
427 * Registered tasks are stored in the database along with a serialized task object.
428 * When a registered task is fetched, its object is unserialized.
429 * At that point, if the class corresponding to the object is not available anymore
430 * (e.g. because the extension providing it has been uninstalled),
431 * the unserialization will produce an incomplete object.
432 * This test checks whether the unserialized object is of the right (parent) class or not.
433 *
434 * @param object $task The object to test
435 * @return bool TRUE if object is a task, FALSE otherwise
436 */
437 public function isValidTaskObject($task)
438 {
439 return $task instanceof Task\AbstractTask && get_class($task->getExecution()) !== '__PHP_Incomplete_Class';
440 }
441
442 /**
443 * This is a utility method that writes some message to the BE Log
444 * It could be expanded to write to some other log
445 *
446 * @param string $message The message to write to the log
447 * @param int $status Status (0 = message, 1 = error)
448 * @param mixed $code Key for the message
449 */
450 public function log($message, $status = 0, $code = '')
451 {
452 // this method could be called from the constructor (via "cleanExecutionArrays") and no logger is instantiated
453 // by then, that's why check if the logger is available
454 if (!($this->logger instanceof LoggerInterface)) {
455 $this->setLogger(GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__));
456 }
457 $message = trim('[scheduler]: ' . $code) . ' - ' . $message;
458 switch ((int)$status) {
459 // error (user problem)
460 case 1:
461 $this->logger->alert($message);
462 break;
463 // System Error (which should not happen)
464 case 2:
465 $this->logger->error($message);
466 break;
467 // security notice (admin)
468 case 3:
469 $this->logger->emergency($message);
470 break;
471 // regular message (= 0)
472 default:
473 $this->logger->info($message);
474 }
475 }
476 }