cron.cpp 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. #include "common/cron.hpp"
  2. #include <algorithm>
  3. #include <sstream>
  4. #include <vector>
  5. namespace smartbotic::common {
  6. namespace {
  7. std::vector<std::string> split(const std::string& text, char separator) {
  8. std::vector<std::string> parts;
  9. std::string current;
  10. std::istringstream stream(text);
  11. while (std::getline(stream, current, separator)) {
  12. parts.push_back(current);
  13. }
  14. return parts;
  15. }
  16. std::string trim(const std::string& text) {
  17. const auto first = text.find_first_not_of(" \t\r\n");
  18. if (first == std::string::npos) return {};
  19. const auto last = text.find_last_not_of(" \t\r\n");
  20. return text.substr(first, last - first + 1);
  21. }
  22. // Fills `slots` from one field. Reports what was wrong rather than which
  23. // character it stopped at, because the person reading has the expression in
  24. // front of them and needs to know which part of it to change.
  25. std::optional<std::string> fillField(const std::string& field, int low, int high,
  26. bool* slots, bool& restricted,
  27. const char* field_name) {
  28. const std::string spec = trim(field);
  29. if (spec.empty()) {
  30. return std::string("the ") + field_name + " field is empty";
  31. }
  32. restricted = (spec != "*");
  33. for (const auto& piece : split(spec, ',')) {
  34. const std::string part = trim(piece);
  35. if (part.empty()) {
  36. return std::string("the ") + field_name + " field has an empty entry between commas";
  37. }
  38. // "*/5" and "2-8/2" both put the step after a slash.
  39. int step = 1;
  40. std::string range_part = part;
  41. const auto slash = part.find('/');
  42. if (slash != std::string::npos) {
  43. range_part = part.substr(0, slash);
  44. const std::string step_text = part.substr(slash + 1);
  45. try {
  46. step = std::stoi(step_text);
  47. } catch (const std::exception&) {
  48. return std::string("the ") + field_name + " field has \"" + step_text +
  49. "\" after a slash, which has to be a whole number";
  50. }
  51. if (step <= 0) {
  52. return std::string("the ") + field_name + " field has a step of " +
  53. std::to_string(step) + ", which has to be at least 1";
  54. }
  55. }
  56. int from = low;
  57. int to = high;
  58. if (range_part != "*") {
  59. const auto dash = range_part.find('-');
  60. try {
  61. if (dash == std::string::npos) {
  62. from = to = std::stoi(range_part);
  63. // "5/15" means "from 5 onwards in steps of 15", not "5 only".
  64. if (slash != std::string::npos) {
  65. to = high;
  66. }
  67. } else {
  68. from = std::stoi(range_part.substr(0, dash));
  69. to = std::stoi(range_part.substr(dash + 1));
  70. }
  71. } catch (const std::exception&) {
  72. return std::string("the ") + field_name + " field has \"" + range_part +
  73. "\", which is not a number or a range";
  74. }
  75. }
  76. // Sunday is 0 in this implementation; 7 is accepted because half the
  77. // world writes it that way.
  78. if (std::string(field_name) == "day-of-week") {
  79. if (from == 7) from = 0;
  80. if (to == 7) to = 0;
  81. }
  82. if (from < low || from > high || to < low || to > high) {
  83. return std::string("the ") + field_name + " field has " + std::to_string(from) +
  84. "-" + std::to_string(to) + ", outside the allowed " + std::to_string(low) +
  85. "-" + std::to_string(high);
  86. }
  87. if (from > to) {
  88. return std::string("the ") + field_name + " field has the range " +
  89. std::to_string(from) + "-" + std::to_string(to) + " backwards";
  90. }
  91. for (int value = from; value <= to; value += step) {
  92. slots[value] = true;
  93. }
  94. }
  95. return std::nullopt;
  96. }
  97. } // namespace
  98. Result<CronSchedule> CronSchedule::parse(const std::string& expression) {
  99. const std::string trimmed = trim(expression);
  100. if (trimmed.empty()) {
  101. return Error(ErrorCode::InvalidArgument, "The cron expression is empty");
  102. }
  103. std::vector<std::string> fields;
  104. std::istringstream stream(trimmed);
  105. std::string field;
  106. while (stream >> field) {
  107. fields.push_back(field);
  108. }
  109. if (fields.size() != 5) {
  110. return Error(ErrorCode::InvalidArgument,
  111. "A cron expression has five fields - minute hour day-of-month month "
  112. "day-of-week - but this one has " + std::to_string(fields.size()) +
  113. ": \"" + trimmed + "\"");
  114. }
  115. CronSchedule schedule;
  116. schedule.expression_ = trimmed;
  117. bool ignored = false;
  118. struct FieldSpec { const std::string& text; int low; int high; bool* slots; bool* restricted;
  119. const char* name; };
  120. const FieldSpec specs[] = {
  121. {fields[0], 0, 59, schedule.minutes_, &ignored, "minute"},
  122. {fields[1], 0, 23, schedule.hours_, &ignored, "hour"},
  123. {fields[2], 1, 31, schedule.days_of_month_, &schedule.day_of_month_restricted_, "day-of-month"},
  124. {fields[3], 1, 12, schedule.months_, &ignored, "month"},
  125. {fields[4], 0, 6, schedule.days_of_week_, &schedule.day_of_week_restricted_, "day-of-week"},
  126. };
  127. for (const auto& spec : specs) {
  128. auto problem = fillField(spec.text, spec.low, spec.high, spec.slots, *spec.restricted,
  129. spec.name);
  130. if (problem) {
  131. return Error(ErrorCode::InvalidArgument, *problem + " (in \"" + trimmed + "\")");
  132. }
  133. }
  134. return schedule;
  135. }
  136. std::optional<std::chrono::system_clock::time_point> CronSchedule::nextAfter(
  137. std::chrono::system_clock::time_point after, const std::string& timezone) const {
  138. const std::chrono::time_zone* zone = nullptr;
  139. try {
  140. zone = std::chrono::locate_zone(timezone.empty() ? "UTC" : timezone);
  141. } catch (const std::exception&) {
  142. try {
  143. zone = std::chrono::locate_zone("UTC");
  144. } catch (const std::exception&) {
  145. return std::nullopt;
  146. }
  147. }
  148. // Walked a minute at a time in local time, which is what a cron expression
  149. // is written in. Four years of minutes is the ceiling: it covers the 29th
  150. // of February, and an expression that matches nothing in four years matches
  151. // nothing ever.
  152. //
  153. // Local time is also why this cannot be arithmetic on a UTC instant. On the
  154. // night a clock goes back, 02:30 happens twice; when it goes forward, it
  155. // does not happen at all. zoned_time decides which instant a local time
  156. // means, so a schedule set for 02:30 behaves the way the person who set it
  157. // expects rather than the way an offset would.
  158. auto local = std::chrono::zoned_time{zone, after}.get_local_time();
  159. local = std::chrono::floor<std::chrono::minutes>(local) + std::chrono::minutes(1);
  160. constexpr int kMaxMinutes = 4 * 366 * 24 * 60;
  161. for (int step = 0; step < kMaxMinutes; ++step, local += std::chrono::minutes(1)) {
  162. const auto day = std::chrono::floor<std::chrono::days>(local);
  163. const std::chrono::year_month_day date{day};
  164. const std::chrono::hh_mm_ss time{local - day};
  165. const int minute = static_cast<int>(time.minutes().count());
  166. const int hour = static_cast<int>(time.hours().count());
  167. const int day_of_month = static_cast<int>(unsigned(date.day()));
  168. const int month = static_cast<int>(unsigned(date.month()));
  169. const int day_of_week = static_cast<int>(
  170. std::chrono::weekday{day}.c_encoding()); // Sunday is 0
  171. if (!minutes_[minute] || !hours_[hour] || !months_[month]) {
  172. continue;
  173. }
  174. // The historical day rule: when both day fields are restricted a match
  175. // on either is enough, so "0 0 1 * 1" is the first of the month AND
  176. // every Monday. When only one is restricted, that one has to match.
  177. const bool dom_ok = days_of_month_[day_of_month];
  178. const bool dow_ok = days_of_week_[day_of_week];
  179. bool day_ok;
  180. if (day_of_month_restricted_ && day_of_week_restricted_) {
  181. day_ok = dom_ok || dow_ok;
  182. } else {
  183. day_ok = dom_ok && dow_ok;
  184. }
  185. if (!day_ok) {
  186. continue;
  187. }
  188. // choose::earliest decides the two cases a wall clock cannot express
  189. // on its own, and both decisions are deliberate.
  190. //
  191. // When the clocks go forward, a schedule set for 02:30 names a time
  192. // that does not happen. This fires it at the transition instead - once,
  193. // just after the gap - because a daily job silently not running one day
  194. // a year is the worse of the two failures.
  195. //
  196. // When the clocks go back, 02:30 happens twice. This takes the first,
  197. // so the job runs once rather than twice; a workflow that posts or
  198. // charges would otherwise do it twice on that one night.
  199. return std::chrono::zoned_time{zone, local, std::chrono::choose::earliest}.get_sys_time();
  200. }
  201. return std::nullopt;
  202. }
  203. } // namespace smartbotic::common