| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- #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
|