master
ブランチ master
LittleBeast / src / Std / Lib / Cron.php
15.3 KB、411行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib;
39
40use Result;
41use LogType;
42use Std\Lib\CronTab;
43
44/**
45 * 定期的なタスクの実行(cron)
46 *
47 * ジョブはdata/cron/cron.jsonに保存し、タスクはコードで名前に登録する:
48 * $cron = new Cron();
49 * $cron->register('apfederate', fn() => …);
50 * $cron->add(CronTab::fromString('0 * * * * apfederate'));
51 * $cron->run(); // システムのcrontabから毎分呼ぶ(cron.php)
52 *
53 * run()は前回の実行から今までに予定されていたジョブを1回ずつ実行する。
54 * 同じ分に2回呼ばれても2重に実行せず、前の実行がまだ終わっていなければ何もしない。
55 * サーバーが止まっていた等で遅れた場合は、最大CATCH_UPの分だけ遡る。
56 */
57class Cron {
58 /** 遅れた時に遡る最大時間(秒) */
59 public const CATCH_UP = 3600;
60
61 private string $dataDir;
62 private string $dataFile;
63 private string $stateFile;
64 private string $lockFile;
65
66 /** @var array タスク名 => callable */
67 private array $tasks = [];
68
69 /**
70 * @param string|null $dataDir 保存先(テスト用。既定はdata/cron/)
71 */
72 public function __construct(?string $dataDir = null) {
73 $this->dataDir = rtrim($dataDir ?? ROOT.'/data/cron', '/').'/';
74 $this->dataFile = $this->dataDir.'cron.json';
75 $this->stateFile = $this->dataDir.'state.json';
76 $this->lockFile = $this->dataDir.'run.lock';
77 }
78
79 /**
80 * タスクを登録する
81 * タスクはジョブ(CronTab)を引数に受け取り、Result・bool・nullを返す。falseかResult::Errorは失敗とする。
82 *
83 * @param string $name タスク名(ジョブのcommand)
84 * @param callable $task 実行する処理
85 * @return void
86 */
87 public function register(string $name, callable $task): void {
88 $this->tasks[$name] = $task;
89 }
90
91 /**
92 * 登録されたタスク名の一覧
93 *
94 * @return array
95 */
96 public function tasks(): array {
97 return array_keys($this->tasks);
98 }
99
100 /**
101 * run()が最後に実行された時刻(システムのcrontabから呼ばれているかの確認用)
102 *
103 * @return int|null まだ実行されていなければnull
104 */
105 public function lastTick(): ?int {
106 $state = $this->readState();
107 return isset($state['lastTick']) ? (int)$state['lastTick'] : null;
108 }
109
110 public function getLastId(): int {
111 $res = $this->list();
112 if (!$res->isSuccess || empty($res->data)) return 0;
113 return (int)max(array_column($res->data, 'id'));
114 }
115
116 /**
117 * 全てのジョブ
118 *
119 * @return Result dataはジョブの配列
120 */
121 public function list(): Result {
122 if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
123 if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
124
125 $fp = fopen($this->dataFile, 'r');
126 if (!$fp) return Result::Error('Cronファイルが存在しません。');
127 flock($fp, LOCK_SH);
128 $content = stream_get_contents($fp);
129 flock($fp, LOCK_UN);
130 fclose($fp);
131
132 $data = $this->decode($content);
133 if ($data === null) return Result::Error('Cronの復号化に失敗');
134 return Result::Success('', $data);
135 }
136
137 /**
138 * ジョブ1件
139 *
140 * @param int $id ジョブのID
141 * @return Result dataはジョブ
142 */
143 public function show(int $id): Result {
144 $res = $this->list();
145 if (!$res->isSuccess) return $res;
146
147 foreach ($res->data as $d) {
148 if (($d['id'] ?? 0) === $id) return Result::Success('', $d);
149 }
150 return Result::Error("ID {$id} のCronエントリが見つかりません。");
151 }
152
153 public function enable(int $id): Result {
154 return $this->setActive($id, fn(bool $current): bool => true);
155 }
156
157 public function disable(int $id): Result {
158 return $this->setActive($id, fn(bool $current): bool => false);
159 }
160
161 public function toggle(int $id): Result {
162 return $this->setActive($id, fn(bool $current): bool => !$current);
163 }
164
165 /**
166 * ジョブを追加する
167 *
168 * @param CronTab $ct ジョブ(IDは自動で付ける)
169 * @return Result dataは追加したジョブ
170 */
171 public function add(CronTab $ct): Result {
172 if (($err = $ct->validate()) !== null) return Result::Error($err);
173
174 return $this->modify(function(array &$data) use ($ct): Result {
175 $ids = array_column($data, 'id');
176 $ct->setId(empty($ids) ? 1 : (int)max($ids) + 1);
177 $data[] = $ct->toArray();
178 return Result::Success("Cronエントリ #{$ct->getId()} を追加しました。", $ct->toArray());
179 });
180 }
181
182 /**
183 * ジョブを変更する(実行履歴は残す)
184 *
185 * @param int $id ジョブのID
186 * @param CronTab $ct 新しい内容
187 * @return Result dataは変更後のジョブ
188 */
189 public function edit(int $id, CronTab $ct): Result {
190 if (($err = $ct->validate()) !== null) return Result::Error($err);
191
192 return $this->modify(function(array &$data) use ($id, $ct): Result {
193 foreach ($data as $i => $d) {
194 if (($d['id'] ?? 0) !== $id) continue;
195 $ct->setId($id);
196 $ct->lastRun = $d['lastRun'] ?? null;
197 $ct->lastError = $d['lastError'] ?? null;
198 $data[$i] = $ct->toArray();
199 return Result::Success("ID {$id} のCronエントリを更新しました。", $data[$i]);
200 }
201 return Result::Error("ID {$id} のCronエントリが見つかりません。");
202 });
203 }
204
205 /**
206 * ジョブを削除する
207 *
208 * @param int $id ジョブのID
209 * @return Result dataは削除したジョブ
210 */
211 public function delete(int $id): Result {
212 return $this->modify(function(array &$data) use ($id): Result {
213 foreach ($data as $i => $d) {
214 if (($d['id'] ?? 0) !== $id) continue;
215 array_splice($data, $i, 1);
216 return Result::Success("ID {$id} のCronエントリを削除しました。", $d);
217 }
218 return Result::Error("ID {$id} のCronエントリが見つかりません。");
219 });
220 }
221
222 /**
223 * 予定の来たジョブを実行する(システムのcrontabから毎分呼ぶ)
224 *
225 * @param \DateTimeInterface|null $now 現在時刻(テスト用)
226 * @return Result dataは ['ran' => [ID => 結果のメッセージ], 'failed' => [ID => エラー]]
227 */
228 public function run(?\DateTimeInterface $now = null): Result {
229 if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
230 if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
231
232 // 前の実行がまだ終わっていなければ何もしない
233 // (エラーにすると、長いタスクの間に毎分cronからメールが届くので成功扱い)
234 $lock = @fopen($this->lockFile, 'c');
235 if (!$lock) return Result::Error('ロックファイルを開けません。');
236 if (!flock($lock, LOCK_EX | LOCK_NB)) {
237 fclose($lock);
238 return Result::Success('前の実行がまだ終わっていません。', ['ran' => [], 'failed' => [], 'busy' => true]);
239 }
240
241 try {
242 $now = \DateTimeImmutable::createFromInterface($now ?? new \DateTimeImmutable());
243 $now = $now->setTime((int)$now->format('G'), (int)$now->format('i'));
244
245 // 前回の実行の次の分から今までが対象(最大CATCH_UPまで遡る)
246 $state = $this->readState();
247 $last = isset($state['lastTick']) ? (new \DateTimeImmutable())->setTimestamp((int)$state['lastTick'])
248 ->setTimezone($now->getTimezone()) : null;
249 $earliest = $now->modify('-'.self::CATCH_UP.' seconds');
250 $from = $last === null ? $now->modify('-1 minute') : max($last, $earliest);
251 if ($from >= $now) return Result::Success('この分は実行済みです。', ['ran' => [], 'failed' => []]);
252
253 $list = $this->list();
254 if (!$list->isSuccess) return $list;
255
256 $ran = [];
257 $failed = [];
258 $updates = [];
259 foreach ($list->data as $d) {
260 $ct = CronTab::fromArray($d);
261 if (!$ct->isActive || $ct->validate() !== null) continue;
262
263 // $fromより後、$now以前に予定があったか
264 $due = $ct->nextRun($from);
265 if ($due === null || $due > $now) continue;
266
267 [$ok, $msg] = $this->execute($ct);
268 $updates[$ct->getId()] = ['lastRun' => $now->getTimestamp(), 'lastError' => $ok ? null : $msg];
269 if ($ok) $ran[$ct->getId()] = $msg;
270 else $failed[$ct->getId()] = $msg;
271 }
272
273 // 実行結果を保存(実行中に変更されたジョブを上書きしない様に、IDで合わせる)
274 if (!empty($updates)) {
275 $this->modify(function(array &$data) use ($updates): Result {
276 foreach ($data as $i => $d) {
277 if (isset($updates[$d['id'] ?? 0])) $data[$i] = array_merge($d, $updates[$d['id']]);
278 }
279 return Result::Success();
280 });
281 }
282 $this->writeState(['lastTick' => $now->getTimestamp()]);
283
284 $total = count($ran) + count($failed);
285 return Result::Success("{$total}件を実行しました(失敗:".count($failed).'件)', ['ran' => $ran, 'failed' => $failed]);
286 } finally {
287 flock($lock, LOCK_UN);
288 fclose($lock);
289 }
290 }
291
292 /**
293 * ジョブを予定に関係なく今すぐ実行する(手動実行・テスト用)
294 *
295 * @param int $id ジョブのID
296 * @return Result
297 */
298 public function runNow(int $id): Result {
299 $res = $this->show($id);
300 if (!$res->isSuccess) return $res;
301
302 $ct = CronTab::fromArray($res->data);
303 [$ok, $msg] = $this->execute($ct);
304 $this->modify(function(array &$data) use ($id, $ok, $msg): Result {
305 foreach ($data as $i => $d) {
306 if (($d['id'] ?? 0) === $id) $data[$i] = array_merge($d, ['lastRun' => time(), 'lastError' => $ok ? null : $msg]);
307 }
308 return Result::Success();
309 });
310 return $ok ? Result::Success($msg) : Result::Error($msg);
311 }
312
313 ///////////////
314
315 /**
316 * タスクを実行する(例外も失敗として扱う)
317 *
318 * @return array [成功か, メッセージ]
319 */
320 private function execute(CronTab $ct): array {
321 $task = $this->tasks[$ct->command] ?? null;
322 $label = "Cron #{$ct->getId()}({$ct->command})";
323 if ($task === null) {
324 $msg = "タスク「{$ct->command}」が登録されていません。";
325 logger(LogType::Cron, "{$label}:{$msg}");
326 return [false, $msg];
327 }
328
329 $start = microtime(true);
330 try {
331 $res = $task($ct);
332 $ok = !($res === false || ($res instanceof Result && !$res->isSuccess));
333 $msg = $res instanceof Result ? (string)$res->message : ($ok ? '完了' : '失敗');
334 } catch (\Throwable $e) {
335 $ok = false;
336 $msg = get_class($e).':'.$e->getMessage();
337 }
338
339 logger(LogType::Cron, sprintf('%s %s:%s(%.2f秒)%s', date('Y-m-d H:i:s'), $label, $ok ? '成功' : '失敗',
340 microtime(true) - $start, $msg !== '' ? " {$msg}" : ''));
341 return [$ok, $msg === '' ? ($ok ? '完了' : '失敗') : $msg];
342 }
343
344 private function setActive(int $id, callable $value): Result {
345 return $this->modify(function(array &$data) use ($id, $value): Result {
346 foreach ($data as $i => $d) {
347 if (($d['id'] ?? 0) !== $id) continue;
348 $data[$i]['isActive'] = $value((bool)($d['isActive'] ?? true));
349 return Result::Success("ID {$id} を".($data[$i]['isActive'] ? '有効' : '無効').'にしました。', $data[$i]);
350 }
351 return Result::Error("ID {$id} のCronエントリが見つかりません。");
352 });
353 }
354
355 /**
356 * ファイルをロックして読み、変更して書き戻す(同時に実行されても壊れない様に)
357 * $fnがResult::Errorを返した場合は保存しない
358 *
359 * @param callable $fn function(array &$data): Result
360 * @return Result $fnの結果
361 */
362 private function modify(callable $fn): Result {
363 if (!CRON_ENABLED) return Result::Error('エラー:Cronシステムは無効です。');
364 if (!$this->ensureStorage()) return Result::Error('Cronディレクトリの作成に失敗');
365
366 $fp = fopen($this->dataFile, 'c+');
367 if (!$fp) return Result::Error('Cronファイルを開けません。');
368 flock($fp, LOCK_EX);
369
370 try {
371 $data = $this->decode(stream_get_contents($fp));
372 if ($data === null) return Result::Error('Cronの復号化に失敗');
373
374 $res = $fn($data);
375 if (!$res->isSuccess) return $res;
376
377 $json = json_encode(array_values($data), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
378 ftruncate($fp, 0);
379 rewind($fp);
380 if (fwrite($fp, $json) === false) return Result::Error('Cronデータの保存に失敗');
381 fflush($fp);
382 return $res;
383 } finally {
384 flock($fp, LOCK_UN);
385 fclose($fp);
386 }
387 }
388
389 private function decode(string|false $content): ?array {
390 if ($content === false) return null;
391 if (trim($content) === '') return [];
392 $data = json_decode($content, true);
393 return is_array($data) ? array_values($data) : null;
394 }
395
396 private function ensureStorage(): bool {
397 if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) return false;
398 if (!file_exists($this->dataFile)) return file_put_contents($this->dataFile, '[]') !== false;
399 return true;
400 }
401
402 private function readState(): array {
403 if (!is_file($this->stateFile)) return [];
404 $data = json_decode((string)file_get_contents($this->stateFile), true);
405 return is_array($data) ? $data : [];
406 }
407
408 private function writeState(array $state): void {
409 file_put_contents($this->stateFile, json_encode($state), LOCK_EX);
410 }
411}