ae33ce6008dd0513bb32c187d90f02740c73494e
[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\Configuration\ExtensionConfiguration;
18 use TYPO3\CMS\Core\Database\Connection;
19 use TYPO3\CMS\Core\Database\ConnectionPool;
20 use TYPO3\CMS\Core\Database\Query\QueryHelper;
21 use TYPO3\CMS\Core\Registry;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
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 = GeneralUtility::makeInstance(ExtensionConfiguration::class)->get('scheduler');
44 if (empty($this->extConf['maxLifetime'])) {
45 $this->extConf['maxLifetime'] = 1440;
46 }
47 // Clean up the serialized execution arrays
48 $this->cleanExecutionArrays();
49 }
50
51 /**
52 * Adds a task to the pool
53 *
54 * @param Task\AbstractTask $task The object representing the task to add
55 * @return bool TRUE if the task was successfully added, FALSE otherwise
56 */
57 public function addTask(Task\AbstractTask $task)
58 {
59 $taskUid = $task->getTaskUid();
60 if (empty($taskUid)) {
61 $fields = [
62 'crdate' => $GLOBALS['EXEC_TIME'],
63 'disable' => (int)$task->isDisabled(),
64 'description' => $task->getDescription(),
65 'task_group' => $task->getTaskGroup(),
66 'serialized_task_object' => 'RESERVED'
67 ];
68 $connection = GeneralUtility::makeInstance(ConnectionPool::class)
69 ->getConnectionForTable('tx_scheduler_task');
70 $result = $connection->insert(
71 'tx_scheduler_task',
72 $fields,
73 ['serialized_task_object' => Connection::PARAM_LOB]
74 );
75
76 if ($result) {
77 $task->setTaskUid($connection->lastInsertId('tx_scheduler_task'));
78 $task->save();
79 $result = true;
80 } else {
81 $result = false;
82 }
83 } else {
84 $result = false;
85 }
86 return $result;
87 }
88
89 /**
90 * Cleans the execution lists of the scheduled tasks, executions older than 24h are removed
91 * @todo find a way to actually kill the job
92 */
93 protected function cleanExecutionArrays()
94 {
95 $tstamp = $GLOBALS['EXEC_TIME'];
96 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
97 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
98
99 // Select all tasks with executions
100 // NOTE: this cleanup is done for disabled tasks too,
101 // to avoid leaving old executions lying around
102 $result = $queryBuilder->select('uid', 'serialized_executions', 'serialized_task_object')
103 ->from('tx_scheduler_task')
104 ->where(
105 $queryBuilder->expr()->neq(
106 'serialized_executions',
107 $queryBuilder->createNamedParameter('', \PDO::PARAM_STR)
108 )
109 )
110 ->execute();
111 $maxDuration = $this->extConf['maxLifetime'] * 60;
112 while ($row = $result->fetch()) {
113 $executions = [];
114 if ($serialized_executions = unserialize($row['serialized_executions'])) {
115 foreach ($serialized_executions as $task) {
116 if ($tstamp - $task < $maxDuration) {
117 $executions[] = $task;
118 } else {
119 $task = unserialize($row['serialized_task_object']);
120 $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());
121 $this->log($logMessage);
122 }
123 }
124 }
125 $executionCount = count($executions);
126 if (count($serialized_executions) !== $executionCount) {
127 if ($executionCount === 0) {
128 $value = '';
129 } else {
130 $value = serialize($executions);
131 }
132 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
133 'tx_scheduler_task',
134 ['serialized_executions' => $value],
135 ['uid' => (int)$row['uid']],
136 ['serialized_executions' => Connection::PARAM_LOB]
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 $task->setRunOnNextCronJob(false);
154 // Trigger the saving of the task, as this will calculate its next execution time
155 // This should be calculated all the time, even if the execution is skipped
156 // (in case it is skipped, this pushes back execution to the next possible date)
157 $task->save();
158 // Set a scheduler object for the task again,
159 // as it was removed during the save operation
160 $task->setScheduler();
161 $result = true;
162 // Task is already running and multiple executions are not allowed
163 if (!$task->areMultipleExecutionsAllowed() && $task->isExecutionRunning()) {
164 // Log multiple execution error
165 $logMessage = 'Task is already running and multiple executions are not allowed, skipping! Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
166 $this->log($logMessage);
167 $result = false;
168 } else {
169 // Log scheduler invocation
170 $logMessage = 'Start execution. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
171 $this->log($logMessage);
172 // Register execution
173 $executionID = $task->markExecution();
174 $failure = null;
175 try {
176 // Execute task
177 $successfullyExecuted = $task->execute();
178 if (!$successfullyExecuted) {
179 throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
180 }
181 } catch (\Exception $e) {
182 // Store exception, so that it can be saved to database
183 $failure = $e;
184 }
185 // Un-register execution
186 $task->unmarkExecution($executionID, $failure);
187 // Log completion of execution
188 $logMessage = 'Task executed. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
189 $this->log($logMessage);
190 // Now that the result of the task execution has been handled,
191 // throw the exception again, if any
192 if ($failure instanceof \Exception) {
193 throw $failure;
194 }
195 }
196 return $result;
197 }
198
199 /**
200 * This method stores information about the last run of the Scheduler into the system registry
201 *
202 * @param string $type Type of run (manual or command-line (assumed to be cron))
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 ->update('tx_scheduler_task', ['deleted' => 1], ['uid' => $taskUid]);
231 } else {
232 $result = false;
233 }
234 return $result;
235 }
236
237 /**
238 * Updates a task in the pool
239 *
240 * @param Task\AbstractTask $task Scheduler task object
241 * @return bool False if submitted task was not of proper class
242 */
243 public function saveTask(Task\AbstractTask $task)
244 {
245 $taskUid = $task->getTaskUid();
246 if (!empty($taskUid)) {
247 try {
248 if ($task->getRunOnNextCronJob()) {
249 $executionTime = time();
250 } else {
251 $executionTime = $task->getNextDueExecution();
252 }
253 $task->setExecutionTime($executionTime);
254 } catch (\Exception $e) {
255 $task->setDisabled(true);
256 $executionTime = 0;
257 }
258 $task->unsetScheduler();
259 $fields = [
260 'nextexecution' => $executionTime,
261 'disable' => (int)$task->isDisabled(),
262 'description' => $task->getDescription(),
263 'task_group' => $task->getTaskGroup(),
264 'serialized_task_object' => serialize($task)
265 ];
266 $result = GeneralUtility::makeInstance(ConnectionPool::class)
267 ->getConnectionForTable('tx_scheduler_task')
268 ->update(
269 'tx_scheduler_task',
270 $fields,
271 ['uid' => $taskUid],
272 ['serialized_task_object' => Connection::PARAM_LOB]
273 );
274 } else {
275 $result = false;
276 }
277 return $result;
278 }
279
280 /**
281 * Fetches and unserializes a task object from the db. If an uid is given the object
282 * with the uid is returned, else the object representing the next due task is returned.
283 * If there are no due tasks the method throws an exception.
284 *
285 * @param int $uid Primary key of a task
286 * @return Task\AbstractTask The fetched task object
287 * @throws \OutOfBoundsException
288 * @throws \UnexpectedValueException
289 */
290 public function fetchTask($uid = 0)
291 {
292 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
293 $queryBuilder = $connectionPool->getQueryBuilderForTable('tx_scheduler_task');
294
295 $queryBuilder->select('t.uid', 't.serialized_task_object')
296 ->from('tx_scheduler_task', 't')
297 ->setMaxResults(1);
298 // Define where clause
299 // If no uid is given, take any non-disabled task which has a next execution time in the past
300 if (empty($uid)) {
301 $queryBuilder->getRestrictions()->removeAll();
302 $queryBuilder->leftJoin(
303 't',
304 'tx_scheduler_task_group',
305 'g',
306 $queryBuilder->expr()->eq('t.task_group', $queryBuilder->quoteIdentifier('g.uid'))
307 );
308 $queryBuilder->where(
309 $queryBuilder->expr()->eq('t.disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
310 $queryBuilder->expr()->neq('t.nextexecution', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
311 $queryBuilder->expr()->lte(
312 't.nextexecution',
313 $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
314 ),
315 $queryBuilder->expr()->orX(
316 $queryBuilder->expr()->eq('g.hidden', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)),
317 $queryBuilder->expr()->isNull('g.hidden')
318 )
319 );
320 } else {
321 $queryBuilder->where(
322 $queryBuilder->expr()->eq('t.uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
323 );
324 }
325
326 $row = $queryBuilder->execute()->fetch();
327 if ($row === false) {
328 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);
329 }
330 if (empty($row)) {
331 // If there are no available tasks, thrown an exception
332 throw new \OutOfBoundsException('No task', 1247827244);
333 }
334 /** @var $task Task\AbstractTask */
335 $task = unserialize($row['serialized_task_object']);
336 if ($this->isValidTaskObject($task)) {
337 // The task is valid, return it
338 $task->setScheduler();
339 } else {
340 // Forcibly set the disable flag to 1 in the database,
341 // so that the task does not come up again and again for execution
342 $connectionPool->getConnectionForTable('tx_scheduler_task')->update(
343 'tx_scheduler_task',
344 ['disable' => 1],
345 ['uid' => (int)$row['uid']]
346 );
347 // Throw an exception to raise the problem
348 throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
349 }
350
351 return $task;
352 }
353
354 /**
355 * This method is used to get the database record for a given task
356 * It returns the database record and not the task object
357 *
358 * @param int $uid Primary key of the task to get
359 * @return array Database record for the task
360 * @see \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
361 * @throws \OutOfBoundsException
362 */
363 public function fetchTaskRecord($uid)
364 {
365 $row = GeneralUtility::makeInstance(ConnectionPool::class)
366 ->getConnectionForTable('tx_scheduler_task')
367 ->select(['*'], 'tx_scheduler_task', ['uid' => (int)$uid])
368 ->fetch();
369
370 // If the task is not found, throw an exception
371 if (empty($row)) {
372 throw new \OutOfBoundsException('No task', 1247827245);
373 }
374
375 return $row;
376 }
377
378 /**
379 * Fetches and unserializes task objects selected with some (SQL) condition
380 * Objects are returned as an array
381 *
382 * @param string $where Part of a SQL where clause (without the "WHERE" keyword)
383 * @param bool $includeDisabledTasks TRUE if disabled tasks should be fetched too, FALSE otherwise
384 * @return array List of task objects
385 */
386 public function fetchTasksWithCondition($where, $includeDisabledTasks = false)
387 {
388 $tasks = [];
389 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
390 ->getQueryBuilderForTable('tx_scheduler_task');
391
392 $queryBuilder
393 ->select('serialized_task_object')
394 ->from('tx_scheduler_task')
395 ->where(
396 $queryBuilder->expr()->eq('deleted', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
397 );
398
399 if (!$includeDisabledTasks) {
400 $queryBuilder->andWhere(
401 $queryBuilder->expr()->eq('disable', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
402 );
403 }
404
405 if (!empty($where)) {
406 $queryBuilder->andWhere(QueryHelper::stripLogicalOperatorPrefix($where));
407 }
408
409 $result = $queryBuilder->execute();
410 while ($row = $result->fetch()) {
411 /** @var Task\AbstractTask $task */
412 $task = unserialize($row['serialized_task_object']);
413 // Add the task to the list only if it is valid
414 if ($this->isValidTaskObject($task)) {
415 $task->setScheduler();
416 $tasks[] = $task;
417 }
418 }
419
420 return $tasks;
421 }
422
423 /**
424 * This method encapsulates a very simple test for the purpose of clarity.
425 * Registered tasks are stored in the database along with a serialized task object.
426 * When a registered task is fetched, its object is unserialized.
427 * At that point, if the class corresponding to the object is not available anymore
428 * (e.g. because the extension providing it has been uninstalled),
429 * the unserialization will produce an incomplete object.
430 * This test checks whether the unserialized object is of the right (parent) class or not.
431 *
432 * @param object $task The object to test
433 * @return bool TRUE if object is a task, FALSE otherwise
434 */
435 public function isValidTaskObject($task)
436 {
437 return $task instanceof Task\AbstractTask;
438 }
439
440 /**
441 * This is a utility method that writes some message to the BE Log
442 * It could be expanded to write to some other log
443 *
444 * @param string $message The message to write to the log
445 * @param int $status Status (0 = message, 1 = error)
446 * @param mixed $code Key for the message
447 */
448 public function log($message, $status = 0, $code = 'scheduler')
449 {
450 // Log only if enabled
451 if (!empty($this->extConf['enableBELog'])) {
452 $GLOBALS['BE_USER']->writelog(4, 0, $status, 0, '[scheduler]: ' . $code . ' - ' . $message, []);
453 }
454 }
455 }