Scheduler.php 16.1 KB
Newer Older
1
2
3
<?php
namespace TYPO3\CMS\Scheduler;

4
/*
5
 * This file is part of the TYPO3 CMS project.
6
 *
7
8
9
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
10
 *
11
12
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
13
 *
14
15
 * The TYPO3 project - inspiring people to share!
 */
16

17
use TYPO3\CMS\Core\Registry;
18
use TYPO3\CMS\Core\Utility\CommandUtility;
19
20
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
21

22
23
24
25
/**
 * TYPO3 Scheduler. This class handles scheduling and execution of tasks.
 * Formerly known as "Gabriel TYPO3 arch angel"
 *
26
27
 * @author François Suter <francois@typo3.org>
 * @author Christian Jul Jensen <julle@typo3.org>
28
29
30
31
 */
class Scheduler implements \TYPO3\CMS\Core\SingletonInterface {

	/**
32
	 * @var array $extConf Settings from the extension manager
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
	 */
	public $extConf = array();

	/**
	 * Constructor, makes sure all derived client classes are included
	 *
	 * @return \TYPO3\CMS\Scheduler\Scheduler
	 */
	public function __construct() {
		// Get configuration from the extension manager
		$this->extConf = unserialize($GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf']['scheduler']);
		if (empty($this->extConf['maxLifetime'])) {
			$this->extConf['maxLifetime'] = 1440;
		}
		if (empty($this->extConf['useAtdaemon'])) {
			$this->extConf['useAtdaemon'] = 0;
		}
		// Clean up the serialized execution arrays
		$this->cleanExecutionArrays();
	}

	/**
	 * Adds a task to the pool
	 *
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
62
63
64
65
		$taskUid = $task->getTaskUid();
		if (empty($taskUid)) {
			$fields = array(
				'crdate' => $GLOBALS['EXEC_TIME'],
				'disable' => $task->isDisabled(),
66
				'description' => $task->getDescription(),
67
				'task_group' => $task->getTaskGroup(),
68
69
				'serialized_task_object' => 'RESERVED'
			);
70
			$result = $this->getDatabaseConnection()->exec_INSERTquery('tx_scheduler_task', $fields);
71
			if ($result) {
72
				$task->setTaskUid($this->getDatabaseConnection()->sql_insert_id());
73
74
75
76
77
78
79
80
81
82
83
84
85
				$task->save();
				$result = TRUE;
			} else {
				$result = FALSE;
			}
		} else {
			$result = FALSE;
		}
		return $result;
	}

	/**
	 * 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
89
90
91
	 *
	 * @return void
	 */
	protected function cleanExecutionArrays() {
		$tstamp = $GLOBALS['EXEC_TIME'];
92
		$db = $this->getDatabaseConnection();
93
94
95
		// Select all tasks with executions
		// NOTE: this cleanup is done for disabled tasks too,
		// 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
101
102
103
104
			if ($serialized_executions = unserialize($row['serialized_executions'])) {
				foreach ($serialized_executions as $task) {
					if ($tstamp - $task < $maxDuration) {
						$executions[] = $task;
					} 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
108
109
110
111
112
113
114
115
116
						$this->log($logMessage);
					}
				}
			}
			if (count($serialized_executions) != count($executions)) {
				if (count($executions) == 0) {
					$value = '';
				} else {
					$value = serialize($executions);
				}
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
125
126
	}

	/**
	 * This method executes the given task and properly marks and records that execution
	 * It is expected to return FALSE if the task was barred from running or if it was not saved properly
	 *
127
	 * @param Task\AbstractTask $task The task to execute
128
	 * @return bool Whether the task was saved successfully to the database or not
129
130
	 * @throws FailedExecutionException
	 * @throws \Exception
131
	 */
132
	public function executeTask(Task\AbstractTask $task) {
133
134
135
136
137
138
139
140
141
142
143
		// Trigger the saving of the task, as this will calculate its next execution time
		// This should be calculated all the time, even if the execution is skipped
		// (in case it is skipped, this pushes back execution to the next possible date)
		$task->save();
		// Set a scheduler object for the task again,
		// as it was removed during the save operation
		$task->setScheduler();
		$result = TRUE;
		// Task is already running and multiple executions are not allowed
		if (!$task->areMultipleExecutionsAllowed() && $task->isExecutionRunning()) {
			// 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
146
147
148
			$this->log($logMessage);
			$result = FALSE;
		} else {
			// Log scheduler invocation
149
			$logMessage = 'Start execution. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
150
151
152
153
154
155
156
157
			$this->log($logMessage);
			// Register execution
			$executionID = $task->markExecution();
			$failure = NULL;
			try {
				// Execute task
				$successfullyExecuted = $task->execute();
				if (!$successfullyExecuted) {
158
					throw new FailedExecutionException('Task failed to execute successfully. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid(), 1250596541);
159
160
161
162
163
164
165
166
				}
			} catch (\Exception $e) {
				// Store exception, so that it can be saved to database
				$failure = $e;
			}
			// Un-register execution
			$task->unmarkExecution($executionID, $failure);
			// Log completion of execution
167
			$logMessage = 'Task executed. Class: ' . get_class($task) . ', UID: ' . $task->getTaskUid();
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
			$this->log($logMessage);
			// Now that the result of the task execution has been handled,
			// throw the exception again, if any
			if ($failure instanceof \Exception) {
				throw $failure;
			}
		}
		return $result;
	}

	/**
	 * This method stores information about the last run of the Scheduler into the system registry
	 *
	 * @param string $type Type of run (manual or command-line (assumed to be cron))
	 * @return void
	 */
	public function recordLastRun($type = 'cron') {
		// Validate input value
		if ($type !== 'manual' && $type !== 'cli-by-id') {
			$type = 'cron';
		}
189
190
		/** @var Registry $registry */
		$registry = GeneralUtility::makeInstance(Registry::class);
191
192
193
194
195
196
		$runInformation = array('start' => $GLOBALS['EXEC_TIME'], 'end' => time(), 'type' => $type);
		$registry->set('tx_scheduler', 'lastRun', $runInformation);
	}

	/**
	 * 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
205
		$taskUid = $task->getTaskUid();
		if (!empty($taskUid)) {
206
			$result = $this->getDatabaseConnection()->exec_DELETEquery('tx_scheduler_task', 'uid = ' . $taskUid);
207
208
209
210
211
212
213
214
215
216
217
218
		} else {
			$result = FALSE;
		}
		if ($result) {
			$this->scheduleNextSchedulerRunUsingAtDaemon();
		}
		return $result;
	}

	/**
	 * Updates a task in the pool
	 *
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
224
225
226
227
228
229
230
231
232
233
234
235
		$taskUid = $task->getTaskUid();
		if (!empty($taskUid)) {
			try {
				$executionTime = $task->getNextDueExecution();
				$task->setExecutionTime($executionTime);
			} catch (\Exception $e) {
				$task->setDisabled(TRUE);
				$executionTime = 0;
			}
			$task->unsetScheduler();
			$fields = array(
				'nextexecution' => $executionTime,
				'disable' => $task->isDisabled(),
236
				'description' => $task->getDescription(),
237
				'task_group' => $task->getTaskGroup(),
238
239
				'serialized_task_object' => serialize($task)
			);
240
			$result = $this->getDatabaseConnection()->exec_UPDATEquery('tx_scheduler_task', 'uid = ' . $taskUid, $fields);
241
242
243
244
245
246
247
248
249
250
251
252
253
254
		} else {
			$result = FALSE;
		}
		if ($result) {
			$this->scheduleNextSchedulerRunUsingAtDaemon();
		}
		return $result;
	}

	/**
	 * Fetches and unserializes a task object from the db. If an uid is given the object
	 * with the uid is returned, else the object representing the next due task is returned.
	 * If there are no due tasks the method throws an exception.
	 *
255
	 * @param int $uid Primary key of a task
256
	 * @return Task\AbstractTask The fetched task object
257
258
	 * @throws \OutOfBoundsException
	 * @throws \UnexpectedValueException
259
260
261
262
263
	 */
	public function fetchTask($uid = 0) {
		// Define where clause
		// If no uid is given, take any non-disabled task which has a next execution time in the past
		if (empty($uid)) {
264
265
			$queryArray = array(
				'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
268
269
				'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)',
				'LIMIT' => 1
			);
270
		} else {
271
272
273
			$queryArray = array(
				'SELECT' => 'uid, serialized_task_object',
				'FROM' => 'tx_scheduler_task',
274
				'WHERE' => 'uid = ' . (int)$uid,
275
276
				'LIMIT' => 1
			);
277
		}
278

279
280
		$db = $this->getDatabaseConnection();
		$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
287
			throw new \OutOfBoundsException('No task', 1247827244);
		} else {
288
289
			$row = $db->sql_fetch_assoc($res);
			/** @var $task Task\AbstractTask */
290
291
292
293
294
295
296
			$task = unserialize($row['serialized_task_object']);
			if ($this->isValidTaskObject($task)) {
				// The task is valid, return it
				$task->setScheduler();
			} else {
				// Forcibly set the disable flag to 1 in the database,
				// 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
299
300
				// Throw an exception to raise the problem
				throw new \UnexpectedValueException('Could not unserialize task', 1255083671);
			}
301
			$db->sql_free_result($res);
302
303
304
305
306
307
308
309
		}
		return $task;
	}

	/**
	 * This method is used to get the database record for a given task
	 * It returns the database record and not the task object
	 *
310
	 * @param int $uid Primary key of the task to get
311
	 * @return array Database record for the task
312
313
	 * @see \TYPO3\CMS\Scheduler\Scheduler::fetchTask()
	 * @throws \OutOfBoundsException
314
315
	 */
	public function fetchTaskRecord($uid) {
316
317
		$db = $this->getDatabaseConnection();
		$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
323
			$row = $db->sql_fetch_assoc($res);
			$db->sql_free_result($res);
324
325
326
327
328
329
330
331
332
		}
		return $row;
	}

