diff --git a/README.md b/README.md index c13bc40..e07c578 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,28 @@ assert(to_cronstr(cex) == "* * * * * *"); assert(to_string(cex) == "111111111111111111111111111111111111111111111111111111111111 111111111111111111111111111111111111111111111111111111111111 111111111111111111111111 1111111111111111111111111111111 111111111111 1111111"); ``` +## Time zones and daylight saving time + +croncpp evaluates expressions in **local time**. It has no time zone database of its own: `std::tm` values are converted with `std::mktime` and `localtime`, so the zone in effect is whatever the C runtime reports, which on most systems is controlled by the `TZ` environment variable. There is no UTC mode; to schedule in UTC, run the process with `TZ` set to UTC. + +This matters when the local clock is not continuous, which happens twice a year in zones that observe daylight saving time. + +**When the clock jumps forward**, some local times do not occur at all. An expression naming a time inside the gap does not fire that day; the next occurrence is on the following day. + +``` +// in a zone where the clock jumps from 02:00 to 03:00 on 2025-03-09 +auto cex = cron::make_cron("0 30 2 * * *"); // every day at 02:30 +auto tm = cron::utils::to_tm("2025-03-09 01:00:00"); + +// 02:30 does not exist on the 9th, so the next occurrence is on the 10th +assert(cron::utils::to_string(cron::cron_next(cex, tm)) == "2025-03-10 02:30:00"); +``` + +**When the clock goes back**, some local times occur twice, and the two are different instants an hour apart. Which of them `cron_next` returns depends on how the platform's `mktime` resolves an ambiguous local time, and that differs between implementations. croncpp does not attempt to hide the difference. What it does guarantee is that + +* the result is a time matching the expression that is **strictly later than the one asked about**, so a caller that repeatedly feeds the previous result back in always makes progress and never repeats a value, and +* the `std::tm`, `std::time_t` and `std::chrono::system_clock::time_point` overloads all name the **same instant** for the same question, including inside the repeated hour. + ## Benchmarks The following results are the average (in microseconds) for running the benchmark program ten times on Windows and Mac with different compilers (all with release settings). diff --git a/include/croncpp.h b/include/croncpp.h index 796e0a3..8f597f1 100644 --- a/include/croncpp.h +++ b/include/croncpp.h @@ -581,6 +581,24 @@ namespace cron return INVALID_INDEX; } + inline int field_value( + std::tm const & date, + cron_field const field) + { + switch (field) + { + case cron_field::second: return date.tm_sec; + case cron_field::minute: return date.tm_min; + case cron_field::hour_of_day: return date.tm_hour; + case cron_field::day_of_week: return date.tm_wday; + case cron_field::day_of_month: return date.tm_mday; + case cron_field::month: return date.tm_mon; + case cron_field::year: return date.tm_year; + } + + return -1; + } + inline void add_to_field( std::tm& date, cron_field const field, @@ -600,17 +618,20 @@ namespace cron case cron_field::day_of_week: case cron_field::day_of_month: date.tm_mday += val; - date.tm_isdst = -1; break; case cron_field::month: date.tm_mon += val; - date.tm_isdst = -1; break; case cron_field::year: date.tm_year += val; break; } + // whatever the field, the time that comes out may fall on the other + // side of a DST transition from the one that went in, so the flag is + // no longer known and mktime has to work it out + date.tm_isdst = -1; + if (INVALID_TIME == utils::tm_to_time(date)) throw bad_cronexpr("Invalid time expression"); } @@ -636,17 +657,17 @@ namespace cron break; case cron_field::day_of_month: date.tm_mday = val; - date.tm_isdst = -1; break; case cron_field::month: date.tm_mon = val; - date.tm_isdst = -1; break; case cron_field::year: date.tm_year = val; break; } + date.tm_isdst = -1; + if (INVALID_TIME == utils::tm_to_time(date)) throw bad_cronexpr("Invalid time expression"); } @@ -671,17 +692,17 @@ namespace cron break; case cron_field::day_of_month: date.tm_mday = 1; - date.tm_isdst = -1; break; case cron_field::month: date.tm_mon = 0; - date.tm_isdst = -1; break; case cron_field::year: date.tm_year = 0; break; } + date.tm_isdst = -1; + if (INVALID_TIME == utils::tm_to_time(date)) throw bad_cronexpr("Invalid time expression"); } @@ -728,6 +749,24 @@ namespace cron { set_field(date, field, static_cast(next_value)); reset_all_fields(date, marked_fields); + + // The value asked for may not exist on this day: when the clock + // jumps forward there is no 02:30 at all, and mktime answers with + // some other time. Asking again would never make progress, so move + // on to the next larger field instead. + if (INVALID_INDEX != next_value && + field_value(date, field) != static_cast(next_value)) + { + add_to_field(date, next_field, 1); + reset_field(date, field); + + next_value = next_set_bit(target, minimum, maximum, 0); + if (INVALID_INDEX != next_value) + { + set_field(date, field, static_cast(next_value)); + reset_all_fields(date, marked_fields); + } + } } return next_value; @@ -870,6 +909,55 @@ namespace cron return res; } + + // The largest shift a DST transition applies to the clock. + // It bounds the search below, which can only fail to move forward while it is inside such a transition. + constexpr std::time_t CRON_MAX_DST_SHIFT = 2 * 60 * 60; + + // Finds the first time matching the expression that is strictly later + // than the one given. find_next works on a local std::tm, and across a + // DST transition mktime can map that local time onto an instant at or + // before the one the search started from: the local clock moves forward + // while the instant it names does not. The result is therefore checked + // and the search restarted a second later until it really is in the + // future, which also keeps a caller looping on cron_next from spinning + // on the same value. + template + static std::time_t find_next_after( + cronexpr const & cex, + std::time_t const original, + std::tm & result) + { + for (std::time_t start = original; start - original <= CRON_MAX_DST_SHIFT; ++start) + { + std::tm date; + if (utils::time_to_tm(&start, &date) == nullptr) + return INVALID_TIME; + + if (!find_next(cex, date, date.tm_year)) + return INVALID_TIME; + + std::time_t const calculated = utils::tm_to_time(date); + if (INVALID_TIME == calculated) + return INVALID_TIME; + + if (calculated > original) + { + // Derive the calendar time back from the instant rather than + // handing out the one mktime normalized. When a local time is + // ambiguous, because the clock went back, mktime may leave + // tm_isdst describing the other of the two readings, and the + // two overloads of cron_next would then answer with different + // instants for the same call. + if (utils::time_to_tm(&calculated, &result) == nullptr) + return INVALID_TIME; + + return calculated; + } + } + + return INVALID_TIME; + } } template @@ -909,23 +997,14 @@ namespace cron if (cex.empty()) throw bad_cronexpr("Invalid empty cron expression"); - time_t original = utils::tm_to_time(date); + std::time_t const original = utils::tm_to_time(date); if (INVALID_TIME == original) return {}; - if (!detail::find_next(cex, date, date.tm_year)) + std::tm result; + if (INVALID_TIME == detail::find_next_after(cex, original, result)) return {}; - time_t calculated = utils::tm_to_time(date); - if (INVALID_TIME == calculated) return {}; - - if (calculated == original) - { - add_to_field(date, detail::cron_field::second, 1); - if (!detail::find_next(cex, date, date.tm_year)) - return {}; - } - - return date; + return result; } template @@ -934,27 +1013,8 @@ namespace cron if (cex.empty()) throw bad_cronexpr("Invalid empty cron expression"); - std::tm val; - std::tm* dt = utils::time_to_tm(&date, &val); - if (dt == nullptr) return INVALID_TIME; - - time_t original = utils::tm_to_time(*dt); - if (INVALID_TIME == original) return INVALID_TIME; - - if(!detail::find_next(cex, *dt, dt->tm_year)) - return INVALID_TIME; - - time_t calculated = utils::tm_to_time(*dt); - if (INVALID_TIME == calculated) return calculated; - - if (calculated == original) - { - add_to_field(*dt, detail::cron_field::second, 1); - if(!detail::find_next(cex, *dt, dt->tm_year)) - return INVALID_TIME; - } - - return utils::tm_to_time(*dt); + std::tm result; + return detail::find_next_after(cex, date, result); } template diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a22a535..84afc30 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,4 +1,4 @@ -set(SOURCES main.cpp test_oracle.cpp test_quartz.cpp test_standard.cpp) +set(SOURCES main.cpp test_dst.cpp test_oracle.cpp test_quartz.cpp test_standard.cpp) add_executable(test_croncpp ${SOURCES}) diff --git a/test/test_dst.cpp b/test/test_dst.cpp new file mode 100644 index 0000000..1926c3d --- /dev/null +++ b/test/test_dst.cpp @@ -0,0 +1,308 @@ +#include "catch.hpp" +#include "croncpp.h" + +#include +#include +#include +#include + +using namespace cron; + +namespace +{ + // croncpp evaluates expressions in local time, so these tests pin the time + // zone rather than depending on the one the machine happens to use. + // CST6CDT is read as a zone name where the zoneinfo database has the + // compatibility names installed, and as a POSIX specification otherwise. + // + // The dates it switches on are NOT the same everywhere: the Windows CRT + // applies its own idea of the US rules and does not agree with zoneinfo. + // Nothing below assumes a date. The transitions are discovered from the C + // runtime, and every expectation is expressed relative to them. + struct scoped_tz + { + explicit scoped_tz(char const * tz) + { +#ifdef _WIN32 + _putenv_s("TZ", tz); + _tzset(); +#else + setenv("TZ", tz, 1); + tzset(); +#endif + } + + ~scoped_tz() + { +#ifdef _WIN32 + _putenv_s("TZ", ""); + _tzset(); +#else + unsetenv("TZ"); + tzset(); +#endif + } + }; + + char const * const TZ = "CST6CDT"; + + int const YEAR = 2025; + + std::tm local_at(std::time_t const t) + { + std::tm tm; + REQUIRE(utils::time_to_tm(&t, &tm) != nullptr); + + return tm; + } + + int isdst_at(std::time_t const t) + { + std::tm tm; + if (utils::time_to_tm(&t, &tm) == nullptr) return -1; + + return tm.tm_isdst > 0 ? 1 : 0; + } + + struct transition + { + std::time_t at; // the first instant on the new side of the change + bool forward; // the clock jumped forward, skipping local times + }; + + // Walks a year an hour at a time looking for the DST flag changing, then + // narrows each change down to the second. + std::vector transitions_in(int const year) + { + std::vector found; + + std::tm start = std::tm(); + start.tm_year = year - 1900; + start.tm_mon = 0; + start.tm_mday = 1; + start.tm_hour = 12; + start.tm_isdst = -1; + + std::time_t const first = utils::tm_to_time(start); + if (INVALID_TIME == first) return found; + + std::time_t const last = first + 364 * 24 * 60 * 60; + + int previous = isdst_at(first); + for (std::time_t t = first + 3600; t <= last; t += 3600) + { + int const current = isdst_at(t); + if (current == previous) continue; + + for (std::time_t s = t - 3599; s <= t; ++s) + { + if (isdst_at(s) != previous) + { + transition const change = { s, current == 1 }; + found.push_back(change); + break; + } + } + + previous = current; + } + + return found; + } + + std::string daily_at(int const hour, int const minute) + { + return "0 " + std::to_string(minute) + " " + std::to_string(hour) + " * * *"; + } +} + +TEST_CASE("dst: the zone under test observes daylight saving time", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + + INFO("the zone " << TZ << " reported " << changes.size() << " transitions in " << YEAR); + REQUIRE(changes.size() >= 2); + + bool forward = false; + bool back = false; + for (size_t i = 0; i < changes.size(); ++i) + { + if (changes[i].forward) forward = true; + else back = true; + } + + REQUIRE(forward); + REQUIRE(back); +} + +TEST_CASE("dst: the hour the clock skips does not fire that day", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + REQUIRE(!changes.empty()); + + for (size_t i = 0; i < changes.size(); ++i) + { + if (!changes[i].forward) continue; + + std::tm const before = local_at(changes[i].at - 1); + std::tm const after = local_at(changes[i].at); + + // only a whole hour skipped on the hour is covered here + if (after.tm_hour != before.tm_hour + 2) continue; + + int const skipped = before.tm_hour + 1; + + auto cex = make_cron(daily_at(skipped, 30)); + std::time_t const from = changes[i].at - 3600; + + auto const next = cron_next(cex, from); + REQUIRE(next > from); + + std::tm const tm = local_at(next); + REQUIRE(tm.tm_hour == skipped); + REQUIRE(tm.tm_min == 30); + REQUIRE(tm.tm_mday != before.tm_mday); // not on the day the hour is missing + } +} + +TEST_CASE("dst: issue 24, a daily job keeps advancing a day at a time", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + REQUIRE(!changes.empty()); + + for (size_t i = 0; i < changes.size(); ++i) + { + // a time of day that exists on both sides of the change + int const hour = (local_at(changes[i].at).tm_hour + 1) % 24; + + auto cex = make_cron(daily_at(hour, 30)); + + std::time_t t = changes[i].at - 3 * 24 * 60 * 60; + for (int day = 0; day < 6; ++day) + { + auto const next = cron_next(cex, t); + + // the reported symptom was a result at or before the time asked about + REQUIRE(next > t); + REQUIRE(next - t <= 25 * 60 * 60); // and never a day skipped + + std::tm const tm = local_at(next); + REQUIRE(tm.tm_hour == hour); + REQUIRE(tm.tm_min == 30); + + t = next; + } + } +} + +TEST_CASE("dst: an ambiguous local time resolves to a single instant", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + REQUIRE(!changes.empty()); + + for (size_t i = 0; i < changes.size(); ++i) + { + if (changes[i].forward) continue; + + // the local times in the hour before the change happen a second time in + // the hour after it + std::tm const repeated = local_at(changes[i].at - 1); + + auto cex = make_cron(daily_at(repeated.tm_hour, repeated.tm_min)); + std::time_t const from = changes[i].at - 3600; + + auto const next = cron_next(cex, from); + REQUIRE(next > from); + + std::tm const tm = local_at(next); + REQUIRE(tm.tm_hour == repeated.tm_hour); + REQUIRE(tm.tm_min == repeated.tm_min); + } +} + +TEST_CASE("dst: cron_next is always strictly in the future", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + REQUIRE(!changes.empty()); + + // Sweeps two hours either side of every transition, against expressions + // that fire often enough to land inside one. A result at or before the time + // asked about would make a caller looping on cron_next spin, and that only + // happens in a narrow window around a transition, so the window is swept + // densely rather than spot checked. + // + // This is by far the largest test case in the suite: 5 expressions x 2 + // transitions x 2058 samples (4 hours at 7 second steps) x 2 assertions = + // 41160 assertions, of the roughly 43500 the whole suite reports. It still + // runs in a fraction of a second. + char const * const exprs[] = { + "0 * * * * *", + "0 0/15 * * * *", + "59 59 1 * * *", + "0 30 2 * * *", + "0 0 12 * * *", + }; + + for (auto const & expr : exprs) + { + auto cex = make_cron(expr); + + for (size_t i = 0; i < changes.size(); ++i) + { + std::time_t const stop = changes[i].at + 2 * 60 * 60; + for (std::time_t t = changes[i].at - 2 * 60 * 60; t < stop; t += 7) + { + auto const next = cron_next(cex, t); + + REQUIRE(next != INVALID_TIME); + REQUIRE(next > t); + } + } + } +} + +TEST_CASE("dst: both overloads agree across a transition", "[dst]") +{ + scoped_tz tz(TZ); + + auto const changes = transitions_in(YEAR); + REQUIRE(!changes.empty()); + + // Asking with a std::time_t and with the equivalent std::tm has to name the + // same instant, including where the local time is ambiguous and mktime has + // to choose between two readings of it. + // + // 3 expressions x 2 transitions x 178 samples (3 hours at 61 second steps) + // x 2 assertions = 2136 assertions. + char const * const exprs[] = { "0 * * * * *", "0 0 12 * * *", "59 59 1 * * *" }; + + for (auto const & expr : exprs) + { + auto cex = make_cron(expr); + + for (size_t i = 0; i < changes.size(); ++i) + { + std::time_t const stop = changes[i].at + 2 * 60 * 60; + for (std::time_t t = changes[i].at - 60 * 60; t < stop; t += 61) + { + std::tm date; + REQUIRE(utils::time_to_tm(&t, &date) != nullptr); + + auto const from_time = cron_next(cex, t); + auto from_tm = cron_next(cex, date); + + REQUIRE(from_time == utils::tm_to_time(from_tm)); + } + } + } +}