Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2e906763 |
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_schedule
|
||||
{
|
||||
public static function normalize(array $schedule): array
|
||||
{
|
||||
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
|
||||
if ($type !== 'interval') {
|
||||
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
|
||||
}
|
||||
|
||||
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
|
||||
if ($seconds < 30 || $seconds > 2678400) {
|
||||
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'interval',
|
||||
'seconds' => $seconds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
|
||||
? strtotime($anchorDateTime)
|
||||
: false;
|
||||
$base = $anchor !== false ? (int)$anchor : $now;
|
||||
$next = $base + (int)$normalized['seconds'];
|
||||
|
||||
if ($next <= $now) {
|
||||
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
|
||||
$next += $missed * (int)$normalized['seconds'];
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $next);
|
||||
}
|
||||
|
||||
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
|
||||
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
|
||||
return self::nextRunAt($normalized, $lastRunAt, $now);
|
||||
}
|
||||
|
||||
if ($legacyLastRun !== null && $legacyLastRun > 0) {
|
||||
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $now);
|
||||
}
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cron_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_state (
|
||||
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
schedule_json LONGTEXT NULL,
|
||||
last_run_at DATETIME NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
locked_until DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
current_run_id BIGINT UNSIGNED NULL,
|
||||
last_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_state_next_run (enabled, next_run_at),
|
||||
KEY idx_cron_task_state_lock (locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_runs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(191) NOT NULL,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
actor_user_id INT NULL,
|
||||
scheduled_for DATETIME NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
duration_ms INT UNSIGNED NULL,
|
||||
summary_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_runs_task_created (task_id, created_at),
|
||||
KEY idx_cron_task_runs_status_created (status, created_at),
|
||||
KEY idx_cron_task_runs_module_created (module, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_definition
|
||||
{
|
||||
public string $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public string $module;
|
||||
public string $handler;
|
||||
public array $schedule;
|
||||
public bool $enabled;
|
||||
public int $timeout_seconds;
|
||||
public int $estimated_duration_ms;
|
||||
public int $priority;
|
||||
public ?string $legacy_name;
|
||||
|
||||
public function __construct(array $definition)
|
||||
{
|
||||
$this->id = self::requiredString($definition, 'id');
|
||||
$this->name = self::requiredString($definition, 'name');
|
||||
$this->description = (string)($definition['description'] ?? '');
|
||||
$this->module = self::requiredString($definition, 'module');
|
||||
$this->handler = self::requiredString($definition, 'handler');
|
||||
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
|
||||
$this->enabled = (bool)($definition['enabled'] ?? true);
|
||||
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
|
||||
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
|
||||
$this->priority = (int)($definition['priority'] ?? 100);
|
||||
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
|
||||
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
|
||||
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
|
||||
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
|
||||
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
|
||||
}
|
||||
}
|
||||
|
||||
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
|
||||
{
|
||||
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
|
||||
? $state['schedule']
|
||||
: $this->schedule;
|
||||
$enabled = array_key_exists('enabled', $state ?? [])
|
||||
? (bool)$state['enabled']
|
||||
: $this->enabled;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'module' => $this->module,
|
||||
'handler' => $this->handler,
|
||||
'schedule' => $schedule,
|
||||
'default_schedule' => $this->schedule,
|
||||
'enabled' => $enabled,
|
||||
'default_enabled' => $this->enabled,
|
||||
'timeout_seconds' => $this->timeout_seconds,
|
||||
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
|
||||
'priority' => $this->priority,
|
||||
'legacy_name' => $this->legacy_name,
|
||||
'last_run_at' => $state['last_run_at'] ?? null,
|
||||
'next_run_at' => $state['next_run_at'] ?? null,
|
||||
'locked_until' => $state['locked_until'] ?? null,
|
||||
'lock_owner' => $state['lock_owner'] ?? null,
|
||||
'current_run_id' => $state['current_run_id'] ?? null,
|
||||
'last_status' => $state['last_status'] ?? null,
|
||||
'last_error' => $state['last_error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function requiredString(array $definition, string $key): string
|
||||
{
|
||||
$value = trim((string)($definition[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_registry
|
||||
{
|
||||
private string $modules_root;
|
||||
|
||||
/** @var array<string, cron_task_definition>|null */
|
||||
private ?array $definitions = null;
|
||||
|
||||
public function __construct(?string $modules_root = null)
|
||||
{
|
||||
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, cron_task_definition>
|
||||
*/
|
||||
public function definitions(): array
|
||||
{
|
||||
if ($this->definitions !== null) {
|
||||
return $this->definitions;
|
||||
}
|
||||
|
||||
$definitions = [];
|
||||
foreach ($this->definitionFiles() as $file) {
|
||||
$module_definitions = require $file;
|
||||
if (!is_array($module_definitions)) {
|
||||
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
|
||||
}
|
||||
|
||||
foreach ($module_definitions as $definition) {
|
||||
$task = new cron_task_definition($definition);
|
||||
if (isset($definitions[$task->id])) {
|
||||
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
|
||||
}
|
||||
$definitions[$task->id] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
|
||||
if ($left->priority !== $right->priority) {
|
||||
return $left->priority <=> $right->priority;
|
||||
}
|
||||
return strcmp($left->id, $right->id);
|
||||
});
|
||||
|
||||
$this->definitions = $definitions;
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
public function get(string $id_or_legacy_name): ?cron_task_definition
|
||||
{
|
||||
$normalized = trim($id_or_legacy_name);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definitions = $this->definitions();
|
||||
if (isset($definitions[$normalized])) {
|
||||
return $definitions[$normalized];
|
||||
}
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function definitionFiles(): array
|
||||
{
|
||||
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,8 @@ $htaccess = "
|
||||
# php -- END cPanel-generated handler, do not edit
|
||||
";
|
||||
|
||||
// Keep the compatibility rewrite file stable without rewriting it every minute.
|
||||
$htaccessPath = __DIR__ . '/.htaccess';
|
||||
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
|
||||
file_put_contents($htaccessPath, $htaccess);
|
||||
}
|
||||
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
|
||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||
|
||||
// Log the time of the cron job
|
||||
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
|
||||
|
||||
@@ -8,7 +8,6 @@ use classes\invoice_period_flag_service;
|
||||
use classes\coolify_manager;
|
||||
use classes\replication_manager;
|
||||
use classes\redis;
|
||||
use classes\selfserve;
|
||||
use classes\system_search_cache;
|
||||
use classes\system_search_document_index;
|
||||
use classes\system_search_economic_customer_index;
|
||||
@@ -41,11 +40,6 @@ const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
require_once __DIR__ . '/../classes/cron_schedule.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_definition.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_registry.php';
|
||||
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/cron_scheduler.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
@@ -1475,292 +1469,17 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
|
||||
}
|
||||
}
|
||||
|
||||
function SelfserveOpeningRelayActivationCron(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
|
||||
return [
|
||||
'skipped' => true,
|
||||
'reason' => 'database_unavailable',
|
||||
];
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
|
||||
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
|
||||
$summary = [
|
||||
'checked_departments' => count($candidates),
|
||||
'activated_departments' => 0,
|
||||
'skipped_departments' => 0,
|
||||
'failed_departments' => 0,
|
||||
'activated_relays' => 0,
|
||||
'skipped_relays' => 0,
|
||||
'failed_relays' => 0,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$departmentId = (int)($candidate['department_id'] ?? 0);
|
||||
$opensAt = (string)($candidate['opens_at'] ?? '');
|
||||
if ($departmentId <= 0 || $opensAt === '') {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
|
||||
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
|
||||
$summary['activated_relays'] += (int)$departmentSummary['activated'];
|
||||
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
|
||||
$summary['failed_relays'] += (int)$departmentSummary['failed'];
|
||||
|
||||
if ((int)$departmentSummary['failed'] > 0) {
|
||||
$summary['failed_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
|
||||
if ((int)$departmentSummary['activated'] > 0) {
|
||||
$summary['activated_departments']++;
|
||||
} else {
|
||||
$summary['skipped_departments']++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
|
||||
. $summary['activated_relays'] . " cleaner relays activated across "
|
||||
. $summary['activated_departments'] . " departments.\n";
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$weekday = strtolower($now->format('l'));
|
||||
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
if (!in_array($weekday, $allowedWeekdays, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$startColumn = $weekday . '_start';
|
||||
$endColumn = $weekday . '_end';
|
||||
$sql = "
|
||||
SELECT
|
||||
oh.department AS department_id,
|
||||
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
|
||||
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
|
||||
FROM department_time_bookings_opening_hours oh
|
||||
INNER JOIN department_variables dv ON dv.department_id = oh.department
|
||||
INNER JOIN department_lanes dl ON dl.department = oh.department
|
||||
WHERE dv.variable = 'selfserve_enabled'
|
||||
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
|
||||
AND oh.`$startColumn` IS NOT NULL
|
||||
AND oh.`$endColumn` IS NOT NULL
|
||||
AND dl.deleted_at IS NULL
|
||||
AND COALESCE(dl.selfserve_enabled, 1) = 1
|
||||
AND dl.relay_machine_cleaner_id IS NOT NULL
|
||||
AND TRIM(dl.relay_machine_cleaner_id) <> ''
|
||||
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$db->fetch_all($result),
|
||||
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
|
||||
$now,
|
||||
(string)($row['opens_at'] ?? ''),
|
||||
(string)($row['closes_at'] ?? '')
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayWindowActive(
|
||||
DateTimeImmutable $now,
|
||||
?string $opensAt,
|
||||
?string $closesAt
|
||||
): bool {
|
||||
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
|
||||
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
|
||||
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nowSeconds = ((int)$now->format('G') * 3600)
|
||||
+ ((int)$now->format('i') * 60)
|
||||
+ (int)$now->format('s');
|
||||
|
||||
if ($opensAtSeconds < $closesAtSeconds) {
|
||||
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
|
||||
}
|
||||
|
||||
return $nowSeconds >= $opensAtSeconds;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
|
||||
{
|
||||
$time = trim((string)$time);
|
||||
if ($time === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hours = (int)$matches[1];
|
||||
$minutes = (int)$matches[2];
|
||||
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
|
||||
|
||||
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($hours * 3600) + ($minutes * 60) + $seconds;
|
||||
}
|
||||
|
||||
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
|
||||
{
|
||||
$summary = [
|
||||
'activated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$selfserve = new selfserve();
|
||||
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||
|
||||
foreach ($lanes as $departmentLane) {
|
||||
if (!($departmentLane instanceof department_lanes_o)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$departmentLane->isSelfServeEnabled()) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$laneId = (int)$departmentLane->id;
|
||||
if ($laneId <= 0) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$lane = $selfserve->lane($laneId);
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
$summary['activated']++;
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['failed']++;
|
||||
warn(
|
||||
'SelfserveOpeningRelayActivationCron failed for department '
|
||||
. $departmentId
|
||||
. ', lane '
|
||||
. $laneId
|
||||
. ': '
|
||||
. $throwable->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
|
||||
{
|
||||
if (
|
||||
$departmentLane === null
|
||||
|| !isset($departmentLane->relay_machine_cleaner_id)
|
||||
|| !is_object($departmentLane->relay_machine_cleaner_id)
|
||||
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationKey(
|
||||
int $departmentId,
|
||||
string $opensAt,
|
||||
DateTimeImmutable $now
|
||||
): string {
|
||||
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
|
||||
return 'selfserve:opening-cleaner-relays:'
|
||||
. $now->format('Y-m-d')
|
||||
. ':'
|
||||
. $departmentId
|
||||
. ':'
|
||||
. $normalizedOpensAt;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return redis->exists($cacheKey);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
foreach ( $cron_tasks as $task => $data ) {
|
||||
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
|
||||
$nextRun = $lastRun + $data['interval'];
|
||||
$cron_tasks[$task]['last_run'] = $lastRun;
|
||||
$cron_tasks[$task]['next_run'] = $nextRun;
|
||||
if ($nextRun <= time()) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
|
||||
$data['function']();
|
||||
redis->set_last_crond_run($task, time());
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
|
||||
} else {
|
||||
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
|
||||
}
|
||||
}
|
||||
|
||||
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
redis->setEx($cacheKey, '1', 36 * 3600);
|
||||
} catch (Throwable) {
|
||||
// Redis idempotency should not block relay activation.
|
||||
}
|
||||
}
|
||||
|
||||
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
|
||||
} catch (Throwable $throwable) {
|
||||
warn('Cron scheduler failed: ' . $throwable->getMessage());
|
||||
$response_cron = [
|
||||
'ran' => [],
|
||||
'count' => 0,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'backups.create_backup',
|
||||
'legacy_name' => 'backup',
|
||||
'name' => 'Create backup',
|
||||
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'backup',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'priority' => 115,
|
||||
],
|
||||
];
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'coolify.availability_monitor',
|
||||
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
|
||||
'name' => 'Coolify availability monitor',
|
||||
'description' => 'Checks registered Coolify-managed infrastructure targets.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyAvailabilityMonitorCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 25,
|
||||
],
|
||||
[
|
||||
'id' => 'coolify.load_balancer_reconcile',
|
||||
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'name' => 'Coolify load balancer reconcile',
|
||||
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 26,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'dynamicimages.pre_render',
|
||||
'legacy_name' => 'PreRenderDynamicImagesCron',
|
||||
'name' => 'Pre-render dynamic images',
|
||||
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
|
||||
'module' => 'dynamicimages',
|
||||
'handler' => 'PreRenderDynamicImagesCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 900],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 90,
|
||||
],
|
||||
];
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'economic.sync_user_customer_discounts',
|
||||
'legacy_name' => 'SyncUserEconomicCustomerDiscounts',
|
||||
'name' => 'Sync e-conomic customer discounts',
|
||||
'description' => 'Clears cached customer discounts so e-conomic discount data can refresh.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncUserEconomicCustomerDiscounts',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 180],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 30,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_user_customer_details',
|
||||
'legacy_name' => 'SyncUserEconomicCustomerDetails',
|
||||
'name' => 'Sync e-conomic customer details',
|
||||
'description' => 'Refreshes customer details and search documents from e-conomic.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncUserEconomicCustomerDetails',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 110,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_system_search_customer_index',
|
||||
'legacy_name' => 'SyncSystemSearchEconomicCustomerIndex',
|
||||
'name' => 'Sync e-conomic customer search index',
|
||||
'description' => 'Refreshes the system search e-conomic customer index.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncSystemSearchEconomicCustomerIndex',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 900],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 75,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.sync_invoice_status',
|
||||
'legacy_name' => 'SyncEconomicInvoiceStatus',
|
||||
'name' => 'Sync e-conomic invoice status',
|
||||
'description' => 'Checks e-conomic invoice errors and draft status.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'SyncEconomicInvoiceStatus',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 120],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 35,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.transfer_queue',
|
||||
'legacy_name' => 'EconomicTransferQueueCron',
|
||||
'name' => 'Process e-conomic transfer queue',
|
||||
'description' => 'Processes pending e-conomic transfer queue jobs in bounded batches.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'EconomicTransferQueueCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 30],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 20,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'failover.replica_monitor',
|
||||
'legacy_name' => 'ReplicaFailoverMonitorCron',
|
||||
'name' => 'Replica failover monitor',
|
||||
'description' => 'Checks replicated services and promotes eligible replicas during automatic failover.',
|
||||
'module' => 'failover',
|
||||
'handler' => 'ReplicaFailoverMonitorCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 24,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'goals.progress_alerts',
|
||||
'legacy_name' => 'GoalsProgressAlertsCron',
|
||||
'name' => 'Goals progress alerts',
|
||||
'description' => 'Evaluates department goal alert schedules and sends due progress alerts.',
|
||||
'module' => 'goals',
|
||||
'handler' => 'GoalsProgressAlertsCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 600,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 45,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'selfserve.activate_opening_cleaner_relays',
|
||||
'legacy_name' => 'SelfserveOpeningRelayActivationCron',
|
||||
'name' => 'Activate self-serve opening cleaner relays',
|
||||
'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.',
|
||||
'module' => 'selfserve',
|
||||
'handler' => 'SelfserveOpeningRelayActivationCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 45,
|
||||
],
|
||||
];
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'system.sync_logs',
|
||||
'legacy_name' => 'SyncLogs',
|
||||
'name' => 'Sync logs',
|
||||
'description' => 'Flush Redis-backed application logs into the database.',
|
||||
'module' => 'system',
|
||||
'handler' => 'syncLogsToDatabase',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||
'timeout_seconds' => 120,
|
||||
'estimated_duration_ms' => 1000,
|
||||
'priority' => 10,
|
||||
],
|
||||
[
|
||||
'id' => 'system.search_cache_maintenance',
|
||||
'legacy_name' => 'SystemSearchCacheMaintenanceCron',
|
||||
'name' => 'System search cache maintenance',
|
||||
'description' => 'Processes dirty-table and full rebuild requests for system search caches.',
|
||||
'module' => 'system',
|
||||
'handler' => 'SystemSearchCacheMaintenanceCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||
'timeout_seconds' => 600,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 70,
|
||||
],
|
||||
[
|
||||
'id' => 'system.prune_session_activity',
|
||||
'legacy_name' => 'PruneSystemSessionActivityCron',
|
||||
'name' => 'Prune system session activity',
|
||||
'description' => 'Deletes stale system session activity rows.',
|
||||
'module' => 'system',
|
||||
'handler' => 'PruneSystemSessionActivityCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 1000,
|
||||
'priority' => 120,
|
||||
],
|
||||
[
|
||||
'id' => 'system.invoice_flags_manual_cache',
|
||||
'legacy_name' => 'WarmInvoicePeriodManualFlagsCron',
|
||||
'name' => 'Warm manual invoice flag cache',
|
||||
'description' => 'Warms superuser invoice-period manual flag counters.',
|
||||
'module' => 'system',
|
||||
'handler' => 'WarmInvoicePeriodManualFlagsCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||
'timeout_seconds' => 600,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 80,
|
||||
],
|
||||
[
|
||||
'id' => 'system.invoice_flags_automatic_cache',
|
||||
'legacy_name' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||
'name' => 'Warm automatic invoice flag cache',
|
||||
'description' => 'Warms current, previous, and queued invoice-period automatic flag caches.',
|
||||
'module' => 'system',
|
||||
'handler' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 85,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'weatherapi.preload_department_responses',
|
||||
'legacy_name' => 'PreloadDepartmentWeatherResponsesCron',
|
||||
'name' => 'Preload department weather responses',
|
||||
'description' => 'Warms department weather timeline responses for visible and active departments.',
|
||||
'module' => 'weatherapi',
|
||||
'handler' => 'PreloadDepartmentWeatherResponsesCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 40,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'workfeed.warm_employee_names',
|
||||
'legacy_name' => 'WarmWorkfeedEmployeeNamesCron',
|
||||
'name' => 'Warm Workfeed employee names',
|
||||
'description' => 'Warms Redis mappings for Workfeed employee identifiers and display names.',
|
||||
'module' => 'workfeed',
|
||||
'handler' => 'WarmWorkfeedEmployeeNamesCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 21600],
|
||||
'timeout_seconds' => 600,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 100,
|
||||
],
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'xlvask.sync_module',
|
||||
'legacy_name' => 'SyncXLVaskModuleCron',
|
||||
'name' => 'Sync XL Vask module',
|
||||
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
|
||||
'module' => 'xlvask',
|
||||
'handler' => 'SyncXLVaskModuleCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 95,
|
||||
],
|
||||
];
|
||||
@@ -3,9 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\cron_scheduler;
|
||||
use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class cronRoute
|
||||
@@ -14,152 +12,39 @@ class cronRoute
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/cron', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
$response->success((new cron_scheduler())->listTasks());
|
||||
}, [
|
||||
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/cron/runs', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
$task_id = $this->getParameter('task_id');
|
||||
$limit = (int)($this->getParameter('limit') ?? 50);
|
||||
$response->success([
|
||||
'runs' => (new cron_scheduler())->listRuns(is_string($task_id) ? $task_id : null, $limit),
|
||||
]);
|
||||
}, [
|
||||
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/run', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$task_id = trim((string)($parameters['task_id'] ?? ''));
|
||||
if ($task_id === '') {
|
||||
$response->error('Missing cron task id.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$run = (new cron_scheduler())->runTask(
|
||||
$task_id,
|
||||
'manual',
|
||||
$this->actorUserId(),
|
||||
$this->toBool($parameters['force'] ?? false, false)
|
||||
);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
|
||||
$response->success($run);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'SUPERUSER_RUN_CRON' => 'Run cron jobs',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/cron/config', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_manage');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$task_id = trim((string)($parameters['task_id'] ?? ''));
|
||||
if ($task_id === '') {
|
||||
$response->error('Missing cron task id.', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$config = [];
|
||||
if (array_key_exists('enabled', $parameters)) {
|
||||
$config['enabled'] = $this->toBool($parameters['enabled'], true);
|
||||
}
|
||||
if (array_key_exists('schedule', $parameters)) {
|
||||
$config['schedule'] = $parameters['schedule'];
|
||||
}
|
||||
$result = (new cron_scheduler())->updateTaskConfig($task_id, $config);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_TASK_CONFIG_UPDATED', 'Updated cron task: ' . $task_id);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_cron_manage' => 'Configure cron task schedules and enabled state',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron', function () {
|
||||
// Get the post data
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$scheduler = new cron_scheduler();
|
||||
|
||||
try {
|
||||
if (isset($parameters['job']) && trim((string)$parameters['job']) !== '') {
|
||||
$job = trim((string)$parameters['job']);
|
||||
$run = $scheduler->runTask($job, 'manual', $this->actorUserId(), true);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron job: ' . $job);
|
||||
$response->success([
|
||||
'message' => 'Cron job ran successfully',
|
||||
'run' => $run,
|
||||
]);
|
||||
// Make sure the user has the SUPERUSER_RUN_CRON permission
|
||||
$this->requirePermission('SUPERUSER_RUN_CRON');
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Get the post data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
// Check if a specific cron job is requested
|
||||
if (isset($data['job'])) {
|
||||
// Check if the cron job exists
|
||||
if (!file_exists(WD . '/cron/' . $data['job'] . '.php')) {
|
||||
$response->error('Cron job not found', 404);
|
||||
}
|
||||
|
||||
$result = $scheduler->runDue('manual');
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_RUN', 'Ran due cron jobs');
|
||||
$response->success([
|
||||
'message' => 'Due cron jobs ran successfully',
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
// Include the cron job
|
||||
require_once WD . '/cron/' . $data['job'] . '.php';
|
||||
// Log the incident
|
||||
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_JOB_RUN', 'Ran cron job: ' . $data['job']);
|
||||
$response->success('Cron job ran successfully');
|
||||
}
|
||||
// If no specific cron job is requested, run all cron jobs (through the cron.php file)
|
||||
require_once WD . '/cron/Cron.php';
|
||||
// Log the incident
|
||||
(new logs_o())->add('cron', 'global', 1, $user->id, 'CRON_RUN', 'Ran all cron jobs');
|
||||
$response->success([
|
||||
'message' => 'All cron jobs ran successfully',
|
||||
'data' => $response_cron ?? []
|
||||
]);
|
||||
},
|
||||
[
|
||||
'SUPERUSER_RUN_CRON' => 'Run cron jobs'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage cron tasks.', 403);
|
||||
}
|
||||
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function toBool(mixed $value, bool $default): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
$normalized = strtolower(trim((string)$value));
|
||||
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +575,11 @@ class departmentsRoute
|
||||
}
|
||||
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
|
||||
{
|
||||
if (!$enabled) {
|
||||
// Self-serve disabled: do not mutate lane relay states.
|
||||
return;
|
||||
}
|
||||
|
||||
$selfserve = new selfserve();
|
||||
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||
|
||||
@@ -590,30 +595,12 @@ class departmentsRoute
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$enabled) {
|
||||
// Self-serve disabled: restore normal/manual relay operation.
|
||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||
$lane->setMachineProgramPickerRelayStatusHard(true);
|
||||
});
|
||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
});
|
||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void {
|
||||
$lane->setMachineRelayStatusHard(true);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$department_lane->isSelfServeEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Self-serve enabled: lances must be usable; machine-only relays stay off.
|
||||
// Self-serve enabled: keep machine stack off.
|
||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
|
||||
$lane->setMachineProgramPickerRelayStatus(false);
|
||||
});
|
||||
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
$lane->setMachineCleanerRelayStatus(false);
|
||||
});
|
||||
try {
|
||||
$lane->setMachineRelayStatus(false);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('delegates automatic cron execution to the db-backed scheduler', function (): void {
|
||||
$content = file_get_contents(app_path('cron/Cron.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('CRON_LOAD_LEGACY_FUNCTIONS_ONLY');
|
||||
expect($content)->toContain("new \\classes\\cron_scheduler())->runDue('automatic')");
|
||||
expect($content)->toContain('function EconomicTransferQueueCron(): void');
|
||||
expect($content)->toContain('function GoalsProgressAlertsCron(): void');
|
||||
});
|
||||
|
||||
it('only rewrites htaccess when the compatibility file changes', function (): void {
|
||||
$content = file_get_contents(app_path('cron.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('file_get_contents($htaccessPath) !== $htaccess');
|
||||
expect($content)->toContain('file_put_contents($htaccessPath, $htaccess)');
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('exposes superuser cron status, history, manual run, and config routes', function (): void {
|
||||
$content = file_get_contents(app_path('routes/cronRoute.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('/superuser/cron');
|
||||
expect($content)->toContain('/superuser/cron/runs');
|
||||
expect($content)->toContain('/superuser/cron/run');
|
||||
expect($content)->toContain('/superuser/cron/config');
|
||||
expect($content)->toContain('new cron_scheduler()');
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_view')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('SUPERUSER_RUN_CRON')");
|
||||
expect($content)->toContain("requireClassicSuperuserPermission('superuser_cron_manage')");
|
||||
});
|
||||
|
||||
it('does not include caller-controlled cron php files', function (): void {
|
||||
$content = file_get_contents(app_path('routes/cronRoute.php'));
|
||||
|
||||
expect($content)->not->toContain("WD . '/cron/'");
|
||||
expect($content)->not->toContain("file_exists(WD . '/cron/'");
|
||||
expect($content)->not->toContain("require_once WD . '/cron/'");
|
||||
});
|
||||
|
||||
it('keeps the legacy cron post endpoint as a scheduler bridge', function (): void {
|
||||
$content = file_get_contents(app_path('routes/cronRoute.php'));
|
||||
|
||||
expect($content)->toContain('runTask($job, \'manual\'');
|
||||
expect($content)->toContain("runDue('manual')");
|
||||
expect($content)->toContain('Cron job ran successfully');
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
use classes\cron_schedule;
|
||||
use classes\cron_task_registry;
|
||||
|
||||
it('discovers module-owned cron task definitions', function (): void {
|
||||
$registry = new cron_task_registry(app_path('modules'));
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
expect($definitions)->toHaveCount(19);
|
||||
expect(array_keys($definitions))->toContain(
|
||||
'system.sync_logs',
|
||||
'economic.transfer_queue',
|
||||
'dynamicimages.pre_render',
|
||||
'weatherapi.preload_department_responses',
|
||||
'goals.progress_alerts'
|
||||
);
|
||||
|
||||
$transferQueue = $registry->get('EconomicTransferQueueCron');
|
||||
expect($transferQueue)->not->toBeNull();
|
||||
expect($transferQueue->id)->toBe('economic.transfer_queue');
|
||||
expect($transferQueue->module)->toBe('economic');
|
||||
expect($transferQueue->schedule)->toBe(['type' => 'interval', 'seconds' => 30]);
|
||||
});
|
||||
|
||||
it('keeps every discovered cron task in a module cron folder', function (): void {
|
||||
$files = glob(app_path('modules/*/cron/tasks.php')) ?: [];
|
||||
$modules = array_map(
|
||||
static fn(string $file): string => basename(dirname(dirname($file))),
|
||||
$files
|
||||
);
|
||||
|
||||
$registry = new cron_task_registry(app_path('modules'));
|
||||
foreach ($registry->definitions() as $definition) {
|
||||
expect($modules)->toContain($definition->module);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes and advances interval schedules without tight loops', function (): void {
|
||||
expect(cron_schedule::normalize(['type' => 'interval', 'seconds' => 60]))
|
||||
->toBe(['type' => 'interval', 'seconds' => 60]);
|
||||
|
||||
$now = strtotime('2026-07-09 12:10:00');
|
||||
$next = cron_schedule::nextRunAt(
|
||||
['type' => 'interval', 'seconds' => 300],
|
||||
'2026-07-09 12:00:00',
|
||||
$now
|
||||
);
|
||||
|
||||
expect($next)->toBe('2026-07-09 12:15:00');
|
||||
});
|
||||
|
||||
it('rejects unsafe cron intervals', function (): void {
|
||||
expect(fn() => cron_schedule::normalize(['type' => 'interval', 'seconds' => 5]))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
+2
-6
@@ -7,15 +7,11 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu
|
||||
expect($routeContent)->toContain('/departments/self-serve/enabled');
|
||||
expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);');
|
||||
expect($routeContent)->toContain('if (!$enabled) {');
|
||||
expect($routeContent)->toContain('// Self-serve disabled: restore normal/manual relay operation.');
|
||||
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('setMachineRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()');
|
||||
expect($routeContent)->toContain('// Self-serve disabled: do not mutate lane relay states.');
|
||||
expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)');
|
||||
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||
expect($routeContent)->toContain('setMachineCleanerRelayStatus(false)');
|
||||
expect($routeContent)->toContain('setMachineRelayStatus(false)');
|
||||
expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)');
|
||||
expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)');
|
||||
expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)');
|
||||
expect($routeContent)->not->toContain('setMachineCleanerRelayStatus(false)');
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
it('registers an opening-time self-serve cleaner relay activation cron', function (): void {
|
||||
$tasks = require app_path('modules/selfserve/cron/tasks.php');
|
||||
|
||||
expect($tasks)->toHaveCount(1);
|
||||
expect($tasks[0]['id'])->toBe('selfserve.activate_opening_cleaner_relays');
|
||||
expect($tasks[0]['legacy_name'])->toBe('SelfserveOpeningRelayActivationCron');
|
||||
expect($tasks[0]['handler'])->toBe('SelfserveOpeningRelayActivationCron');
|
||||
expect($tasks[0]['schedule'])->toBe(['type' => 'interval', 'seconds' => 60]);
|
||||
});
|
||||
|
||||
it('activates only configured cleaner relays after department opening time', function (): void {
|
||||
$cronContent = file_get_contents(app_path('cron/Cron.php'));
|
||||
|
||||
expect($cronContent)->not->toBeFalse();
|
||||
expect($cronContent)->toContain('function SelfserveOpeningRelayActivationCron(): array');
|
||||
expect($cronContent)->toContain("new DateTimeZone('Europe/Copenhagen')");
|
||||
expect($cronContent)->toContain('department_time_bookings_opening_hours');
|
||||
expect($cronContent)->toContain("dv.variable = 'selfserve_enabled'");
|
||||
expect($cronContent)->toContain('dl.selfserve_enabled');
|
||||
expect($cronContent)->toContain('relay_machine_cleaner_id');
|
||||
expect($cronContent)->toContain('$nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds');
|
||||
expect($cronContent)->toContain('setMachineCleanerRelayStatusHard(true)');
|
||||
expect($cronContent)->toContain('selfserve:opening-cleaner-relays:');
|
||||
expect($cronContent)->not->toContain('setMachineRelayStatusHard(true)');
|
||||
expect($cronContent)->not->toContain('setMachineProgramPickerRelayStatusHard(true)');
|
||||
});
|
||||
Reference in New Issue
Block a user