	/**
	 * Fetches and unserializes task objects selected with some (SQL) condition
	 * Objects are returned as an array
	 *
	 * @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
335
336
337
338
339
340
341
342
343
344
345
346
347
	 * @return array List of task objects
	 */
	public function fetchTasksWithCondition($where, $includeDisabledTasks = FALSE) {
		$whereClause = '';
		$tasks = array();
		if (!empty($where)) {
			$whereClause = $where;
		}
		if (!$includeDisabledTasks) {
			if (!empty($whereClause)) {
				$whereClause .= ' AND ';
			}
			$whereClause .= 'disable = 0';
		}
348
349
		$db = $this->getDatabaseConnection();
		$res = $db->exec_SELECTquery('serialized_task_object', 'tx_scheduler_task', $whereClause);
350
		if ($res) {
351
352
			while ($row = $db->sql_fetch_assoc($res)) {
				/** @var Task\AbstractTask $task */
353
354
355
356
357
358
359
				$task = unserialize($row['serialized_task_object']);
				// Add the task to the list only if it is valid
				if ($this->isValidTaskObject($task)) {
					$task->setScheduler();
					$tasks[] = $task;
				}
			}
360
			$db->sql_free_result($res);
361
362
363
364
365
366
367
368
369
370
371
372
373
374
		}
		return $tasks;
	}

