simplify task scheduler implementation

This commit is contained in:
Spine
2025-05-15 06:05:05 +00:00
parent 5e386f75d1
commit fecf0ceea1
12 changed files with 323 additions and 296 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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&amp;mode=view\"><span class=\"sys-error\">TASK</span></a>");
$this->setAlert("<a title=\"$insane insane task"
. plural($insane)
. "\" href=\"tools.php?action=periodic&amp;mode=view\"><span class=\"sys-error\">TASK</span></a>"
);
}
}
return $this;

View File

@@ -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'])
);
}
}

View File

@@ -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,
]);

View File

@@ -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,
]);

View File

@@ -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,
]);

View File

@@ -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']),

View File

@@ -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,
]);

View File

@@ -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() }}

View File

@@ -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&amp;mode=detail&amp;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&amp;mode=detail&amp;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&amp;auth={{ viewer.auth }}&amp;id={{ t.periodic_task_id }}&amp;mode=enqueue">Enqueue</a>
<a class="brackets" href="tools.php?action=periodic&amp;auth={{ viewer.auth }}&amp;id={{ t.periodic_task_id }}&amp;mode=run">Run</a>
{% endif %}
{% if viewer.permitted('admin_schedule') %}
<a class="brackets" href="tools.php?action=periodic&amp;auth={{
viewer.auth }}&amp;id={{ t.periodic_task_id }}&amp;mode=enqueue">Enqueue</a>
<a class="brackets" href="tools.php?action=periodic&amp;auth={{
viewer.auth }}&amp;id={{ t.periodic_task_id }}&amp;mode=run">Run</a>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@@ -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',
);
}
}