tt-rss/classes/Scheduler.php

169 lines
5.1 KiB
PHP

<?php
class Scheduler {
private static ?Scheduler $instance = null;
const TASK_RC_EXCEPTION = -100;
/** @var array<string, mixed> */
private array $scheduled_tasks = [];
private string $name;
function __construct(string $name = 'Default Scheduler') {
$this->set_name($name);
$this->add_scheduled_task('purge_orphaned_scheduled_tasks', '@weekly',
function() {
return $this->purge_orphaned_tasks();
}
);
}
public static function getInstance(): Scheduler {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
/** Sets specific identifier for this instance of Scheduler used in debug logging */
public function set_name(string $name) : void {
$this->name = $name;
}
/**
* Adds a backend scheduled task which will be executed by updater (if due) during housekeeping.
*
* The granularity is not strictly guaranteed, housekeeping is invoked several times per hour
* depending on how fast feed batch was processed, but no more than once per minute.
*
* Tasks do not run in user context. Task names may not overlap. Plugins should register tasks
* via PluginHost methods (to be implemented later).
*
* Tasks should return an integer value (return code) which is stored in the database, a value of
* 0 is considered successful.
*
* @param string $task_name unique name for this task, plugins should prefix this with plugin name
* @param string $cron_expression schedule for this task in cron format
* @param Closure $callback task code that gets executed
*/
function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool {
$task_name = strtolower($task_name);
if (isset($this->scheduled_tasks[$task_name])) {
user_error("[$this->name] Attempted to override already registered scheduled task $task_name", E_USER_WARNING);
return false;
} else {
try {
$cron = new Cron\CronExpression($cron_expression);
} catch (InvalidArgumentException $e) {
user_error("[$this->name] Attempt to register scheduled task $task_name failed: " . $e->getMessage(), E_USER_WARNING);
return false;
}
$this->scheduled_tasks[$task_name] = [
"cron" => $cron,
"callback" => $callback,
];
return true;
}
}
/**
* Execute scheduled tasks which are due to run and record last run timestamps.
*/
function run_due_tasks() : void {
Debug::log("[$this->name] Processing all scheduled tasks...");
$tasks_succeeded = 0;
$tasks_failed = 0;
foreach ($this->scheduled_tasks as $task_name => $task) {
$task_record = ORM::for_table('ttrss_scheduled_tasks')
->where('task_name', $task_name)
->find_one();
if ($task_record)
$last_run = $task_record->last_run;
else
$last_run = '1970-01-01 00:00';
// because we don't schedule tasks every minute, we assume that task is due if its
// next estimated run based on previous timestamp is in the past
if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) {
Debug::log("=> Scheduled task $task_name is due, executing...");
$task_started = time();
try {
$rc = (int) $task['callback']();
} catch (Exception $e) {
user_error("[$this->name] Scheduled task $task_name failed with exception: " . $e->getMessage(), E_USER_WARNING);
$rc = self::TASK_RC_EXCEPTION;
}
$task_duration = time() - $task_started;
if ($rc === 0) {
++$tasks_succeeded;
Debug::log("<= Scheduled task $task_name has finished in $task_duration seconds.");
} else {
$tasks_failed++;
Debug::log("!! Scheduled task $task_name has failed with RC: $rc after $task_duration seconds.");
}
if ($task_record) {
$task_record->last_run = Db::NOW();
$task_record->last_duration = $task_duration;
$task_record->last_rc = $rc;
$task_record->last_cron_expression = $task['cron']->getExpression();
$task_record->save();
} else {
$task_record = ORM::for_table('ttrss_scheduled_tasks')->create();
$task_record->set([
'task_name' => $task_name,
'last_duration' => $task_duration,
'last_rc' => $rc,
'last_run' => Db::NOW(),
'last_cron_expression' => $task['cron']->getExpression()
]);
$task_record->save();
}
}
}
Debug::log("[$this->name] Processing scheduled tasks finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed.");
}
/**
* Purge records of scheduled tasks that aren't currently registered
* and haven't ran for a long time.
*
* @return int 0 if successful, 1 on failure
*/
private function purge_orphaned_tasks(): int {
if (!$this->scheduled_tasks) {
Debug::log(__METHOD__ . ' was invoked before scheduled tasks have been registered. This should never happen.');
return 1;
}
$result = ORM::for_table('ttrss_scheduled_tasks')
->where_not_in('task_name', array_keys($this->scheduled_tasks))
->where_raw("last_run < NOW() - INTERVAL '5 weeks'")
->delete_many();
if ($result) {
$deleted_count = ORM::get_last_statement()->rowCount();
if ($deleted_count)
Debug::log("Purged {$deleted_count} orphaned scheduled tasks.");
}
return $result ? 0 : 1;
}
}