Skip to content

Commit f611392

Browse files
authored
Merge pull request #1407 from mickem/scheduler-run-on-startup
Run checks at NSClient++ startup
2 parents 7acb180 + c845bc7 commit f611392

12 files changed

Lines changed: 530 additions & 17 deletions

docs/samples/Scheduler_samples.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,41 @@ The syntax of the schedule is similar to a cron expression in that you have:
6868
| Day of month | 1-31 | , * |
6969
| Month | 0-11 | , * |
7070
| Day of week | 1-7 | , * |
71+
72+
#### Running a check at startup
73+
74+
Both interval and schedule only report for the first time once the interval (or
75+
the next matching time) has elapsed. With a long interval that leaves the
76+
monitoring server with the old result for a long time after a reboot - exactly
77+
when the status is most likely to have changed. Set `run on startup` to run the
78+
command once as soon as the agent has started:
79+
80+
```
81+
[/settings/scheduler/schedules/uptime]
82+
interval = 1h
83+
channel = NSCA
84+
command = check_uptime
85+
run on startup = true
86+
```
87+
88+
The schedule is otherwise unaffected: the next run follows an hour after the
89+
startup run. The startup run also happens after a configuration reload, so a
90+
schedule you just changed reports its new status right away.
91+
92+
Setting it on the default section turns it on for every schedule which does not
93+
override it:
94+
95+
```
96+
[/settings/scheduler/schedules/default]
97+
interval = 1h
98+
channel = NSCA
99+
run on startup = true
100+
```
101+
102+
If you have a lot of schedules and do not want all of them to report at the very
103+
same instant, spread the startup runs out over a window:
104+
105+
```
106+
[/settings/scheduler]
107+
startup window = 30s
108+
```

include/scheduler/simple_scheduler.cpp

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,26 +82,34 @@ void scheduler::stop() {
8282
log_trace(__FILE__, __LINE__, "Thread pool contains: " + str::xtos(threads_.count()));
8383
}
8484

