| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib; |
| 39 | |
| 40 | use Result; |
| 41 | use LogType; |
| 42 | use 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 | */ |
| 57 | class 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 | } |