Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
140 changes: 100 additions & 40 deletions include/croncpp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -728,6 +749,24 @@ namespace cron
{
set_field(date, field, static_cast<int>(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<int>(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<int>(next_value));
reset_all_fields(date, marked_fields);
}
}
}

return next_value;
Expand Down Expand Up @@ -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 <typename Traits>
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<Traits>(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 <typename Traits>
Expand Down Expand Up @@ -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<Traits>(cex, date, date.tm_year))
std::tm result;
if (INVALID_TIME == detail::find_next_after<Traits>(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<Traits>(cex, date, date.tm_year))
return {};
}

return date;
return result;
}

template <typename Traits = cron_standard_traits>
Expand All @@ -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<Traits>(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<Traits>(cex, *dt, dt->tm_year))
return INVALID_TIME;
}

return utils::tm_to_time(*dt);
std::tm result;
return detail::find_next_after<Traits>(cex, date, result);
}

template <typename Traits = cron_standard_traits>
Expand Down
2 changes: 1 addition & 1 deletion test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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})

Expand Down
Loading
Loading