#include "common/cron.hpp" #include #include #include namespace smartbotic::common { namespace { std::vector split(const std::string& text, char separator) { std::vector 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 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::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 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 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(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(local); const std::chrono::year_month_day date{day}; const std::chrono::hh_mm_ss time{local - day}; const int minute = static_cast(time.minutes().count()); const int hour = static_cast(time.hours().count()); const int day_of_month = static_cast(unsigned(date.day())); const int month = static_cast(unsigned(date.month())); const int day_of_week = static_cast( 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