|
|
@@ -0,0 +1,232 @@
|
|
|
+#include "common/cron.hpp"
|
|
|
+
|
|
|
+#include <algorithm>
|
|
|
+#include <sstream>
|
|
|
+#include <vector>
|
|
|
+
|
|
|
+namespace smartbotic::common {
|
|
|
+
|
|
|
+namespace {
|
|
|
+
|
|
|
+std::vector<std::string> split(const std::string& text, char separator) {
|
|
|
+ std::vector<std::string> parts;
|
|
|
+ std::string current;
|
|
|
+ std::istringstream stream(text);
|
|
|
+ while (std::getline(stream, current, separator)) {
|
|
|
+ parts.push_back(current);
|
|
|
+ }
|
|
|
+ return parts;
|
|
|
+}
|
|
|
+
|
|
|
+std::string trim(const std::string& text) {
|
|
|
+ const auto first = text.find_first_not_of(" \t\r\n");
|
|
|
+ if (first == std::string::npos) return {};
|
|
|
+ const auto last = text.find_last_not_of(" \t\r\n");
|
|
|
+ return text.substr(first, last - first + 1);
|
|
|
+}
|
|
|
+
|
|
|
+// Fills `slots` from one field. Reports what was wrong rather than which
|
|
|
+// character it stopped at, because the person reading has the expression in
|
|
|
+// front of them and needs to know which part of it to change.
|
|
|
+std::optional<std::string> fillField(const std::string& field, int low, int high,
|
|
|
+ bool* slots, bool& restricted,
|
|
|
+ const char* field_name) {
|
|
|
+ const std::string spec = trim(field);
|
|
|
+ if (spec.empty()) {
|
|
|
+ return std::string("the ") + field_name + " field is empty";
|
|
|
+ }
|
|
|
+
|
|
|
+ restricted = (spec != "*");
|
|
|
+
|
|
|
+ for (const auto& piece : split(spec, ',')) {
|
|
|
+ const std::string part = trim(piece);
|
|
|
+ if (part.empty()) {
|
|
|
+ return std::string("the ") + field_name + " field has an empty entry between commas";
|
|
|
+ }
|
|
|
+
|
|
|
+ // "*/5" and "2-8/2" both put the step after a slash.
|
|
|
+ int step = 1;
|
|
|
+ std::string range_part = part;
|
|
|
+ const auto slash = part.find('/');
|
|
|
+ if (slash != std::string::npos) {
|
|
|
+ range_part = part.substr(0, slash);
|
|
|
+ const std::string step_text = part.substr(slash + 1);
|
|
|
+ try {
|
|
|
+ step = std::stoi(step_text);
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ return std::string("the ") + field_name + " field has \"" + step_text +
|
|
|
+ "\" after a slash, which has to be a whole number";
|
|
|
+ }
|
|
|
+ if (step <= 0) {
|
|
|
+ return std::string("the ") + field_name + " field has a step of " +
|
|
|
+ std::to_string(step) + ", which has to be at least 1";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ int from = low;
|
|
|
+ int to = high;
|
|
|
+ if (range_part != "*") {
|
|
|
+ const auto dash = range_part.find('-');
|
|
|
+ try {
|
|
|
+ if (dash == std::string::npos) {
|
|
|
+ from = to = std::stoi(range_part);
|
|
|
+ // "5/15" means "from 5 onwards in steps of 15", not "5 only".
|
|
|
+ if (slash != std::string::npos) {
|
|
|
+ to = high;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ from = std::stoi(range_part.substr(0, dash));
|
|
|
+ to = std::stoi(range_part.substr(dash + 1));
|
|
|
+ }
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ return std::string("the ") + field_name + " field has \"" + range_part +
|
|
|
+ "\", which is not a number or a range";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sunday is 0 in this implementation; 7 is accepted because half the
|
|
|
+ // world writes it that way.
|
|
|
+ if (std::string(field_name) == "day-of-week") {
|
|
|
+ if (from == 7) from = 0;
|
|
|
+ if (to == 7) to = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (from < low || from > high || to < low || to > high) {
|
|
|
+ return std::string("the ") + field_name + " field has " + std::to_string(from) +
|
|
|
+ "-" + std::to_string(to) + ", outside the allowed " + std::to_string(low) +
|
|
|
+ "-" + std::to_string(high);
|
|
|
+ }
|
|
|
+ if (from > to) {
|
|
|
+ return std::string("the ") + field_name + " field has the range " +
|
|
|
+ std::to_string(from) + "-" + std::to_string(to) + " backwards";
|
|
|
+ }
|
|
|
+
|
|
|
+ for (int value = from; value <= to; value += step) {
|
|
|
+ slots[value] = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return std::nullopt;
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace
|
|
|
+
|
|
|
+Result<CronSchedule> CronSchedule::parse(const std::string& expression) {
|
|
|
+ const std::string trimmed = trim(expression);
|
|
|
+ if (trimmed.empty()) {
|
|
|
+ return Error(ErrorCode::InvalidArgument, "The cron expression is empty");
|
|
|
+ }
|
|
|
+
|
|
|
+ std::vector<std::string> fields;
|
|
|
+ std::istringstream stream(trimmed);
|
|
|
+ std::string field;
|
|
|
+ while (stream >> field) {
|
|
|
+ fields.push_back(field);
|
|
|
+ }
|
|
|
+ if (fields.size() != 5) {
|
|
|
+ return Error(ErrorCode::InvalidArgument,
|
|
|
+ "A cron expression has five fields - minute hour day-of-month month "
|
|
|
+ "day-of-week - but this one has " + std::to_string(fields.size()) +
|
|
|
+ ": \"" + trimmed + "\"");
|
|
|
+ }
|
|
|
+
|
|
|
+ CronSchedule schedule;
|
|
|
+ schedule.expression_ = trimmed;
|
|
|
+ bool ignored = false;
|
|
|
+
|
|
|
+ struct FieldSpec { const std::string& text; int low; int high; bool* slots; bool* restricted;
|
|
|
+ const char* name; };
|
|
|
+ const FieldSpec specs[] = {
|
|
|
+ {fields[0], 0, 59, schedule.minutes_, &ignored, "minute"},
|
|
|
+ {fields[1], 0, 23, schedule.hours_, &ignored, "hour"},
|
|
|
+ {fields[2], 1, 31, schedule.days_of_month_, &schedule.day_of_month_restricted_, "day-of-month"},
|
|
|
+ {fields[3], 1, 12, schedule.months_, &ignored, "month"},
|
|
|
+ {fields[4], 0, 6, schedule.days_of_week_, &schedule.day_of_week_restricted_, "day-of-week"},
|
|
|
+ };
|
|
|
+ for (const auto& spec : specs) {
|
|
|
+ auto problem = fillField(spec.text, spec.low, spec.high, spec.slots, *spec.restricted,
|
|
|
+ spec.name);
|
|
|
+ if (problem) {
|
|
|
+ return Error(ErrorCode::InvalidArgument, *problem + " (in \"" + trimmed + "\")");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return schedule;
|
|
|
+}
|
|
|
+
|
|
|
+std::optional<std::chrono::system_clock::time_point> CronSchedule::nextAfter(
|
|
|
+ std::chrono::system_clock::time_point after, const std::string& timezone) const {
|
|
|
+
|
|
|
+ const std::chrono::time_zone* zone = nullptr;
|
|
|
+ try {
|
|
|
+ zone = std::chrono::locate_zone(timezone.empty() ? "UTC" : timezone);
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ try {
|
|
|
+ zone = std::chrono::locate_zone("UTC");
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ return std::nullopt;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Walked a minute at a time in local time, which is what a cron expression
|
|
|
+ // is written in. Four years of minutes is the ceiling: it covers the 29th
|
|
|
+ // of February, and an expression that matches nothing in four years matches
|
|
|
+ // nothing ever.
|
|
|
+ //
|
|
|
+ // Local time is also why this cannot be arithmetic on a UTC instant. On the
|
|
|
+ // night a clock goes back, 02:30 happens twice; when it goes forward, it
|
|
|
+ // does not happen at all. zoned_time decides which instant a local time
|
|
|
+ // means, so a schedule set for 02:30 behaves the way the person who set it
|
|
|
+ // expects rather than the way an offset would.
|
|
|
+ auto local = std::chrono::zoned_time{zone, after}.get_local_time();
|
|
|
+ local = std::chrono::floor<std::chrono::minutes>(local) + std::chrono::minutes(1);
|
|
|
+
|
|
|
+ constexpr int kMaxMinutes = 4 * 366 * 24 * 60;
|
|
|
+ for (int step = 0; step < kMaxMinutes; ++step, local += std::chrono::minutes(1)) {
|
|
|
+ const auto day = std::chrono::floor<std::chrono::days>(local);
|
|
|
+ const std::chrono::year_month_day date{day};
|
|
|
+ const std::chrono::hh_mm_ss time{local - day};
|
|
|
+
|
|
|
+ const int minute = static_cast<int>(time.minutes().count());
|
|
|
+ const int hour = static_cast<int>(time.hours().count());
|
|
|
+ const int day_of_month = static_cast<int>(unsigned(date.day()));
|
|
|
+ const int month = static_cast<int>(unsigned(date.month()));
|
|
|
+ const int day_of_week = static_cast<int>(
|
|
|
+ std::chrono::weekday{day}.c_encoding()); // Sunday is 0
|
|
|
+
|
|
|
+ if (!minutes_[minute] || !hours_[hour] || !months_[month]) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // The historical day rule: when both day fields are restricted a match
|
|
|
+ // on either is enough, so "0 0 1 * 1" is the first of the month AND
|
|
|
+ // every Monday. When only one is restricted, that one has to match.
|
|
|
+ const bool dom_ok = days_of_month_[day_of_month];
|
|
|
+ const bool dow_ok = days_of_week_[day_of_week];
|
|
|
+ bool day_ok;
|
|
|
+ if (day_of_month_restricted_ && day_of_week_restricted_) {
|
|
|
+ day_ok = dom_ok || dow_ok;
|
|
|
+ } else {
|
|
|
+ day_ok = dom_ok && dow_ok;
|
|
|
+ }
|
|
|
+ if (!day_ok) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // choose::earliest decides the two cases a wall clock cannot express
|
|
|
+ // on its own, and both decisions are deliberate.
|
|
|
+ //
|
|
|
+ // When the clocks go forward, a schedule set for 02:30 names a time
|
|
|
+ // that does not happen. This fires it at the transition instead - once,
|
|
|
+ // just after the gap - because a daily job silently not running one day
|
|
|
+ // a year is the worse of the two failures.
|
|
|
+ //
|
|
|
+ // When the clocks go back, 02:30 happens twice. This takes the first,
|
|
|
+ // so the job runs once rather than twice; a workflow that posts or
|
|
|
+ // charges would otherwise do it twice on that one night.
|
|
|
+ return std::chrono::zoned_time{zone, local, std::chrono::choose::earliest}.get_sys_time();
|
|
|
+ }
|
|
|
+
|
|
|
+ return std::nullopt;
|
|
|
+}
|
|
|
+
|
|
|
+} // namespace smartbotic::common
|