	/**
	 * This method encapsulates a very simple test for the purpose of clarity.
	 * Registered tasks are stored in the database along with a serialized task object.
	 * When a registered task is fetched, its object is unserialized.
	 * At that point, if the class corresponding to the object is not available anymore
	 * (e.g. because the extension providing it has been uninstalled),
	 * the unserialization will produce an incomplete object.
	 * This test checks whether the unserialized object is of the right (parent) class or not.
	 *
	 * @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
383
384
385
	}

	/**
	 * This is a utility method that writes some message to the BE Log
	 * It could be expanded to write to some other log
	 *
	 * @param string $message The message to write to the log
386
	 * @param int $status Status (0 = message, 1 = error)
387
388
389
390
391
392
393
394
395
396
397
398
399
400
	 * @param mixed $code Key for the message
	 * @return void
	 */
	public function log($message, $status = 0, $code = 'scheduler') {
		// Log only if enabled
		if (!empty($this->extConf['enableBELog'])) {
			$GLOBALS['BE_USER']->writelog(4, 0, $status, $code, '[scheduler]: ' . $message, array());
		}
	}

	/**
	 * Schedule the next run of scheduler
	 * For the moment only the "at"-daemon is used, and only if it is enabled
	 *
401
	 * @return bool Successfully scheduled next execution using "at"-daemon
402
403
404
	 * @see tx_scheduler::fetchTask()
	 */
	public function scheduleNextSchedulerRunUsingAtDaemon() {
405
		if ((int)$this->extConf['useAtdaemon'] !== 1) {
406
407
			return FALSE;
		}
408
409
		/** @var $registry Registry */
		$registry = GeneralUtility::makeInstance(Registry::class);
410
411
		// Get at job id from registry and remove at job
		$atJobId = $registry->get('tx_scheduler', 'atJobId');
412
		if (MathUtility::canBeInterpretedAsInteger($atJobId)) {
413
			shell_exec('atrm ' . (int)$atJobId . ' 2>&1');
414
415
416
417
418
419
		}
		// Can not use fetchTask() here because if tasks have just executed
		// they are not in the list of next executions
		$tasks = $this->fetchTasksWithCondition('');
		$nextExecution = FALSE;
		foreach ($tasks as $task) {
420
			try {
421
				/** @var $task Task\AbstractTask */
422
423
424
425
426
427
428
				$tempNextExecution = $task->getNextDueExecution();
				if ($nextExecution === FALSE || $tempNextExecution < $nextExecution) {
					$nextExecution = $tempNextExecution;
				}
			} catch (\OutOfBoundsException $e) {
				// The event will not be executed again or has already ended - we don't have to consider it for
				// scheduling the next "at" run
429
430
431
432
433
434
435
436
437
			}
		}
		if ($nextExecution !== FALSE) {
			if ($nextExecution > $GLOBALS['EXEC_TIME']) {
				$startTime = strftime('%H:%M %F', $nextExecution);
			} else {
				$startTime = 'now+1minute';
			}
			$cliDispatchPath = PATH_site . 'typo3/cli_dispatch.phpsh';
438
439
440
			list($cliDispatchPathEscaped, $startTimeEscaped) =
				CommandUtility::escapeShellArguments(array($cliDispatchPath, $startTime));
			$cmd = 'echo ' . $cliDispatchPathEscaped . ' scheduler | at ' . $startTimeEscaped . ' 2>&1';
441
442
443
			$output = shell_exec($cmd);
			$outputParts = '';
			foreach (explode(LF, $output) as $outputLine) {
444
				if (GeneralUtility::isFirstPartOfStr($outputLine, 'job')) {
445
446
447
448
					$outputParts = explode(' ', $outputLine, 3);
					break;
				}
			}
449
			if ($outputParts[0] === 'job' && MathUtility::canBeInterpretedAsInteger($outputParts[1])) {
450
				$atJobId = (int)$outputParts[1];
451
452
453
454
455
456
				$registry->set('tx_scheduler', 'atJobId', $atJobId);
			}
		}
		return TRUE;
	}

457
458
459
460
461
462
	/**
	 * @return \TYPO3\CMS\Core\Database\DatabaseConnection
	 */
	protected function getDatabaseConnection() {
		return $GLOBALS['TYPO3_DB'];
	}
463
}