Passed
Push — 5.x ( 88ff80...0e9d8a )
by Jeroen
12:39 queued 15s
created

Cron::logCompletion()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3.1406

Importance

Changes 0
Metric Value
cc 3
eloc 12
nc 3
nop 1
dl 0
loc 17
ccs 9
cts 12
cp 0.75
crap 3.1406
rs 9.8666
c 0
b 0
f 0
1
<?php
2
3
namespace Elgg;
4
5
use Elgg\Exceptions\CronException;
6
use Elgg\I18n\DateTime;
7
use Elgg\I18n\Translator;
8
use Elgg\Traits\Loggable;
9
use Elgg\Traits\TimeUsing;
10
use GO\Job;
11
use GO\Scheduler;
12
13
/**
14
 * Cron
15
 *
16
 * @internal
17
 */
18
class Cron {
19
20
	use Loggable;
21
	use TimeUsing;
22
23
	protected const LOG_FILES_TO_KEEP = 5;
24
	
25
	protected array $default_intervals = [
26
		'minute' => '* * * * *',
27
		'fiveminute' => '*/5 * * * *',
28
		'fifteenmin' => '*/15 * * * *',
29
		'halfhour' => '*/30 * * * *',
30
		'hourly' => '0 * * * *',
31
		'daily' => '0 0 * * *',
32
		'weekly' => '0 0 * * 0',
33
		'monthly' => '0 0 1 * *',
34
		'yearly' => '0 0 1 1 *',
35
	];
36
37
	protected EventsService $events;
38
39
	protected Translator $translator;
40
41
	/**
42
	 * Constructor
43
	 *
44
	 * @param EventsService $events     Events service
45
	 * @param Translator    $translator Translator service
46
	 */
47 12
	public function __construct(EventsService $events, Translator $translator) {
48 12
		$this->events = $events;
49 12
		$this->translator = $translator;
50
	}
51
52
	/**
53
	 * Executes handlers for periods that have elapsed since last cron
54
	 *
55
	 * @param null|array $intervals Interval names to run (default: all cron intervals)
56
	 * @param bool       $force     Force cron jobs to run even they are not yet due
57
	 *
58
	 * @return Job[]
59
	 * @throws CronException
60
	 */
61 10
	public function run(array $intervals = null, bool $force = false): array {
62 10
		if (!isset($intervals)) {
63 4
			$intervals = array_keys($this->default_intervals);
64
		}
65
		
66 10
		$allowed_intervals = $this->getConfiguredIntervals();
67
		
68 10
		$scheduler = new Scheduler();
69 10
		$time = $this->getCurrentTime();
70
71 10
		foreach ($intervals as $interval) {
72 10
			if (!array_key_exists($interval, $allowed_intervals)) {
73 1
				throw new CronException("{$interval} is not a recognized cron interval");
74
			}
75
76 9
			$cron_interval = $force ? $allowed_intervals['minute'] : $allowed_intervals[$interval];
77 9
			$filename = $this->getLogFilename($interval, $time);
78
			
79 9
			$cron_logger = \Elgg\Logger\Cron::factory([
80 9
				'interval' => $interval,
81 9
				'filename' => $filename,
82 9
			]);
83
			
84 9
			$scheduler
85 9
				->call(function () use ($interval, $time, $cron_logger, $filename) {
86 9
					return $this->execute($interval, $cron_logger, $filename, $time);
87 9
				})
88 9
				->at($cron_interval)
89 9
				->before(function () use ($interval, $time, $cron_logger) {
90 9
					$this->before($interval, $cron_logger, $time);
91 9
				})
92 9
				->then(function ($output) use ($interval, $cron_logger) {
93 9
					$this->after($output, $interval, $cron_logger);
94 9
				});
95
		}
96
97 9
		return $scheduler->run($time);
98
	}
99
100
	/**
101
	 * Execute commands before cron interval is run
102
	 *
103
	 * @param string            $interval    Interval name
104
	 * @param \Elgg\Logger\Cron $cron_logger Cron logger
105
	 * @param null|\DateTime    $time        Time of the cron initialization (default: current service time)
106
	 *
107
	 * @return void
108
	 */
109 9
	protected function before(string $interval, \Elgg\Logger\Cron $cron_logger, \DateTime $time = null): void {
110 9
		if (!isset($time)) {
111
			$time = $this->getCurrentTime();
112
		}
113
114
		try {
115 9
			$this->events->triggerBefore('cron', $interval, $time);
116
		} catch (\Throwable $t) {
117
			$this->getLogger()->error($t);
118
		}
119
120
		// give every period at least 'max_execution_time' (PHP ini setting)
121 9
		set_time_limit((int) ini_get('max_execution_time'));
122
		
123 9
		$now = new DateTime();
124
		
125 9
		$cron_logger->notice($this->translator->translate('admin:cron:started', [$interval, $time->format(DATE_RFC2822)]));
126 9
		$cron_logger->notice($this->translator->translate('admin:cron:started:actual', [$interval, $now->format(DATE_RFC2822)]));
127
	}
128
129
	/**
130
	 * Execute handlers attached to a specific cron interval
131
	 *
132
	 * @param string            $interval    Cron interval to execute
133
	 * @param \Elgg\Logger\Cron $cron_logger Cron logger
134
	 * @param string            $filename    Filename of the cron log
135
	 * @param null|\DateTime    $time        Time of cron initialization (default: current service time)
136
	 *
137
	 * @return string
138
	 */
139 9
	protected function execute(string $interval, \Elgg\Logger\Cron $cron_logger, string $filename, \DateTime $time = null): string {
140 9
		if (!isset($time)) {
141
			$time = $this->getCurrentTime();
142
		}
143
		
144
		try {
145 9
			ob_start();
146
			
147 9
			$begin_callback = function (array $params) use ($cron_logger) {
148 4
				$readable_callable = (string) elgg_extract('readable_callable', $params);
149
				
150 4
				$cron_logger->notice("Starting {$readable_callable}");
151 9
			};
152
			
153 9
			$end_callback = function (array $params) use ($cron_logger) {
154 4
				$readable_callable = (string) elgg_extract('readable_callable', $params);
155
				
156 4
				$cron_logger->notice("Finished {$readable_callable}");
157 9
			};
158
			
159 9
			$old_stdout = $this->events->triggerResults('cron', $interval, [
160 9
				'time' => $time->getTimestamp(),
161 9
				'dt' => $time,
162 9
				'logger' => $cron_logger,
163 9
			], '', [
164 9
				EventsService::OPTION_BEGIN_CALLBACK => $begin_callback,
165 9
				EventsService::OPTION_END_CALLBACK => $end_callback,
166 9
			]);
167
			
168 9
			$ob_output = ob_get_clean();
169
			
170 9
			if (!empty($ob_output)) {
171 4
				elgg_deprecated_notice('Direct output (echo, print) in a CRON event will be removed, use the provided "logger"', '5.1');
172
				
173 4
				$cron_logger->notice($ob_output, ['ob_output']);
174
			}
175
			
176 9
			if (!empty($old_stdout)) {
177
				elgg_deprecated_notice('Output in a CRON event result will be removed, use the provided "logger"', '5.1');
178
				
179 9
				$cron_logger->notice($old_stdout, ['event_result']);
180
			}
181
		} catch (\Throwable $t) {
182
			$ob_output = ob_get_clean();
183
			
184
			if (!empty($ob_output)) {
185
				elgg_deprecated_notice('Direct output (echo, print) in a CRON event will be removed, use the provided "logger"', '5.1');
186
				
187
				$cron_logger->notice($ob_output, ['ob_output', 'throwable']);
188
			}
189
			
190
			$this->getLogger()->error($t);
191
		}
192
193 9
		$now = new DateTime();
194
195 9
		$complete = $this->translator->translate('admin:cron:complete', [$interval, $now->format(DATE_RFC2822)]);
196 9
		$cron_logger->notice($complete);
197
		
198 9
		if (file_exists($filename) && is_readable($filename)) {
199 9
			return file_get_contents($filename);
200
		}
201
		
202
		return '';
203
	}
204
205
	/**
206
	 * Printers handler result
207
	 *
208
	 * @param string            $output      Output string
209
	 * @param string            $interval    Interval name
210
	 * @param \Elgg\Logger\Cron $cron_logger Cron logger
211
	 *
212
	 * @return void
213
	 */
214 9
	protected function after(string $output, string $interval, \Elgg\Logger\Cron $cron_logger): void {
215 9
		$this->getLogger()->info($output);
216
		
217
		try {
218 9
			$this->events->triggerAfter('cron', $interval, new \DateTime());
219
		} catch (\Throwable $t) {
220
			$this->getLogger()->error($t);
221
		}
222
		
223 9
		$cron_logger->close();
224 9
		$this->rotateLogs($interval);
225 9
		$this->logCompletion($interval);
226
	}
227
	
228
	/**
229
	 * Get the log files for a given cron interval
230
	 *
231
	 * The results are sorted so the newest log is the first in the array
232
	 *
233
	 * @param string $interval       cron interval
234
	 * @param bool   $filenames_only only return the filenames (default: false)
235
	 *
236
	 * @return array
237
	 */
238 11
	public function getLogs(string $interval, bool $filenames_only = false): array {
239 11
		$fh = new \ElggFile();
240 11
		$fh->owner_guid = elgg_get_site_entity()->guid;
241 11
		$fh->setFilename("cron/{$interval}/dummy.log");
242
		
243 11
		$dir = pathinfo($fh->getFilenameOnFilestore(), PATHINFO_DIRNAME);
244 11
		if (!is_dir($dir) || !is_readable($dir)) {
2 ignored issues
show
Bug introduced by
It seems like $dir can also be of type array; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

244
		if (!is_dir(/** @scrutinizer ignore-type */ $dir) || !is_readable($dir)) {
Loading history...
Bug introduced by
It seems like $dir can also be of type array; however, parameter $filename of is_readable() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

244
		if (!is_dir($dir) || !is_readable(/** @scrutinizer ignore-type */ $dir)) {
Loading history...
245 2
			return [];
246
		}
247
		
248 9
		$dh = new \DirectoryIterator($dir);
1 ignored issue
show
Bug introduced by
It seems like $dir can also be of type array; however, parameter $directory of DirectoryIterator::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

248
		$dh = new \DirectoryIterator(/** @scrutinizer ignore-type */ $dir);
Loading history...
249 9
		$files = [];
250
		/* @var $file \DirectoryIterator */
251 9
		foreach ($dh as $file) {
252 9
			if ($file->isDot() || !$file->isFile()) {
253 9
				continue;
254
			}
255
			
256 9
			if ($filenames_only) {
257 9
				$files[] = $file->getFilename();
258
			} else {
259
				$files[$file->getFilename()] = file_get_contents($file->getPathname());
260
			}
261
		}
262
		
263 9
		if ($filenames_only) {
264 9
			natcasesort($files);
265
		} else {
266
			uksort($files, 'strnatcasecmp');
267
		}
268
		
269 9
		return array_reverse($files);
270
	}
271
	
272
	/**
273
	 * Get the time of the last completion of a cron interval
274
	 *
275
	 * @param string $interval cron interval
276
	 *
277
	 * @return null|DateTime
278
	 */
279 2
	public function getLastCompletion(string $interval): ?DateTime {
280 2
		$fh = new \ElggFile();
281 2
		$fh->owner_guid = elgg_get_site_entity()->guid;
282 2
		$fh->setFilename("cron/{$interval}.complete");
283
		
284 2
		if (!$fh->exists()) {
285 2
			return null;
286
		}
287
		
288
		$date = $fh->grabFile();
289
		if (empty($date)) {
290
			// how??
291
			return null;
292
		}
293
		
294
		try {
295
			return Values::normalizeTime($date);
296
		} catch (\Elgg\Exceptions\ExceptionInterface $e) {
297
			$this->getLogger()->warning($e);
298
		}
299
		
300
		return null;
301
	}
302
	
303
	/**
304
	 * Get the cron interval configuration
305
	 *
306
	 * @param bool $only_names Only return the names of the intervals
307
	 *
308
	 * @return array
309
	 * @since 3.2
310
	 */
311 12
	public function getConfiguredIntervals(bool $only_names = false): array {
312 12
		$result = $this->events->triggerResults('cron:intervals', 'system', [], $this->default_intervals);
313 12
		if (!is_array($result)) {
314
			$this->getLogger()->warning("The event 'cron:intervals', 'system' should return an array, " . gettype($result) . ' given');
315
			
316
			$result = $this->default_intervals;
317
		}
318
		
319 12
		if ($only_names) {
320 11
			return array_keys($result);
321
		}
322
		
323 10
		return $result;
324
	}
325
	
326
	/**
327
	 * Get a filename to log in
328
	 *
329
	 * @param string         $interval cron interval to log
330
	 * @param \DateTime|null $time     start time of the cron
331
	 *
332
	 * @return string
333
	 */
334 9
	protected function getLogFilename(string $interval, \DateTime $time = null): string {
335 9
		if (!isset($time)) {
336
			$time = $this->getCurrentTime();
337
		}
338
		
339 9
		$date = $time->format(\DateTimeInterface::ATOM);
340 9
		$date = str_replace('+', 'p', $date);
341 9
		$date = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $date);
342
		
343 9
		$fh = new \ElggFile();
344 9
		$fh->owner_guid = elgg_get_site_entity()->guid;
345 9
		$fh->setFilename("cron/{$interval}/{$date}.log");
346
		
347 9
		return $fh->getFilenameOnFilestore();
348
	}
349
	
350
	/**
351
	 * Rotate the log files
352
	 *
353
	 * @param string $interval cron interval
354
	 *
355
	 * @return void
356
	 */
357 9
	protected function rotateLogs(string $interval): void {
358 9
		$files = $this->getLogs($interval, true);
359 9
		if (count($files) <= self::LOG_FILES_TO_KEEP) {
360 9
			return;
361
		}
362
		
363
		$fh = new \ElggFile();
364
		$fh->owner_guid = elgg_get_site_entity()->guid;
365
		
366
		while (count($files) > self::LOG_FILES_TO_KEEP) {
367
			$filename = array_pop($files);
368
			
369
			$fh->setFilename("cron/{$interval}/{$filename}");
370
			$fh->delete();
371
		}
372
	}
373
	
374
	/**
375
	 * Log the completion time of a cron interval
376
	 *
377
	 * @param string $interval cron interval
378
	 *
379
	 * @return void
380
	 */
381 9
	protected function logCompletion(string $interval): void {
382 9
		$fh = new \ElggFile();
383 9
		$fh->owner_guid = elgg_get_site_entity()->guid;
384 9
		$fh->setFilename("cron/{$interval}.complete");
385
		
386
		try {
387 9
			if ($fh->open('write') === false) {
388 9
				return;
389
			}
390
		} catch (\Elgg\Exceptions\ExceptionInterface $e) {
391
			$this->getLogger()->warning($e);
392
			return;
393
		}
394
		
395 9
		$now = new DateTime();
396 9
		$fh->write($now->format(\DateTimeInterface::ATOM));
397 9
		$fh->close();
398
	}
399
}
400