*/ 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; } }