Refactor cron scheduling
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class cron_scheduler
|
||||
{
|
||||
private cron_task_registry $registry;
|
||||
private string $lock_owner;
|
||||
|
||||
public function __construct(?cron_task_registry $registry = null)
|
||||
{
|
||||
$this->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 runDue(string $source = 'automatic'): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$ran = [];
|
||||
$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 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);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
$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);
|
||||
|
||||
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
}
|
||||
|
||||
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 releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): 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;
|
||||
}
|
||||
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, 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
|
||||
): int {
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
|
||||
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)
|
||||
. ")"
|
||||
);
|
||||
|
||||
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<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, int>
|
||||
*/
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user