registry = $registry ?? new cron_task_registry(); $this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4)); } public function listTasks(): array { $this->ensureReady(); $this->syncDefinitions(); $states = $this->stateRows(); $estimates = $this->durationEstimates(); $tasks = []; $now = time(); foreach ($this->registry->definitions() as $definition) { $state = $states[$definition->id] ?? []; $schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== [] ? $state['schedule'] : $definition->schedule; $nextRunAt = $state['next_run_at'] ?? null; if ($nextRunAt === null || trim((string)$nextRunAt) === '') { $nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now); } $task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null); $task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now; $task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now); $tasks[] = $task; } return [ 'tasks' => $tasks, 'summary' => [ 'total' => count($tasks), 'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])), 'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])), ], ]; } public function listRuns(?string $task_id = null, int $limit = 50): array { $this->ensureReady(); $limit = max(1, min(200, $limit)); $where = ''; if ($task_id !== null && trim($task_id) !== '') { $where = "WHERE task_id = " . $this->sql($task_id); } return $this->fetchAll( "SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit" ); } public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array { $this->ensureReady(); $this->syncDefinitions(); $definition = $this->registry->get($task_id_or_legacy_name); if ($definition === null) { throw new RuntimeException('Cron task not found.'); } $state = $this->stateRows()[$definition->id] ?? []; $enabled = (bool)($state['enabled'] ?? $definition->enabled); if (!$enabled && !$force) { throw new RuntimeException('Cron task is disabled.'); } if ($this->taskIsLocked($state)) { throw new RuntimeException('Cron task is already running.'); } $existing = $this->fetchOne( "SELECT * FROM cron_task_runs WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued' ORDER BY id DESC LIMIT 1" ); if ($existing !== null) { $this->markTaskQueued($definition); return $this->publicRun($existing); } $scheduled_for = date('Y-m-d H:i:s'); $this->query( "INSERT INTO cron_task_runs (task_id, module, source, status, actor_user_id, scheduled_for, force_run) VALUES (" . $this->sql($definition->id) . ', ' . $this->sql($definition->module) . ", 'manual', 'queued', " . ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', ' . $this->sql($scheduled_for) . ', ' . ($force ? '1' : '0') . ")" ); $run_id = (int)$this->insertId(); $this->markTaskQueued($definition); return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []); } public function runDue(string $source = 'automatic'): array { $this->ensureReady(); $this->syncDefinitions(); $ran = []; foreach ($this->queuedRuns() as $queuedRun) { try { $run = $this->runQueuedRun($queuedRun); if ($run !== null) { $ran[] = $run; } } catch (Throwable $throwable) { $ran[] = [ 'task_id' => (string)($queuedRun['task_id'] ?? ''), 'module' => (string)($queuedRun['module'] ?? ''), 'source' => (string)($queuedRun['source'] ?? 'manual'), 'status' => 'skipped', 'error_message' => $throwable->getMessage(), ]; } } $now = time(); $states = $this->stateRows(); foreach ($this->registry->definitions() as $definition) { $state = $states[$definition->id] ?? []; if (!(bool)($state['enabled'] ?? $definition->enabled)) { continue; } $nextRunAt = (string)($state['next_run_at'] ?? ''); if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) { continue; } try { $ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt); } catch (Throwable $throwable) { $ran[] = [ 'task_id' => $definition->id, 'module' => $definition->module, 'source' => $source, 'status' => 'skipped', 'error_message' => $throwable->getMessage(), ]; } } return [ 'ran' => $ran, 'count' => count($ran), ]; } public function markExpiredRunningRuns(): int { $this->ensureReady(); $now = date('Y-m-d H:i:s'); $message = 'Task lock expired before completion.'; $this->query( "UPDATE cron_task_runs r INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id SET r.status = 'timed_out', r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "), r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "), s.current_run_id = NULL, s.locked_until = NULL, s.lock_owner = NULL, s.last_status = 'timed_out', s.last_error = " . $this->sql($message) . " WHERE r.status = 'running' AND s.locked_until IS NOT NULL AND s.locked_until < " . $this->sql($now) ); return $this->affectedRows(); } public function runTask( string $task_id_or_legacy_name, string $source = 'manual', ?int $actor_user_id = null, bool $force = false, ?string $scheduled_for = null ): array { $this->ensureReady(); $this->syncDefinitions(); $definition = $this->registry->get($task_id_or_legacy_name); if ($definition === null) { throw new RuntimeException('Cron task not found.'); } $state = $this->stateRows()[$definition->id] ?? []; $enabled = (bool)($state['enabled'] ?? $definition->enabled); if (!$enabled && !$force) { throw new RuntimeException('Cron task is disabled.'); } if (!$this->claimLock($definition)) { throw new RuntimeException('Cron task is already running.'); } $started = microtime(true); $started_at = date('Y-m-d H:i:s', (int)$started); $run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force); $this->query( "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id) ); return $this->executeClaimedRun($definition, $run_id, $started, $scheduled_for); } private function executeClaimedRun( cron_task_definition $definition, int $run_id, float $started, ?string $scheduled_for = null ): array { $status = 'succeeded'; $summary = []; $error_message = null; $output = ''; try { if (function_exists('set_time_limit')) { @set_time_limit($definition->timeout_seconds + 30); } $this->ensureLegacyFunctionsLoaded($definition); if (!is_callable($definition->handler)) { throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler); } ob_start(); $result = call_user_func($definition->handler); $output = (string)ob_get_clean(); $summary = is_array($result) ? $result : []; } catch (Throwable $throwable) { if (ob_get_level() > 0) { $output .= (string)ob_get_clean(); } $status = 'failed'; $error_message = $throwable->getMessage(); } $completed = microtime(true); $duration_ms = (int)round(($completed - $started) * 1000); if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') { $status = 'timed_out'; $error_message = 'Task exceeded its configured timeout window.'; } if ($output !== '') { $summary['output'] = substr($output, 0, 8000); } $completed_at = date('Y-m-d H:i:s', (int)$completed); $this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message); $this->releaseLock($definition, $status, $error_message, $completed_at, $scheduled_for); return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []); } public function updateTaskConfig(string $task_id, array $config): array { $this->ensureReady(); $this->syncDefinitions(); $definition = $this->registry->get($task_id); if ($definition === null) { throw new RuntimeException('Cron task not found.'); } $updates = []; if (array_key_exists('enabled', $config)) { $updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0'); } if (array_key_exists('schedule', $config)) { $schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']); $updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule))); $anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? ''); $updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time())); } if ($updates !== []) { $this->query( "UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id) ); } return $this->listTasks(); } private function ensureReady(): void { cron_schema_bootstrap::ensureTables(); } private function syncDefinitions(): void { $now = time(); foreach ($this->registry->definitions() as $definition) { $row = $this->fetchOne( "SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id) ); if ($row !== null) { continue; } $legacyLastRun = $this->legacyLastRun($definition); $nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun); $this->query( "INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at) VALUES (" . $this->sql($definition->id) . ', ' . $this->sql($definition->module) . ', ' . ($definition->enabled ? '1' : '0') . ', NULL, ' . $this->sql($nextRunAt) . ")" ); } } private function legacyLastRun(cron_task_definition $definition): ?int { if ($definition->legacy_name === null || !defined('redis')) { return null; } try { $last_run = redis->get_last_crond_run($definition->legacy_name); return $last_run !== null ? (int)$last_run : null; } catch (Throwable) { return null; } } private function claimLock(cron_task_definition $definition): bool { $now = date('Y-m-d H:i:s'); $locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60); $this->query( "UPDATE cron_task_state SET locked_until = " . $this->sql($locked_until) . ", lock_owner = " . $this->sql($this->lock_owner) . " WHERE task_id = " . $this->sql($definition->id) . " AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")" ); return $this->affectedRows() === 1; } private function taskIsLocked(array $state): bool { $lockedUntil = (string)($state['locked_until'] ?? ''); return $lockedUntil !== '' && strtotime($lockedUntil) !== false && strtotime($lockedUntil) >= time(); } private function markTaskQueued(cron_task_definition $definition): void { $now = date('Y-m-d H:i:s'); $this->query( "UPDATE cron_task_state SET last_status = 'queued', last_error = NULL, next_run_at = CASE WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . " ELSE next_run_at END WHERE task_id = " . $this->sql($definition->id) ); } /** * @return array> */ private function queuedRuns(): array { return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50"); } private function runQueuedRun(array $queuedRun): ?array { $run_id = (int)($queuedRun['id'] ?? 0); $definition = $this->registry->get((string)($queuedRun['task_id'] ?? '')); if ($run_id < 1 || $definition === null) { if ($run_id > 0) { $this->skipQueuedRun($run_id, 'Cron task not found.'); return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []); } return null; } $force = (bool)($queuedRun['force_run'] ?? false); $state = $this->stateRows()[$definition->id] ?? []; $enabled = (bool)($state['enabled'] ?? $definition->enabled); if (!$enabled && !$force) { $this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition); return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []); } if (!$this->claimLock($definition)) { return null; } $started = microtime(true); $started_at = date('Y-m-d H:i:s', (int)$started); $this->query( "UPDATE cron_task_runs SET status = 'running', started_at = " . $this->sql($started_at) . ", lock_owner = " . $this->sql($this->lock_owner) . " WHERE id = $run_id AND status = 'queued'" ); if ($this->affectedRows() !== 1) { $this->clearClaimedLock($definition); return null; } $this->query( "UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id) ); return $this->executeClaimedRun( $definition, $run_id, $started, isset($queuedRun['scheduled_for']) ? (string)$queuedRun['scheduled_for'] : null ); } private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void { $completed_at = date('Y-m-d H:i:s'); $this->query( "UPDATE cron_task_runs SET status = 'skipped', completed_at = " . $this->sql($completed_at) . ", duration_ms = 0, error_message = " . $this->sql($message) . " WHERE id = $run_id AND status = 'queued'" ); $updated = $this->affectedRows() === 1; if (!$updated || $definition === null) { return; } $this->query( "UPDATE cron_task_state SET last_status = 'skipped', last_error = " . $this->sql($message) . " WHERE task_id = " . $this->sql($definition->id) ); } private function clearClaimedLock(cron_task_definition $definition): void { $this->query( "UPDATE cron_task_state SET locked_until = NULL, lock_owner = NULL WHERE task_id = " . $this->sql($definition->id) . " AND lock_owner = " . $this->sql($this->lock_owner) ); } private function releaseLock( cron_task_definition $definition, string $status, ?string $error_message, string $completed_at, ?string $scheduled_for = null ): void { $state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)); $schedule = $this->decodeJson($state['schedule_json'] ?? null); if ($schedule === []) { $schedule = $definition->schedule; } // Automatic runs stay anchored to their intended schedule slot. Anchoring // to completion time causes every task to drift by its execution time. $scheduleAnchor = $scheduled_for !== null && strtotime($scheduled_for) !== false ? $scheduled_for : $completed_at; $nextRunAt = cron_schedule::nextRunAt($schedule, $scheduleAnchor, time()); if ($status !== 'succeeded') { $retrySeconds = min(300, max(60, (int)$schedule['seconds'])); $nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds); } $this->query( "UPDATE cron_task_state SET last_run_at = " . $this->sql($completed_at) . ", next_run_at = " . $this->sql($nextRunAt) . ", locked_until = NULL, lock_owner = NULL, current_run_id = NULL, last_status = " . $this->sql($status) . ", last_error = " . $this->nullableSql($error_message) . " WHERE task_id = " . $this->sql($definition->id) . " AND lock_owner = " . $this->sql($this->lock_owner) ); if ($definition->legacy_name !== null && defined('redis')) { try { redis->set_last_crond_run($definition->legacy_name, time()); } catch (Throwable) { } } } private function createRun( cron_task_definition $definition, string $source, ?int $actor_user_id, ?string $scheduled_for, string $started_at, bool $force = false ): int { $this->query( "INSERT INTO cron_task_runs (task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run) VALUES (" . $this->sql($definition->id) . ', ' . $this->sql($definition->module) . ', ' . $this->sql($source) . ", 'running', " . ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', ' . $this->nullableSql($scheduled_for) . ', ' . $this->sql($started_at) . ', ' . $this->sql($this->lock_owner) . ', ' . ($force ? '1' : '0') . ")" ); return $this->insertId(); } private function completeRun( int $run_id, string $status, string $completed_at, int $duration_ms, array $summary, ?string $error_message ): void { $this->query( "UPDATE cron_task_runs SET status = " . $this->sql($status) . ", completed_at = " . $this->sql($completed_at) . ", duration_ms = " . (string)$duration_ms . ", summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ", error_message = " . $this->nullableSql($error_message) . " WHERE id = " . (string)$run_id ); } private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void { if (function_exists($definition->handler)) { return; } if (!defined('WD')) { return; } if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) { define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true); } require_once WD . '/cron/Cron.php'; } /** * @return array> */ private function stateRows(): array { $rows = $this->fetchAll("SELECT * FROM cron_task_state"); $states = []; foreach ($rows as $row) { $row['enabled'] = (bool)$row['enabled']; $row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null); $states[(string)$row['task_id']] = $row; } return $states; } /** * @return array */ private function durationEstimates(): array { $rows = $this->fetchAll( "SELECT task_id, AVG(duration_ms) AS avg_duration_ms FROM ( SELECT task_id, duration_ms FROM cron_task_runs WHERE status = 'succeeded' AND duration_ms IS NOT NULL ORDER BY id DESC LIMIT 500 ) recent_runs GROUP BY task_id" ); $estimates = []; foreach ($rows as $row) { $estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']); } return $estimates; } private function decodeJson(mixed $json): array { if (!is_string($json) || trim($json) === '') { return []; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } private function publicRun(array $run): array { if ($run === []) { return []; } $run['force_run'] = (bool)($run['force_run'] ?? false); $run['summary'] = $this->decodeJson($run['summary_json'] ?? null); return $run; } private function fetchOne(string $sql): ?array { $rows = $this->fetchAll($sql); return $rows[0] ?? null; } private function fetchAll(string $sql): array { $result = $this->query($sql); if ($result === false || $result === true) { return []; } return $result->fetch_all(MYSQLI_ASSOC); } private function query(string $sql): \mysqli_result|bool { global $db; return $db->query($sql); } private function sql(string $value): string { global $db; return "'" . $db->escape_string($value) . "'"; } private function nullableSql(?string $value): string { return $value === null ? 'NULL' : $this->sql($value); } private function affectedRows(): int { global $db; return (int)$db->conn()->affected_rows; } private function insertId(): int { global $db; return (int)$db->insert_id(); } }