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