85-
int scheduler::add_task(const std::string &tag, const boost::posix_time::time_duration duration, const double jitter_factor) {
85+
int scheduler::add_task(const std::string &tag, const boost::posix_time::time_duration duration, const double jitter_factor, const bool schedule_first_run) {
8686
task item(tag, duration, jitter_factor);
8787
{
8888
boost::mutex::scoped_lock l(mutex_);
8989
item.id = ++schedule_id_;
9090
tasks_[item.id] = item;
9191
}
92-
reschedule(item, now());
92+
if (schedule_first_run) reschedule(item, now());
9393
return item.id;
9494
}
95-
int scheduler::add_task(const std::string &tag, const cron_parser::schedule &schedule) {
95+
int scheduler::add_task(const std::string &tag, const cron_parser::schedule &schedule, const bool schedule_first_run) {
9696
task item(tag, schedule);
9797
{
9898
boost::mutex::scoped_lock l(mutex_);
9999
item.id = ++schedule_id_;
100100
tasks_[item.id] = item;
101101
}
102-
reschedule(item, now());
102+
if (schedule_first_run) reschedule(item, now());
103103
return item.id;
104104
}
105+
void scheduler::run_now(const int id, const boost::posix_time::time_duration delay) {
106+
const op_task_object item = get_task(id);
107+
if (!item) {
108+
log_error(__FILE__, __LINE__, "Cannot run unknown task: " + str::xtos(id));
109+
return;
110+
}
111+
reschedule_at(item->tag, id, now() + delay, true);
112+
}
105113
void scheduler::remove_task(const int id) {
106114
boost::mutex::scoped_lock l(mutex_);
107115
const auto it = tasks_.find(id);
@@ -120,8 +128,14 @@ scheduler::op_task_object scheduler::get_task(const int id) {
120128
}
121129

122130
void scheduler::clear_tasks() {
123-
boost::mutex::scoped_lock l(mutex_);
124-
tasks_.clear();
131+
{
132+
boost::mutex::scoped_lock l(mutex_);
133+
tasks_.clear();
134+
}
135+
// Drop the pending instances as well: without a task behind them they would
136+
// only produce "Task not found" errors as they come due, and on a reload
137+
// they would race the freshly added tasks.
138+
if (!queue_.clear()) log_error(__FILE__, __LINE__, "Failed to clear the schedule queue");
125139
}
126140

127141
void scheduler::watch_dog(const int id) {
@@ -182,7 +196,7 @@ void scheduler::thread_proc(const int id) {
182196

183197
try {
184198
boost::posix_time::time_duration off = now() - instance->time;
185-
if (off.total_seconds() > error_threshold_) {
199+
if (!instance->suppress_late_warning && off.total_seconds() > error_threshold_) {
186200
log_error(__FILE__, __LINE__,
187201
"Ran scheduled item " + instance->tag + "(" + str::xtos(instance->schedule_id) + ") " + str::xtos(off.total_seconds()) +
188202
" seconds to late from thread " + str::xtos(id));
@@ -256,11 +270,12 @@ void scheduler::reschedule(const task &item, boost::posix_time::ptime now_time)
256270
reschedule_at(item.tag, item.id, item.get_next(now_time));
257271
}
258272
}
259-
void scheduler::reschedule_at(const std::string &tag, const int id, boost::posix_time::ptime new_time) {
273+
void scheduler::reschedule_at(const std::string &tag, const int id, boost::posix_time::ptime new_time, const bool suppress_late_warning) {
260274
schedule_instance instance;
261275
instance.tag = tag;
262276
instance.schedule_id = id;
263277
instance.time = new_time;
278+
instance.suppress_late_warning = suppress_late_warning;
264279
if (!queue_.push(instance)) {
265280
log_error(__FILE__, __LINE__, "Failed to reschedule item");
266281
}

include/scheduler/simple_scheduler.hpp

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ struct schedule_instance {
9191
boost::posix_time::ptime time;
9292
std::string tag;
9393
int schedule_id{};
94+
// Startup runs (run_now) are all queued for the same instant, so with more
95+
// tasks than worker threads the tail is inevitably "late" through no fault
96+
// of the configuration. Such instances are exempt from the lateness error
97+
// to keep boot from filling the log with false alarms; the watchdog still
98+
// sees the lag and scales the pool.
99+
bool suppress_late_warning{false};
94100
friend bool operator<(const schedule_instance& p1, const schedule_instance& p2) { return p1.time > p2.time; }
95101
};
96102

@@ -131,6 +137,13 @@ class safe_schedule_queue {
131137
return ret;
132138
}
133139

140+
bool clear(const unsigned int timeout = 5) {
141+
boost::unique_lock<boost::shared_mutex> lock(mutex_, boost::get_system_time() + boost::posix_time::seconds(timeout));
142+
if (!lock) return false;
143+
queue_ = schedule_queue_type();
144+
return true;
145+
}
146+
134147
bool push(T instance, const unsigned int timeout = 5) {
135148
boost::unique_lock<boost::shared_mutex> lock(mutex_, boost::get_system_time() + boost::posix_time::seconds(timeout));
136149
if (!lock) {
@@ -192,8 +205,18 @@ class scheduler : public boost::noncopyable {
192205
std::size_t get_metric_ql();
193206
bool has_metrics() const;
194207

195-
int add_task(const std::string& tag, boost::posix_time::time_duration duration, double jitter_factor);
196-
int add_task(const std::string& tag, const cron_parser::schedule& schedule);
208+
// `schedule_first_run == false` registers the task without queueing
209+
// anything, leaving it dormant until run_now() queues its first instance.
210+
// Used by run-on-startup schedules, which must not be queued twice: every
211+
// execution queues the following one, so an extra instance would make the
212+
// task run twice per interval forever.
213+
int add_task(const std::string& tag, boost::posix_time::time_duration duration, double jitter_factor, bool schedule_first_run = true);
214+
int add_task(const std::string& tag, const cron_parser::schedule& schedule, bool schedule_first_run = true);
215+
// Queue a run of an already added task `delay` from now. Only ever call this
216+
// for a task added with schedule_first_run = false (or one that has since
217+
// been abandoned) - calling it for a live task adds a second, parallel chain
218+
// of instances for that task.
219+
void run_now(int id, boost::posix_time::time_duration delay = boost::posix_time::seconds(0));
197220
void remove_task(int id);
198221
op_task_object get_task(int id);
199222
void clear_tasks();
@@ -220,7 +243,7 @@ class scheduler : public boost::noncopyable {
220243
void thread_proc(int id);
221244

222245
void reschedule(const task& item, boost::posix_time::ptime now_time);
223-
void reschedule_at(const std::string& tag, int id, boost::posix_time::ptime new_time);
246+
void reschedule_at(const std::string& tag, int id, boost::posix_time::ptime new_time, bool suppress_late_warning = false);
224247
void start_threads();
225248

226249
void log_error(const char* file, const int line, const std::string& err) const {

include/scheduler/simple_scheduler_test.cpp

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,3 +359,82 @@ TEST(simple_scheduler_running, no_handler_does_not_crash) {
359359
s.stop();
360360
SUCCEED();
361361
}
362+
363+
// --- deferred first run (run on startup) ----------------------------------
364+
365+
TEST(simple_scheduler_basic, add_task_without_first_run_queues_nothing) {
366+
simple_scheduler::scheduler s;
367+
const int id = s.add_task("deferred", boost::posix_time::seconds(3600), 0.0, false);
368+
EXPECT_TRUE(s.get_task(id));
369+
EXPECT_EQ(s.get_metric_ql(), 0u);
370+
}
371+
372+
TEST(simple_scheduler_basic, add_cron_task_without_first_run_queues_nothing) {
373+
simple_scheduler::scheduler s;
374+
const int id = s.add_task("deferred", cron_parser::parse("0 * * * *"), false);
375+
EXPECT_TRUE(s.get_task(id));
376+
EXPECT_EQ(s.get_metric_ql(), 0u);
377+
}
378+
379+
TEST(simple_scheduler_basic, run_now_queues_a_deferred_task) {
380+
simple_scheduler::scheduler s;
381+
const int id = s.add_task("deferred", boost::posix_time::seconds(3600), 0.0, false);
382+
s.run_now(id);
383+
EXPECT_EQ(s.get_metric_ql(), 1u);
384+
}
385+
386+
TEST(simple_scheduler_basic, run_now_unknown_task_is_ignored) {
387+
simple_scheduler::scheduler s;
388+
s.run_now(4711);
389+
EXPECT_EQ(s.get_metric_ql(), 0u);
390+
}
391+
392+
TEST(simple_scheduler_basic, clear_tasks_also_drops_pending_instances) {
393+
// Pending instances outlive their task otherwise, which on a reload means
394+
// the schedules from before the reload keep firing alongside the new ones.
395+
simple_scheduler::scheduler s;
396+
s.add_task("one", boost::posix_time::seconds(3600), 0.0);
397+
s.add_task("two", boost::posix_time::seconds(3600), 0.0);
398+
ASSERT_EQ(s.get_metric_ql(), 2u);
399+
400+
s.clear_tasks();
401+
EXPECT_EQ(s.get_metric_ql(), 0u);
402+
}
403+
404+
TEST(simple_scheduler_running, run_now_fires_a_deferred_task) {
405+
simple_scheduler::scheduler s;
406+
counting_handler h;
407+
h.reschedule_after = false;
408+
s.set_handler(&h);
409+
s.set_threads(2);
410+
// An hour-long interval: without run_now the handler cannot possibly be
411+
// called during the test, which is exactly the stale-until-the-first-tick
412+
// behaviour run-on-startup schedules avoid.
413+
const int id = s.add_task("startup", boost::posix_time::seconds(3600), 0.0, false);
414+
s.start();
415+
EXPECT_FALSE(wait_for([&] { return h.calls.load() >= 1; }, std::chrono::milliseconds(200)));
416+
417+
s.run_now(id);
418+
EXPECT_TRUE(wait_for([&] { return h.calls.load() >= 1; }, std::chrono::seconds(5)));
419+
420+
s.stop();
421+
s.unset_handler();
422+
}
423+
424+
TEST(simple_scheduler_running, run_now_honours_the_delay) {
425+
simple_scheduler::scheduler s;
426+
counting_handler h;
427+
h.reschedule_after = false;
428+
s.set_handler(&h);
429+
s.set_threads(2);
430+
const int id = s.add_task("startup", boost::posix_time::seconds(3600), 0.0, false);
431+
s.start();
432+
s.run_now(id, boost::posix_time::seconds(2));
433+
434+
// The instance is queued, but it must not run before its delay has passed.
435+
EXPECT_FALSE(wait_for([&] { return h.calls.load() >= 1; }, std::chrono::milliseconds(500)));
436+
EXPECT_TRUE(wait_for([&] { return h.calls.load() >= 1; }, std::chrono::seconds(5)));
437+
438+
s.stop();
439+
s.unset_handler();
440+
}

modules/Scheduler/Scheduler.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <nscapi/protobuf/functions_submit.hpp>
1313
#include <nscapi/settings/helper.hpp>
1414
#include <nscapi/settings/proxy.hpp>
15+
#include <str/format.hpp>
1516
#include <str/utf8.hpp>
1617

1718
namespace sh = nscapi::settings_helper;
@@ -20,6 +21,11 @@ bool Scheduler::loadModuleEx(std::string alias, NSCAPI::moduleLoadMode mode) {
2021
if (mode == NSCAPI::reloadStart) {
2122
scheduler_.prepare_shutdown();
2223
scheduler_.stop();
24+
// The worker threads are joined at this point, so the task list and the
25+
// pending queue can be dropped safely. Without this the tasks (and their
26+
// queued instances) from before the reload survive alongside the ones
27+
// added below, so every schedule ends up running twice.
28+
scheduler_.clear();
2329
schedules_.clear();
2430
}
2531

@@ -36,6 +42,10 @@ bool Scheduler::loadModuleEx(std::string alias, NSCAPI::moduleLoadMode mode) {
3642
settings.alias().add_key_to_settings()
3743
.add_int("threads", sh::int_fun_key([this] (auto value) { scheduler_.set_threads(value); }, 5),
3844
"Threads", "Number of threads to use.")
45+
.add_string("startup window", sh::string_fun_key([this] (const auto& value) { this->set_startup_window(value); }, "0s"),
46+
"Startup window",
47+
"Time over which schedules with 'run on startup' are spread out when the agent starts. The default (0s) runs them all immediately; raise it if you "
48+
"have many startup schedules and do not want to hit the monitoring server with all of them at once.")
3949
.add_string("timezone", sh::string_fun_key([this] (const auto& value) { scheduler_.set_timezone(value); }, "local"),
4050
"Timezone",
4151
"Reference clock for cron expressions. Accepts 'local' (default — standard cron semantics), 'utc'/'gmt' (restores the pre-0.13 "
@@ -96,9 +106,32 @@ bool Scheduler::loadModuleEx(std::string alias, NSCAPI::moduleLoadMode mode) {
96106
scheduler_.set_handler(this);
97107
scheduler_.start();
98108
}
109+
// On a normal boot the startup runs wait for startModule, which the core
110+
// calls once every plugin is loaded - firing them here would query commands
111+
// that later modules have not registered yet. A reload (or a module loaded
112+
// into an already running agent) has no such problem and gets no second
113+
// startModule, so fire them right away instead.
114+
if (started_ && (mode == NSCAPI::normalStart || mode == NSCAPI::reloadStart)) {
115+
scheduler_.run_startup_tasks(startup_window_);
116+
}
99117
return true;
100118
}
101119

120+
bool Scheduler::startModule() {
121+
started_ = true;
122+
scheduler_.run_startup_tasks(startup_window_);
123+
return true;
124+
}
125+
126+
void Scheduler::set_startup_window(const std::string &value) {
127+
try {
128+
startup_window_ = boost::posix_time::seconds(str::format::stox_as_time_sec<long>(value, "s"));
129+
} catch (const std::exception &e) {
130+
NSC_LOG_ERROR_EXR("Invalid 'startup window' value '" + value + "', falling back to 0s: ", e);
131+
startup_window_ = boost::posix_time::seconds(0);
132+
}
133+
}
134+
102135
void Scheduler::add_schedule(const std::string &key, const std::string &arg) {
103136
try {
104137
schedules_.add(nscapi::settings_proxy::create(get_id(), get_core()), key, arg);

modules/Scheduler/Scheduler.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,29 @@ class Scheduler : public schedules::task_handler, public nscapi::impl::simple_pl
1212
private:
1313
schedules::scheduler scheduler_;
1414
schedules::schedule_handler schedules_;
15+
// Window over which "run on startup" schedules are spread, see the
16+
// "startup window" setting.
17+
boost::posix_time::time_duration startup_window_;
18+
// True once startModule has run, i.e. once every other plugin is loaded and
19+
// startup runs are safe to fire. Reloads happen after that point and are
20+
// never followed by another startModule (the core calls it once per plugin
21+
// lifetime), so loadModuleEx has to fire the startup runs itself when set.
22+
bool started_;
1523

1624
public:
17-
Scheduler() { scheduler_.set_handler(this); }
25+
Scheduler() : startup_window_(boost::posix_time::seconds(0)), started_(false) { scheduler_.set_handler(this); }
1826
virtual ~Scheduler() { scheduler_.set_handler(nullptr); }
1927
// Module calls
2028
bool loadModuleEx(std::string alias, NSCAPI::moduleLoadMode mode);
29+
bool startModule();
2130
void prepareShutdown();
2231
bool unloadModule();
2332

2433
// Metrics
2534
void fetchMetrics(PB::Metrics::MetricsMessage_Response* response);
2635

2736
void add_schedule(const std::string& alias, const std::string& command);
37+
void set_startup_window(const std::string& value);
2838
bool handle_schedule(schedules::target_object task);
2939

3040
void on_error(const char* file, int line, std::string error);

modules/Scheduler/module.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
"metrics" : "produce",
1212

13+
"on_start" : true,
14+
1315
"prepare_shutdown" : true
1416

1517
}

0 commit comments

Comments
 (0)