Passed
Pull Request — master (#408)
by
unknown
03:52
created

CronManager::loadTasks()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 9
nc 5
nop 0
dl 0
loc 19
rs 9.2222
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * Quantum PHP Framework
5
 *
6
 * An open source software development framework for PHP
7
 *
8
 * @package Quantum
9
 * @author Arman Ag. <[email protected]>
10
 * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org)
11
 * @link http://quantum.softberg.org/
12
 * @since 3.0.0
13
 */
14
15
namespace Quantum\Libraries\Cron;
16
17
use Quantum\Libraries\Cron\Contracts\CronTaskInterface;
18
use Quantum\Libraries\Cron\Exceptions\CronException;
19
use Quantum\Libraries\Logger\Factories\LoggerFactory;
20
21
/**
22
 * Class CronManager
23
 * @package Quantum\Libraries\Cron
24
 */
25
class CronManager
26
{
27
    /**
28
     * Loaded tasks
29
     * @var array<string, CronTaskInterface>
30
     */
31
    private $tasks = [];
32
33
    /**
34
     * Cron directory path
35
     * @var string
36
     */
37
    private $cronDirectory;
38
39
    /**
40
     * Execution statistics
41
     * @var array
42
     */
43
    private $stats = [
44
        'total' => 0,
45
        'executed' => 0,
46
        'skipped' => 0,
47
        'failed' => 0,
48
        'locked' => 0,
49
    ];
50
51
    /**
52
     * CronManager constructor
53
     * @param string|null $cronDirectory
54
     */
55
    public function __construct(?string $cronDirectory = null)
56
    {
57
        $this->cronDirectory = $cronDirectory ?? $this->getDefaultCronDirectory();
58
    }
59
60
    /**
61
     * Load tasks from cron directory
62
     * @return void
63
     * @throws CronException
64
     */
65
    public function loadTasks(): void
66
    {
67
        if (!is_dir($this->cronDirectory)) {
68
            return;
69
        }
70
71
        $files = scandir($this->cronDirectory);
72
73
        foreach ($files as $file) {
74
            if ($file === '.' || $file === '..') {
75
                continue;
76
            }
77
78
            if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
79
                $this->loadTaskFromFile($this->cronDirectory . DIRECTORY_SEPARATOR . $file);
80
            }
81
        }
82
83
        $this->stats['total'] = count($this->tasks);
84
    }
85
86
    /**
87
     * Load a single task from file
88
     * @param string $file
89
     * @return void
90
     * @throws CronException
91
     */
92
    private function loadTaskFromFile(string $file): void
93
    {
94
        $task = require $file;
95
96
        if (is_array($task)) {
97
            $task = $this->createTaskFromArray($task);
98
        }
99
100
        if (!$task instanceof CronTaskInterface) {
101
            throw CronException::invalidTaskFile($file);
102
        }
103
104
        $this->tasks[$task->getName()] = $task;
105
    }
106
107
    /**
108
     * Create task from array definition
109
     * @param array $definition
110
     * @return CronTask
111
     * @throws CronException
112
     */
113
    private function createTaskFromArray(array $definition): CronTask
114
    {
115
        if (!isset($definition['name'], $definition['expression'], $definition['callback'])) {
116
            throw new CronException('Task definition must contain name, expression, and callback');
117
        }
118
119
        return new CronTask(
120
            $definition['name'],
121
            $definition['expression'],
122
            $definition['callback']
123
        );
124
    }
125
126
    /**
127
     * Run all due tasks
128
     * @param bool $force Ignore locks
129
     * @return array Statistics
130
     */
131
    public function runDueTasks(bool $force = false): array
132
    {
133
        $this->loadTasks();
134
135
        foreach ($this->tasks as $task) {
136
            if ($task->shouldRun()) {
137
                $this->runTask($task, $force);
138
            } else {
139
                $this->stats['skipped']++;
140
            }
141
        }
142
143
        return $this->stats;
144
    }
145
146
    /**
147
     * Run a specific task by name
148
     * @param string $taskName
149
     * @param bool $force Ignore locks
150
     * @return void
151
     * @throws CronException
152
     */
153
    public function runTaskByName(string $taskName, bool $force = false): void
154
    {
155
        $this->loadTasks();
156
157
        if (!isset($this->tasks[$taskName])) {
158
            throw CronException::taskNotFound($taskName);
159
        }
160
161
        $this->runTask($this->tasks[$taskName], $force);
162
    }
163
164
    /**
165
     * Run a single task
166
     * @param CronTaskInterface $task
167
     * @param bool $force Ignore locks
168
     * @return void
169
     */
170
    private function runTask(CronTaskInterface $task, bool $force = false): void
171
    {
172
        $lock = new CronLock($task->getName());
173
174
        if (!$force && $lock->isLocked()) {
175
            $this->stats['locked']++;
176
            $this->log('warning', "Task \"{$task->getName()}\" skipped: locked");
177
            return;
178
        }
179
180
        if (!$force && !$lock->acquire()) {
181
            $this->stats['locked']++;
182
            $this->log('warning', "Task \"{$task->getName()}\" skipped: failed to acquire lock");
183
            return;
184
        }
185
186
        $startTime = microtime(true);
187
        $this->log('info', "Task \"{$task->getName()}\" started");
188
189
        try {
190
            $task->handle();
191
            $duration = round(microtime(true) - $startTime, 2);
192
            $this->stats['executed']++;
193
            $this->log('info', "Task \"{$task->getName()}\" completed in {$duration}s");
194
        } catch (\Throwable $e) {
195
            $this->stats['failed']++;
196
            $this->log('error', "Task \"{$task->getName()}\" failed: " . $e->getMessage(), [
197
                'exception' => get_class($e),
198
                'file' => $e->getFile(),
199
                'line' => $e->getLine(),
200
            ]);
201
        } finally {
202
            if (!$force) {
203
                $lock->release();
204
            }
205
        }
206
    }
207
208
    /**
209
     * Get all loaded tasks
210
     * @return array<string, CronTaskInterface>
211
     */
212
    public function getTasks(): array
213
    {
214
        return $this->tasks;
215
    }
216
217
    /**
218
     * Get execution statistics
219
     * @return array
220
     */
221
    public function getStats(): array
222
    {
223
        return $this->stats;
224
    }
225
226
    /**
227
     * Get default cron directory
228
     * @return string
229
     */
230
    private function getDefaultCronDirectory(): string
231
    {
232
        return base_dir() . DIRECTORY_SEPARATOR . 'cron';
233
    }
234
235
    /**
236
     * Log a message
237
     * @param string $level
238
     * @param string $message
239
     * @param array $context
240
     * @return void
241
     */
242
    private function log(string $level, string $message, array $context = []): void
243
    {
244
        try {
245
            $logger = LoggerFactory::get();
246
            $logger->log($level, '[CRON] ' . $message, $context);
247
        } catch (\Throwable $exception) {
248
            error_log(sprintf('[CRON] [%s] %s', strtoupper($level), $message));
249
        }
250
    }
251
}
252