1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
<?php
class Scheduler {
private static ?Scheduler $instance = null;
const TASK_RC_EXCEPTION = -100;
/** @var array<string, mixed> */
private array $scheduled_tasks = [];
function __construct() {
$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;
}
/**
* 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("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("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('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("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->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(),
]);
$task_record->save();
}
}
}
Debug::log("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;
}
}
|