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