mirror of
https://github.com/OPSnet/Gazelle.git
synced 2026-01-16 18:04:34 -05:00
simplify task scheduler implementation
This commit is contained in:
21
app/Task.php
21
app/Task.php
@@ -7,23 +7,26 @@ use Gazelle\Util\Irc;
|
||||
abstract class Task extends Base {
|
||||
protected array $events = [];
|
||||
protected int $processed = 0;
|
||||
protected float $startTime;
|
||||
protected int $historyId;
|
||||
protected float $startTime;
|
||||
|
||||
abstract public function run(): void;
|
||||
|
||||
public function __construct(
|
||||
protected readonly int $taskId,
|
||||
protected readonly string $name,
|
||||
protected readonly bool $isDebug,
|
||||
) {}
|
||||
public readonly int $taskId,
|
||||
public readonly string $name,
|
||||
public readonly bool $isDebug,
|
||||
) {
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
public function begin(): void {
|
||||
$this->startTime = microtime(true);
|
||||
self::$db->prepared_query('
|
||||
INSERT INTO periodic_task_history
|
||||
(periodic_task_id)
|
||||
VALUES (?)
|
||||
', $this->taskId);
|
||||
|
||||
', $this->taskId
|
||||
);
|
||||
$this->historyId = self::$db->inserted_id();
|
||||
}
|
||||
|
||||
@@ -94,6 +97,4 @@ abstract class Task extends Base {
|
||||
public function error(string $message, int $reference = 0): void {
|
||||
$this->log($message, 'error', $reference);
|
||||
}
|
||||
|
||||
abstract public function run(): void;
|
||||
}
|
||||
|
||||
@@ -2,104 +2,59 @@
|
||||
|
||||
namespace Gazelle;
|
||||
|
||||
use Gazelle\Util\Irc;
|
||||
/* Note: tasks are created and removed via a phinx migration. There
|
||||
* is no ability to create a task from the site because the task
|
||||
* code will need to be added via a repo commit in any event.
|
||||
*/
|
||||
|
||||
class TaskScheduler extends Base {
|
||||
final public const CACHE_TASKS = 'scheduled_tasks';
|
||||
|
||||
public function getTask(int $id): ?array {
|
||||
$tasks = $this->getTasks();
|
||||
return array_key_exists($id, $tasks) ? $tasks[$id] : null;
|
||||
protected Util\SortableTableHeader $heading;
|
||||
|
||||
public function findById(int $taskId): ?array {
|
||||
$tasks = $this->taskList();
|
||||
return array_key_exists($taskId, $tasks) ? $tasks[$taskId] : null;
|
||||
}
|
||||
|
||||
public function getTasks(): array {
|
||||
if (!$tasks = self::$cache->get_value(self::CACHE_TASKS)) {
|
||||
self::$db->prepared_query('
|
||||
SELECT periodic_task_id, name, classname, description, period, is_enabled, is_sane, is_debug, run_now
|
||||
FROM periodic_task
|
||||
');
|
||||
|
||||
$tasks = self::$db->to_array('periodic_task_id', MYSQLI_ASSOC);
|
||||
self::$cache->cache_value(self::CACHE_TASKS, $tasks, 3600);
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
public function getInsaneTasks(): int {
|
||||
return count(array_filter($this->getTasks(),
|
||||
fn($v) => !$v['is_sane']
|
||||
));
|
||||
}
|
||||
|
||||
public static function isClassValid(string $class): bool {
|
||||
$class = 'Gazelle\\Task\\' . $class;
|
||||
return class_exists($class);
|
||||
}
|
||||
|
||||
public function flush(): static {
|
||||
self::$cache->delete_value(self::CACHE_TASKS);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function createTask(string $name, string $class, string $description, int $period, bool $isEnabled, bool $isSane, bool $isDebug): void {
|
||||
if (!self::isClassValid($class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$db->prepared_query("
|
||||
INSERT INTO periodic_task
|
||||
(name, classname, description, period, is_enabled, is_sane, is_debug)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?)
|
||||
", $name, $class, $description, $period, (int)$isEnabled, (int)$isSane, (int)$isDebug);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function updateTask(int $id, string $name, string $class, string $description, int $period, bool $isEnabled, bool $isSane, bool $isDebug): void {
|
||||
if (!self::isClassValid($class)) {
|
||||
return;
|
||||
}
|
||||
self::$db->prepared_query("
|
||||
UPDATE periodic_task SET
|
||||
name = ?,
|
||||
classname = ?,
|
||||
description = ?,
|
||||
period = ?,
|
||||
is_enabled = ?,
|
||||
is_sane = ?,
|
||||
is_debug = ?
|
||||
WHERE periodic_task_id = ?
|
||||
", $name, $class, $description, $period, (int)$isEnabled, (int)$isSane, (int)$isDebug, $id);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function runNow(int $id): void {
|
||||
self::$db->prepared_query("
|
||||
UPDATE periodic_task SET
|
||||
run_now = 1 - run_now
|
||||
WHERE periodic_task_id = ?
|
||||
", $id
|
||||
public function findByName(string $className): ?array {
|
||||
return $this->findById(
|
||||
(int)self::$db->scalar("
|
||||
SELECT pt.periodic_task_id FROM periodic_task pt WHERE pt.classname = ?
|
||||
", $className
|
||||
)
|
||||
);
|
||||
$this->flush();
|
||||
}
|
||||
|
||||
public function deleteTask(int $id): void {
|
||||
self::$db->prepared_query("
|
||||
DELETE FROM periodic_task WHERE periodic_task_id = ?
|
||||
", $id);
|
||||
$this->flush();
|
||||
public function heading(): Util\SortableTableHeader {
|
||||
return $this->heading ??= new Util\SortableTableHeader(
|
||||
'next', [
|
||||
'name' => ['dbColumn' => 'name', 'defaultSort' => 'asc', 'text' => 'Name'],
|
||||
'period' => ['dbColumn' => 'period', 'defaultSort' => 'asc', 'text' => 'Interval'],
|
||||
'runs' => ['dbColumn' => 'runs', 'defaultSort' => 'desc', 'text' => 'Runs'],
|
||||
'duration' => ['dbColumn' => 'duration', 'defaultSort' => 'desc', 'text' => 'Duration'],
|
||||
'processed' => ['dbColumn' => 'processed', 'defaultSort' => 'desc', 'text' => 'Processed'],
|
||||
'status' => ['dbColumn' => 'status', 'defaultSort' => 'desc', 'text' => 'Status'],
|
||||
'errors' => ['dbColumn' => 'errors', 'defaultSort' => 'desc', 'text' => 'Errors'],
|
||||
'events' => ['dbColumn' => 'events', 'defaultSort' => 'desc', 'text' => 'Events'],
|
||||
'last' => ['dbColumn' => "last_run IS NULL ASC, is_enabled DESC, last_run", 'defaultSort' => 'desc', 'text' => 'Last Run'],
|
||||
'next' => ['dbColumn' => 'next_run IS NULL ASC, is_enabled DESC, next_run', 'defaultSort' => 'desc', 'text' => 'Next Run'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getTaskDetails(int $days = 7): array {
|
||||
public function taskDetailList(int $days = 7): array {
|
||||
self::$db->prepared_query("
|
||||
SELECT pt.periodic_task_id, name, description, period, is_enabled, is_sane, run_now,
|
||||
coalesce(stats.runs, 0) runs, coalesce(stats.processed, 0) processed,
|
||||
coalesce(stats.errors, 0) errors, coalesce(events.events, 0) events,
|
||||
coalesce(pth.launch_time, '') last_run,
|
||||
coalesce(pth.duration_ms, 0) duration,
|
||||
coalesce(pth.status, '') status,
|
||||
if(pth.launch_time is null, now(), pth.launch_time + INTERVAL period SECOND) AS next_run
|
||||
coalesce(stats.runs, 0) AS runs,
|
||||
coalesce(stats.processed, 0) AS processed,
|
||||
coalesce(stats.errors, 0) AS errors,
|
||||
coalesce(events.events, 0) AS events,
|
||||
coalesce(pth.duration_ms, 0) AS duration,
|
||||
coalesce(pth.status, '') AS status,
|
||||
pth.launch_time AS last_run,
|
||||
pth.launch_time + INTERVAL period SECOND
|
||||
AS next_run
|
||||
FROM periodic_task pt
|
||||
LEFT JOIN
|
||||
(
|
||||
@@ -118,41 +73,80 @@ class TaskScheduler extends Base {
|
||||
GROUP BY pth.periodic_task_id
|
||||
) events ON (pt.periodic_task_id = events.periodic_task_id)
|
||||
LEFT JOIN periodic_task_history pth ON (stats.latest = pth.periodic_task_history_id)
|
||||
ORDER BY pt.run_now DESC, pt.is_enabled DESC, pt.period, pt.periodic_task_id
|
||||
", $days, $days);
|
||||
|
||||
ORDER BY {$this->heading()->orderBy()} {$this->heading()->dir()}, name ASC
|
||||
", $days, $days
|
||||
);
|
||||
return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
public function getTotal(int $id): int {
|
||||
public function taskList(): array {
|
||||
self::$db->prepared_query("
|
||||
SELECT periodic_task_id, name, classname, description, period, is_enabled, is_sane, is_debug, run_now
|
||||
FROM periodic_task
|
||||
");
|
||||
return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
public function insaneTaskList(): int {
|
||||
return count(array_filter($this->taskList(),
|
||||
fn ($v) => !$v['is_sane']
|
||||
));
|
||||
}
|
||||
|
||||
public static function isClassValid(string $class): bool {
|
||||
return class_exists('Gazelle\\Task\\' . $class);
|
||||
}
|
||||
|
||||
public function updateTask(
|
||||
int $taskId,
|
||||
string $name,
|
||||
string $class,
|
||||
string $description,
|
||||
int $period,
|
||||
bool $isEnabled,
|
||||
bool $isSane,
|
||||
bool $isDebug
|
||||
): int {
|
||||
if (!self::isClassValid($class)) {
|
||||
return 0;
|
||||
}
|
||||
self::$db->prepared_query("
|
||||
UPDATE periodic_task SET
|
||||
name = ?,
|
||||
classname = ?,
|
||||
description = ?,
|
||||
period = ?,
|
||||
is_enabled = ?,
|
||||
is_sane = ?,
|
||||
is_debug = ?
|
||||
WHERE periodic_task_id = ?
|
||||
", $name, $class, $description, $period,
|
||||
(int)$isEnabled, (int)$isSane, (int)$isDebug,
|
||||
$taskId,
|
||||
);
|
||||
return self::$db->affected_rows();
|
||||
}
|
||||
|
||||
public function taskRunTotal(int $taskId): int {
|
||||
return (int)self::$db->scalar("
|
||||
SELECT count(*) FROM periodic_task_history WHERE periodic_task_id = ?
|
||||
", $id
|
||||
", $taskId
|
||||
);
|
||||
}
|
||||
|
||||
public function getTaskHistory(int $id, int $limit, int $offset, string $sort, string $direction): ?TaskScheduler\TaskHistory {
|
||||
$sortMap = [
|
||||
'id' => 'periodic_task_history_id',
|
||||
'launchtime' => 'launch_time',
|
||||
'status' => 'status',
|
||||
'errors' => 'num_errors',
|
||||
'items' => 'num_items',
|
||||
'duration' => 'duration_ms'
|
||||
];
|
||||
|
||||
if (!isset($sortMap[$sort])) {
|
||||
return null;
|
||||
}
|
||||
$sort = $sortMap[$sort];
|
||||
|
||||
public function taskHistory(
|
||||
int $taskId,
|
||||
int $limit,
|
||||
int $offset,
|
||||
): ?TaskScheduler\TaskHistory {
|
||||
self::$db->prepared_query("
|
||||
SELECT periodic_task_history_id, launch_time, status, num_errors, num_items, duration_ms
|
||||
FROM periodic_task_history
|
||||
WHERE periodic_task_id = ?
|
||||
ORDER BY $sort $direction
|
||||
ORDER BY launch_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
", $id, $limit, $offset);
|
||||
", $taskId, $limit, $offset
|
||||
);
|
||||
$items = self::$db->to_array('periodic_task_history_id', MYSQLI_ASSOC);
|
||||
|
||||
$historyEvents = [];
|
||||
@@ -171,14 +165,17 @@ class TaskScheduler extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
$task = new TaskScheduler\TaskHistory($this->getTask($id)['name'], $this->getTotal($id));
|
||||
$history = new TaskScheduler\TaskHistory(
|
||||
$this->findById($taskId)['name'],
|
||||
$this->taskRunTotal($taskId)
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
[$historyId, $launchTime, $status, $numErrors, $numItems, $duration] = array_values($item);
|
||||
$taskEvents = $historyEvents[$historyId] ?? [];
|
||||
$task->items[] = new TaskScheduler\HistoryItem($launchTime, $status, $numErrors, $numItems, $duration, $taskEvents);
|
||||
$history->items[] = new TaskScheduler\HistoryItem($launchTime, $status, $numErrors, $numItems, $duration, $taskEvents);
|
||||
}
|
||||
|
||||
return $task;
|
||||
return $history;
|
||||
}
|
||||
|
||||
private function constructAxes(array $data, string $key, array $axes, bool $time): array {
|
||||
@@ -186,17 +183,17 @@ class TaskScheduler extends Base {
|
||||
|
||||
foreach ($axes as $axis) {
|
||||
if (is_array($axis)) {
|
||||
$id = $axis[0];
|
||||
$taskId = $axis[0];
|
||||
$name = $axis[1];
|
||||
} else {
|
||||
$id = $axis;
|
||||
$taskId = $axis;
|
||||
$name = $axis;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'name' => $name,
|
||||
'data' => array_map(
|
||||
fn($v) => [$time ? strtotime($v[$key]) * 1000 : $v[$key], (int)$v[$id]],
|
||||
fn ($v) => [$time ? strtotime($v[$key]) * 1000 : $v[$key], (int)$v[$taskId]],
|
||||
$data
|
||||
)
|
||||
];
|
||||
@@ -204,7 +201,7 @@ class TaskScheduler extends Base {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getRuntimeStats(int $days = 90): array {
|
||||
public function runtimeStats(int $days = 90): array {
|
||||
self::$db->prepared_query("
|
||||
SELECT date_format(pth.launch_time, '%Y-%m-%d %H:00:00') AS date,
|
||||
sum(pth.duration_ms) AS duration,
|
||||
@@ -268,7 +265,7 @@ class TaskScheduler extends Base {
|
||||
];
|
||||
}
|
||||
|
||||
public function getTaskRuntimeStats(int $taskId, int $days = 90): array {
|
||||
public function taskRuntimeStats(int $taskId, int $days = 90): array {
|
||||
self::$db->prepared_query("
|
||||
SELECT date(pth.launch_time) AS date,
|
||||
sum(pth.duration_ms) AS duration,
|
||||
@@ -285,42 +282,41 @@ class TaskScheduler extends Base {
|
||||
return $this->constructAxes(self::$db->to_array(false, MYSQLI_ASSOC), 'date', ['duration', 'processed'], true);
|
||||
}
|
||||
|
||||
public function getTaskSnapshot(float $start, float $end): array {
|
||||
self::$db->prepared_query('
|
||||
SELECT pt.periodic_task_id, pt.name, pth.launch_time, pth.status, pth.num_errors, pth.num_items, pth.duration_ms
|
||||
FROM periodic_task pt
|
||||
INNER JOIN periodic_task_history pth USING (periodic_task_id)
|
||||
WHERE pth.launch_time <= ? AND pth.launch_time + INTERVAL pth.duration_ms / 1000 SECOND >= ?
|
||||
', $end, $start
|
||||
public function runNow(int $taskId): int {
|
||||
self::$db->prepared_query("
|
||||
UPDATE periodic_task SET
|
||||
run_now = 1 - run_now
|
||||
WHERE periodic_task_id = ?
|
||||
", $taskId
|
||||
);
|
||||
|
||||
return self::$db->to_array('periodic_task_id', MYSQLI_ASSOC);
|
||||
return self::$db->affected_rows();
|
||||
}
|
||||
|
||||
public function run(): void {
|
||||
public function run(): int {
|
||||
$pendingMigrations = array_filter(
|
||||
json_decode(
|
||||
(string)shell_exec(BIN_PHINX . ' status -c ' . PHINX_MYSQL . ' --format=json | tail -n 1'),
|
||||
true
|
||||
)['migrations'],
|
||||
fn($value) => count($value) > 0 && $value['migration_status'] === 'down'
|
||||
fn ($value) => count($value) > 0 && $value['migration_status'] === 'down'
|
||||
);
|
||||
|
||||
if ($pendingMigrations) {
|
||||
Irc::sendMessage(IRC_CHAN_DEV, 'Pending migrations found, scheduler cannot continue');
|
||||
Util\Irc::sendMessage(IRC_CHAN_DEV, 'Pending migrations found, scheduler cannot continue');
|
||||
echo "Pending migrations found, aborting\n";
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* We attempt to run as many tasks as we can within a minute. If a task
|
||||
* runs over the TTL, it will be noted as in progress, so the next
|
||||
* invocation of the scheduler will ignore it. When the task finally
|
||||
* invocation of the scheduler ignores it. When the task finally
|
||||
* returns, this invocation will exit.
|
||||
* If a task fails, do not try to run again in this slice.
|
||||
*/
|
||||
|
||||
$fail = [0];
|
||||
$run = 0;
|
||||
|
||||
$TTL = microtime(true) + 58;
|
||||
while (microtime(true) < $TTL) {
|
||||
@@ -351,34 +347,37 @@ class TaskScheduler extends Base {
|
||||
", ...$fail
|
||||
);
|
||||
if (!$taskId) {
|
||||
// no tasks remaining to be run
|
||||
break;
|
||||
}
|
||||
$run++;
|
||||
$result = $this->runTask($taskId);
|
||||
if ($result == -1) {
|
||||
$fail[] = $taskId;
|
||||
}
|
||||
}
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function runClass(string $className, bool $debug = false): int {
|
||||
return $this->runTask(
|
||||
(int)self::$db->scalar("
|
||||
SELECT pt.periodic_task_id FROM periodic_task pt WHERE pt.classname = ?
|
||||
", $className
|
||||
), $debug
|
||||
);
|
||||
}
|
||||
|
||||
public function runTask(int $id, bool $debug = false): int {
|
||||
$task = $this->getTask($id);
|
||||
$task = $this->findByName($className);
|
||||
if ($task === null) {
|
||||
return -1;
|
||||
}
|
||||
echo('Running task ' . $task['name'] . "...");
|
||||
return $this->runTask($task['periodic_task_id'], $debug);
|
||||
}
|
||||
|
||||
$taskRunner = $this->createRunner($id, $task['name'], $task['classname'], $task['is_debug'] || $debug);
|
||||
public function runTask(int $taskId, bool $debug = false): int {
|
||||
$task = $this->findById($taskId);
|
||||
if ($task === null) {
|
||||
return -1;
|
||||
}
|
||||
echo "Running task {$task['name']}...";
|
||||
|
||||
$taskRunner = $this->createRunner($taskId, $task['name'], $task['classname'], $task['is_debug'] || $debug);
|
||||
if ($taskRunner === null) {
|
||||
Irc::sendMessage(IRC_CHAN_DEV, "Failed to construct task {$task['name']}");
|
||||
echo "DONE! (0.000)\n";
|
||||
Util\Irc::sendMessage(IRC_CHAN_DEV, "Failed to construct task {$task['name']}");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -409,18 +408,17 @@ class TaskScheduler extends Base {
|
||||
UPDATE periodic_task SET
|
||||
run_now = FALSE
|
||||
WHERE periodic_task_id = ?
|
||||
', $id
|
||||
', $taskId
|
||||
);
|
||||
$this->flush();
|
||||
}
|
||||
return $processed;
|
||||
}
|
||||
|
||||
private function createRunner(int $id, string $name, string $class, bool $isDebug): mixed {
|
||||
private function createRunner(int $taskId, string $name, string $class, bool $isDebug): mixed {
|
||||
$class = 'Gazelle\\Task\\' . $class;
|
||||
if (!class_exists($class)) {
|
||||
return null;
|
||||
}
|
||||
return new $class($id, $name, $isDebug);
|
||||
return new $class($taskId, $name, $isDebug);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +138,12 @@ class Activity extends \Gazelle\BaseUser {
|
||||
if ($lastSchedulerRun > SCHEDULER_DELAY) {
|
||||
$this->setAlert("<span class=\"sys-error\" title=\"Cron scheduler not running\">CRON</span>");
|
||||
}
|
||||
$insane = $scheduler->getInsaneTasks();
|
||||
$insane = $scheduler->insaneTaskList();
|
||||
if ($insane) {
|
||||
$plural = plural($insane);
|
||||
$this->setAlert("<a title=\"$insane insane task$plural\" href=\"tools.php?action=periodic&mode=view\"><span class=\"sys-error\">TASK</span></a>");
|
||||
$this->setAlert("<a title=\"$insane insane task"
|
||||
. plural($insane)
|
||||
. "\" href=\"tools.php?action=periodic&mode=view\"><span class=\"sys-error\">TASK</span></a>"
|
||||
);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
|
||||
@@ -9,17 +9,9 @@ if (!$Viewer->permitted('admin_periodic_task_manage')) {
|
||||
Error403::error();
|
||||
}
|
||||
|
||||
authorize();
|
||||
|
||||
$scheduler = new TaskScheduler();
|
||||
$taskId = (int)($_POST['id'] ?? 0);
|
||||
if ($_POST['submit'] == 'Delete') {
|
||||
if (!$taskId) {
|
||||
$err = 'Unknown or missing task id for delete';
|
||||
} else {
|
||||
$scheduler->deleteTask($taskId);
|
||||
}
|
||||
} else {
|
||||
$taskId = (int)($_POST['id'] ?? 0);
|
||||
if ($taskId && $_POST['submit'] == 'Edit') {
|
||||
authorize();
|
||||
$validator = new Util\Validator();
|
||||
$validator->setFields([
|
||||
['name', true, 'string', 'The name must be set, and has a max length of 64 characters', ['maxlength' => 64]],
|
||||
@@ -29,26 +21,14 @@ if ($_POST['submit'] == 'Delete') {
|
||||
]);
|
||||
$err = $validator->validate($_POST) ? false : $validator->errorMessage();
|
||||
if ($err === false) {
|
||||
if ($_POST['submit'] == 'Create') {
|
||||
if (!$scheduler::isClassValid($_POST['classname'])) {
|
||||
$err = "Cannot import class " . $_POST['classname'];
|
||||
} else {
|
||||
$scheduler->createTask($_POST['name'], $_POST['classname'], $_POST['description'], intval($_POST['interval']),
|
||||
isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug'])
|
||||
);
|
||||
}
|
||||
} elseif ($_POST['submit'] == 'Edit') {
|
||||
if (!$taskId) {
|
||||
$err = 'Unknown or missing task id for edit';
|
||||
}
|
||||
$task = $scheduler->getTask($taskId);
|
||||
if ($task == null) {
|
||||
$err = "Task $taskId not found";
|
||||
}
|
||||
$scheduler->updateTask($taskId, $_POST['name'], $_POST['classname'], $_POST['description'],
|
||||
(int)$_POST['interval'], isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug'])
|
||||
);
|
||||
$scheduler = new TaskScheduler();
|
||||
$task = $scheduler->findById($taskId);
|
||||
if ($task == null) {
|
||||
$err = "Task $taskId not found";
|
||||
}
|
||||
$scheduler->updateTask($taskId, $_POST['name'], $_POST['classname'], $_POST['description'],
|
||||
(int)$_POST['interval'], isset($_POST['enabled']), isset($_POST['sane']), isset($_POST['debug'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,31 +11,23 @@ if (!$Viewer->permitted('admin_periodic_task_view')) {
|
||||
}
|
||||
|
||||
$scheduler = new TaskScheduler();
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if (!$scheduler->getTask($id)) {
|
||||
$taskId = (int)($_GET['id'] ?? 0);
|
||||
if (!$scheduler->findById($taskId)) {
|
||||
Error404::error();
|
||||
}
|
||||
|
||||
$header = new Util\SortableTableHeader('launchtime', [
|
||||
'id' => ['defaultSort' => 'desc'],
|
||||
'launchtime' => ['defaultSort' => 'desc', 'text' => 'Launch Time'],
|
||||
'duration' => ['defaultSort' => 'desc', 'text' => 'Duration'],
|
||||
'status' => ['defaultSort' => 'desc', 'text' => 'Status'],
|
||||
'items' => ['defaultSort' => 'desc', 'text' => 'Processed'],
|
||||
'errors' => ['defaultSort' => 'desc', 'text' => 'Errors']
|
||||
]);
|
||||
|
||||
$paginator = new Util\Paginator(ITEMS_PER_PAGE, (int)($_GET['page'] ?? 1));
|
||||
$paginator->setTotal($scheduler->getTotal($id));
|
||||
|
||||
$stats = $scheduler->getTaskRuntimeStats($id);
|
||||
$paginator->setTotal($scheduler->taskRunTotal($taskId));
|
||||
$stats = $scheduler->taskRuntimeStats($taskId);
|
||||
|
||||
echo $Twig->render('admin/scheduler/task.twig', [
|
||||
'header' => $header,
|
||||
'stats' => $scheduler->getTaskRuntimeStats($id),
|
||||
'header' => $scheduler->heading(),
|
||||
'stats' => $stats,
|
||||
'duration' => json_encode($stats[0]['data']),
|
||||
'processed' => json_encode($stats[1]['data']),
|
||||
'task' => $scheduler->getTaskHistory($id, $paginator->limit(), $paginator->offset(), $header->orderKey(), $header->dir()),
|
||||
'task' => $scheduler->taskHistory(
|
||||
$taskId, $paginator->limit(), $paginator->offset()
|
||||
),
|
||||
'paginator' => $paginator,
|
||||
'viewer' => $Viewer,
|
||||
]);
|
||||
|
||||
@@ -12,6 +12,6 @@ if (!$Viewer->permitted('admin_periodic_task_manage')) {
|
||||
|
||||
echo $Twig->render('admin/scheduler/edit.twig', [
|
||||
'err' => $err ?? null,
|
||||
'task_list' => (new TaskScheduler())->getTasks(),
|
||||
'task_list' => (new TaskScheduler())->taskList(),
|
||||
'viewer' => $Viewer,
|
||||
]);
|
||||
|
||||
@@ -23,7 +23,7 @@ $processed = $scheduler->runTask($taskId, true);
|
||||
$output = ob_get_flush();
|
||||
|
||||
echo $Twig->render('admin/scheduler/run.twig', [
|
||||
'task' => $scheduler->getTask($taskId),
|
||||
'task' => $scheduler->findById($taskId),
|
||||
'output' => $output,
|
||||
'processed' => $processed,
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ if (!$Viewer->permitted('admin_periodic_task_view')) {
|
||||
Error403::error();
|
||||
}
|
||||
|
||||
$stats = (new TaskScheduler())->getRuntimeStats();
|
||||
$stats = (new TaskScheduler())->runtimeStats();
|
||||
echo $Twig->render('admin/scheduler/stats.twig', [
|
||||
'hourly' => [
|
||||
'duration' => json_encode($stats['hourly'][0]['data']),
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!$Viewer->permitted('admin_periodic_task_view')) {
|
||||
}
|
||||
|
||||
$scheduler = new TaskScheduler();
|
||||
$taskId = (int)($_REQUEST['id'] ?? 0);
|
||||
$taskId = (int)($_REQUEST['id'] ?? 0);
|
||||
|
||||
if ($taskId && $_REQUEST['mode'] === 'run_now') {
|
||||
if (!$Viewer->permitted('admin_schedule')) {
|
||||
@@ -22,6 +22,7 @@ if ($taskId && $_REQUEST['mode'] === 'run_now') {
|
||||
}
|
||||
|
||||
echo $Twig->render('admin/scheduler/view.twig', [
|
||||
'task_list' => $scheduler->getTaskDetails(),
|
||||
'viewer' => $Viewer,
|
||||
'heading' => $scheduler->heading(),
|
||||
'task_list' => $scheduler->taskDetailList(),
|
||||
'viewer' => $Viewer,
|
||||
]);
|
||||
|
||||
@@ -48,44 +48,9 @@
|
||||
</td>
|
||||
<td>
|
||||
<input type="submit" name="submit" value="Edit" />
|
||||
<input type="submit" name="submit" value="Delete" onclick="return confirm('Are you sure you want to delete this task? This is an irreversible action!')" />
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="colhead">
|
||||
<td colspan="8">Create Task</td>
|
||||
</tr>
|
||||
<tr class="rowa">
|
||||
<form class="create_form" name="accounts" action="" method="post">
|
||||
<input type="hidden" name="action" value="periodic" />
|
||||
<input type="hidden" name="mode" value="alter" />
|
||||
<input type="hidden" name="auth" value="{{ viewer.auth }}" />
|
||||
<td>
|
||||
<input type="text" size="10" name="name" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" size="15" name="classname" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" size="10" name="description" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" size="10" name="interval" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="enabled" checked="checked" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="sane" checked="checked" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="debug" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="submit" name="submit" value="Create" />
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
</table>
|
||||
{{ footer() }}
|
||||
|
||||
@@ -3,52 +3,64 @@
|
||||
<h2>Scheduler › Status</h2>
|
||||
</div>
|
||||
{% include 'admin/scheduler/links.twig' with {'can_edit': viewer.permitted('admin_periodic_task_manage')} only %}
|
||||
<div class="thin">
|
||||
Note: tasks are created and removed via Phinx migrations. When removing a task, remember to
|
||||
remove the code as well as the database entry.</div>
|
||||
<br />
|
||||
<div class="thin">
|
||||
<a href="#" onclick="$('#tasks .reltime').gtoggle(); $('#tasks .abstime').gtoggle(); return false;" class="brackets">Toggle</a>
|
||||
date display between relative and absolute.
|
||||
</div>
|
||||
<br />
|
||||
<table width="100%" id="tasks">
|
||||
<tr class="colhead">
|
||||
<td>Name</td>
|
||||
<td>Interval</td>
|
||||
<td>Last Run <a href="#" onclick="$('#tasks .reltime').gtoggle(); $('#tasks .abstime').gtoggle(); return false;" class="brackets">Toggle</a></td>
|
||||
<td>Duration</td>
|
||||
<td>Next Run</td>
|
||||
<td>Status</td>
|
||||
<td>Runs</td>
|
||||
<td>Processed</td>
|
||||
<td>Errors</td>
|
||||
<td>Events</td>
|
||||
<td>{{ heading.emit('name')|raw }}</td>
|
||||
<td>{{ heading.emit('period')|raw }}</td>
|
||||
<td>{{ heading.emit('last')|raw }}</td>
|
||||
<td>{{ heading.emit('duration')|raw }}</td>
|
||||
<td>{{ heading.emit('next')|raw }}</td>
|
||||
<td>{{ heading.emit('status')|raw }}</td>
|
||||
<td>{{ heading.emit('runs')|raw }}</td>
|
||||
<td>{{ heading.emit('processed')|raw }}</td>
|
||||
<td>{{ heading.emit('errors')|raw }}</td>
|
||||
<td>{{ heading.emit('events')|raw }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for t in task_list %}
|
||||
{% set color = '' %}
|
||||
{% set prefix = '' %}
|
||||
{% if not t.is_sane %}
|
||||
{% set color = 'color:tomato;' %}
|
||||
{% set prefix = 'Insane: ' %}
|
||||
{% endif %}
|
||||
{% if not t.is_enabled and not t.run_now %}
|
||||
{% set color = 'color:sandybrown;' %}
|
||||
{% set prefix = prefix ~ 'Disabled: ' %}
|
||||
{% endif %}
|
||||
{% if t.run_now %}
|
||||
{% set color = 'color:green;' %}
|
||||
{% set prefix = prefix ~ 'Run Now: ' %}
|
||||
{% endif %}
|
||||
{% set color = '' %}
|
||||
{% set prefix = '' %}
|
||||
{% if not t.is_sane %}
|
||||
{% set color = 'color:tomato;' %}
|
||||
{% set prefix = '😵 ' %}
|
||||
{% endif %}
|
||||
{% if not t.is_enabled and not t.run_now %}
|
||||
{% set color = 'color:sandybrown;' %}
|
||||
{% set prefix = prefix ~ '❌ ' %}
|
||||
{% endif %}
|
||||
{% if t.run_now %}
|
||||
{% set color = 'color:green;' %}
|
||||
{% set prefix = prefix ~ 'Run Now: ' %}
|
||||
{% endif %}
|
||||
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
|
||||
<td title="{{ t.description }}">
|
||||
<a style="{{ color }}" href="tools.php?action=periodic&mode=detail&id={{ t.periodic_task_id }}">{{ prefix }}{{ t.name }}</a>
|
||||
<td title="{% if not t.is_sane %}Insane! {% endif %}{% if not t.is_enabled
|
||||
%}Disabled! {% endif %}{{ t.description }}">
|
||||
<a style="{{ color }}" href="tools.php?action=periodic&mode=detail&id={{
|
||||
t.periodic_task_id }}">{{ prefix }}{{ t.name }}</a>
|
||||
</td>
|
||||
<td>{{ t.period|time_compact }}</td>
|
||||
<td>
|
||||
<span class="reltime">{% if t.last_run %}{{ t.last_run|time_diff }}{% else %}Never{% endif %}</span>
|
||||
<span class="reltime">{% if t.last_run %}{{ t.last_run|time_diff
|
||||
}}{% else %}Never{% endif %}</span>
|
||||
<span class="abstime hidden">{{ t.last_run|default('Never') }}</span>
|
||||
</td>
|
||||
<td>{{ t.duration }}ms</td>
|
||||
<td>
|
||||
{% if not t.is_enabled %}
|
||||
{% if not t.is_enabled %}
|
||||
Never
|
||||
{% else %}
|
||||
{% else %}
|
||||
<span class="reltime">{{ t.next_run|time_diff }}</span>
|
||||
<span class="abstime hidden">{{ t.next_run }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ t.status|default('-') }}</td>
|
||||
<td class="number_column">{{ t.runs|number_format }}</td>
|
||||
@@ -56,10 +68,12 @@
|
||||
<td class="number_column">{{ t.errors|number_format }}</td>
|
||||
<td class="number_column">{{ t.events|number_format }}</td>
|
||||
<td>
|
||||
{% if viewer.permitted('admin_schedule') %}
|
||||
<a class="brackets" href="tools.php?action=periodic&auth={{ viewer.auth }}&id={{ t.periodic_task_id }}&mode=enqueue">Enqueue</a>
|
||||
<a class="brackets" href="tools.php?action=periodic&auth={{ viewer.auth }}&id={{ t.periodic_task_id }}&mode=run">Run</a>
|
||||
{% endif %}
|
||||
{% if viewer.permitted('admin_schedule') %}
|
||||
<a class="brackets" href="tools.php?action=periodic&auth={{
|
||||
viewer.auth }}&id={{ t.periodic_task_id }}&mode=enqueue">Enqueue</a>
|
||||
<a class="brackets" href="tools.php?action=periodic&auth={{
|
||||
viewer.auth }}&id={{ t.periodic_task_id }}&mode=run">Run</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,13 +4,14 @@ namespace Gazelle;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use GazelleUnitTest\Helper;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects.FoundWithSymbols
|
||||
ini_set('memory_limit', '1G');
|
||||
// phpcs:enable
|
||||
|
||||
class SchedulerTest extends TestCase {
|
||||
public function testRun(): void {
|
||||
public function testGlobalRun(): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$this->expectOutputRegex('/^(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \[(?:debug|info)\] (.*?)\n|Running task (?:.*?)\.\.\.DONE! \(\d+\.\d+\)\n)*$/');
|
||||
$scheduler->run();
|
||||
@@ -42,7 +43,15 @@ class SchedulerTest extends TestCase {
|
||||
|
||||
public function testMissingTaskEntry(): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$this->assertEquals(-1, $scheduler->runClass("NoSuchClassname"), "sched-task-no-such-class");
|
||||
$this->assertEquals(
|
||||
-1,
|
||||
$scheduler->runClass("NoSuchClassname"),
|
||||
"sched-task-no-such-class"
|
||||
);
|
||||
$this->assertFalse(
|
||||
$scheduler->isClassValid("NoSuchClassname"),
|
||||
"sched-is-class-valid"
|
||||
);
|
||||
}
|
||||
|
||||
public function testMissingImplementation(): void {
|
||||
@@ -60,7 +69,13 @@ class SchedulerTest extends TestCase {
|
||||
",
|
||||
$name, "phpunit task", "A task with no PHP implementation"
|
||||
);
|
||||
$this->assertEquals(-1, $scheduler->runClass($name), "sched-task-unimplemented");
|
||||
ob_start();
|
||||
$this->assertEquals(
|
||||
-1,
|
||||
$scheduler->runClass($name),
|
||||
"sched-task-unimplemented"
|
||||
);
|
||||
ob_end_clean();
|
||||
$db->prepared_query("
|
||||
DELETE FROM periodic_task WHERE classname = ?
|
||||
", $name
|
||||
@@ -70,6 +85,10 @@ class SchedulerTest extends TestCase {
|
||||
#[DataProvider('taskProvider')]
|
||||
public function testTask(string $taskName): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$this->assertTrue(
|
||||
$scheduler->isClassValid($taskName),
|
||||
"sched-has-task-$taskName"
|
||||
);
|
||||
ob_start();
|
||||
$this->assertGreaterThanOrEqual(
|
||||
-1,
|
||||
@@ -80,15 +99,13 @@ class SchedulerTest extends TestCase {
|
||||
}
|
||||
|
||||
public static function taskProvider(): array {
|
||||
$taskList = [
|
||||
['NoSuchTaskEither'],
|
||||
return [
|
||||
['ArtistUsage'],
|
||||
['BetterTranscode'],
|
||||
['CalculateContestLeaderboard'],
|
||||
['CommunityStats'],
|
||||
['CycleAuthKeys'],
|
||||
['DeleteTags'],
|
||||
['DemoteUsers'],
|
||||
['DemoteUsersRatio'],
|
||||
['DisableDownloadingRatioWatch'],
|
||||
['DisableLeechingRatioWatch'],
|
||||
@@ -104,14 +121,12 @@ class SchedulerTest extends TestCase {
|
||||
['InactiveUserDeactivate'],
|
||||
['LockOldThreads'],
|
||||
['LowerLoginAttempts'],
|
||||
['NotifyNonseedingUploaders'],
|
||||
['Peerupdate'],
|
||||
['PromoteUsers'],
|
||||
['PurgeOldTaskHistory'],
|
||||
['RatioRequirements'],
|
||||
['RatioWatch'],
|
||||
['RemoveDeadSessions'],
|
||||
['RemoveExpiredWarnings'],
|
||||
['ResolveStaffPms'],
|
||||
['SSLCertificate'],
|
||||
['Test'],
|
||||
@@ -119,20 +134,79 @@ class SchedulerTest extends TestCase {
|
||||
['UpdateDailyTop10'],
|
||||
['UpdateSeedTimes'],
|
||||
['UpdateUserBonusPoints'],
|
||||
['UpdateUserTorrentHistory'],
|
||||
['UpdateWeeklyTop10'],
|
||||
['UserLastAccess'],
|
||||
['UserStatsDaily'],
|
||||
['UserStatsMonthly'],
|
||||
['UserStatsYearly'],
|
||||
];
|
||||
}
|
||||
|
||||
if (getenv('CI') !== false) {
|
||||
// too dangerous to run locally
|
||||
$taskList[] = ['DeleteNeverSeededTorrents'];
|
||||
$taskList[] = ['DeleteUnseededTorrents'];
|
||||
}
|
||||
public function testTaskDetailList(): void {
|
||||
$list = new TaskScheduler()->taskDetailList();
|
||||
$this->assertCount(44, $list, 'task-detail-list');
|
||||
$detail = current($list);
|
||||
$this->assertEquals(
|
||||
[
|
||||
"periodic_task_id", "name", "description", "period",
|
||||
"is_enabled", "is_sane", "run_now", "runs", "processed",
|
||||
"errors", "events", "duration", "status", "last_run",
|
||||
"next_run",
|
||||
],
|
||||
array_keys($detail),
|
||||
'task-detail-entry'
|
||||
);
|
||||
}
|
||||
|
||||
return $taskList;
|
||||
public function testTaskHistory(): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$task = $scheduler->findByName('Test');
|
||||
$taskId = $task['periodic_task_id'];
|
||||
$initial = $scheduler->taskHistory($taskId, 10, 0);
|
||||
$total = $scheduler->taskRunTotal($taskId);
|
||||
ob_start();
|
||||
$scheduler->runTask($taskId);
|
||||
ob_end_clean();
|
||||
$this->assertEquals(
|
||||
$total + 1,
|
||||
$scheduler->taskRunTotal($taskId),
|
||||
'task-run-total'
|
||||
);
|
||||
|
||||
$history = $scheduler->taskHistory($taskId, 10, 0);
|
||||
$this->assertEquals(
|
||||
1,
|
||||
$history->count - $initial->count,
|
||||
'task-history-total'
|
||||
);
|
||||
$item = current($history->items);
|
||||
$this->assertTrue(Helper::recentDate($item->launchTime), 'task-launch-time');
|
||||
$this->assertEquals('completed', $item->status, 'task-status');
|
||||
$this->assertEquals(0, $item->nrErrors, 'task-nr-error');
|
||||
$this->assertEquals(0, $item->nrItems, 'task-nr-item');
|
||||
}
|
||||
|
||||
public function testTaskStats(): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$task = $scheduler->findByName('Test');
|
||||
$taskId = $task['periodic_task_id'];
|
||||
$stats = $scheduler->taskRuntimeStats($taskId, 1);
|
||||
$this->assertCount(2, $stats, 'task-runtime-stats-count');
|
||||
$this->assertEquals(
|
||||
'duration', $stats[0]['name'], 'task-stats-duration',
|
||||
);
|
||||
$this->assertEquals(
|
||||
'processed', $stats[1]['name'], 'task-stats-processed',
|
||||
);
|
||||
}
|
||||
|
||||
public function testGlobalStats(): void {
|
||||
$scheduler = new TaskScheduler();
|
||||
$stats = $scheduler->runtimeStats();
|
||||
$this->assertEquals(
|
||||
['hourly', 'daily', 'tasks', 'totals'],
|
||||
array_keys($stats),
|
||||
'task-stats-global',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user