Cronit (short for cron iterator) is a Clojure library to iterate over the time points defined by a cron like expression.
Small and free of side effects.
The library is small, and is free of side effects. It does not assume a specific threading nor a persistence scheme. It is just meant to handle all the date time specific aspects needed to iterate forward or backward over recurrent time points derived from a cron like expression. Possible applications are for example a periodic job scheduling engine or a calendar that displays recurrent events on a time grid around a given point in time.
Structured expressions, not strings.
Recurrent time points are defined using a hashmap with time fields as keys and
tagged vector as values, where the tag :+
denotes an enumeration and :*
a
range expression. For example, the expression {:hour [:* 2], :day-of-week [:+ :mon :wed]}
defines time points that occur every 2 hour on Monday and
Wednesdays. It is equivalent to the standard cron expression 0 */2 * * mon,wed
.
Values relative to the end of a range.
Values relative to the end of a temporal range can here be expressed using
negative offsets, which addresses a known limitation with cron. For example,
{:day-of-month [:+ -1]}
will pick the the last day of every month, and
{:day-of-month [:* -7 -1], :day-of-week [:+ :thu]}
will pick the last Thursday
of every month, by combining the last 7 days of any month with any Thursday of a
week.
Many time fields.
The library relies on java.time
to adjust date and times of temporal
fields. Since java.time
supports many kinds of temporal fields that fit the
same abstraction, it felt natural to support more fields than those available in
standard cron, such as :second
, :week-of-month
, :week-of-year
,
day-of-quarter
, quarter-of-year
, and day-of-year
fields.
Import the library in a project with java.time
(or any Clojure wrapper you like):
(require '[sbocq.cronit :as c])
(import '[java.time ZonedDateTime])
Use c/show
to observe the values generated by an arbitrary expression. The
example below will print the intermediate time points for every 12th hours in a
day, on Monday and Wednesdays, around the 16th of June at 11h15:
(c/show {:hour [:* 12], :day-of-week [:+ :mon :wed]}
{:date "2021-06-16T11:15:30+02:00[Europe/Brussels]" :context 6})
"2021-06-07T12:00:00 Mon W2 w24 Q2"
"2021-06-09T00:00:00 Wed W2 w24 Q2"
"2021-06-09T12:00:00 Wed W2 w24 Q2"
"2021-06-14T00:00:00 Mon W3 w25 Q2"
"2021-06-14T12:00:00 Mon W3 w25 Q2"
"2021-06-16T00:00:00 Wed W3 w25 Q2"
"2021-06-16T11:15:30 Wed W3 w25 Q2" ;<- :init
"2021-06-16T12:00:00 Wed W3 w25 Q2"
"2021-06-21T00:00:00 Mon W4 w26 Q2"
"2021-06-21T12:00:00 Mon W4 w26 Q2"
"2021-06-23T00:00:00 Wed W4 w26 Q2"
"2021-06-23T12:00:00 Wed W4 w26 Q2"
"2021-06-28T00:00:00 Mon W5 w27 Q2"
Use c/init
to create the same iterator that was used to generate the dates
above and :current
to access its current value.
(-> (c/init {:hour [:* 12], :day-of-week [:+ :mon :wed]}
(ZonedDateTime/parse "2021-06-16T11:15:30+02:00[Europe/Brussels]"))
:current str)
;; "2021-06-16T11:15:30+02:00[Europe/Brussels]"
Use c/next
to iterate forward by 3 positions and then :current
to access its current value:
(-> (c/init {:hour [:* 12], :day-of-week [:+ :mon :wed]}
(ZonedDateTime/parse "2021-06-16T11:15:30+02:00[Europe/Brussels]"))
c/next
c/next
c/next
:current str)
;; "2021-06-21T12:00+02:00[Europe/Brussels]"
Use c/prev
to iterate backward by 3 positions and then :current
to access its current value:
(-> (c/init {:hour [:* 12], :day-of-week [:+ :mon :wed]}
(ZonedDateTime/parse "2021-06-16T11:15:30+02:00[Europe/Brussels]"))
c/prev
c/prev
c/prev
:current str)
;; "2021-06-14T00:00+02:00[Europe/Brussels]"
Runnable examples
The source repository contains also examples that can be executed from the command line:
$ clj -X:examples timegrid/show \
:context 12 \
:c-expr '{:hour [:* 2], :day-of-week [:+ :mon :wed], :locale "fr-be"}' \
:date '"2021-01-01T00:00"'
$ clj -X:examples scheduler/run \
:timers '["fizz" {:second [:* 3]} "buzz" {:second [:* 5]}]'
Follow the link below for the instructions to add a dependency to your project:
(init c-expr time-point)
Return a cron iterator for a cronit expression and
initialized at a given point in time (ZonedDateTime
). The iterator is a
hashmap with internal :state
and the :current
time point. In addition, it
is tagged with {:unaligned? true, :unaligned-cause :init}
when the initial
time point is not aligned with the specification given by the cronit
expression.
(next cronit)
Return an iterator to the next time point. The iterator is
tagged with with {:unaligned? true, :unaligned-cause :gap}
if its :current
time point is not aligned with its specification because the next value felt
within a gap when DST starts its current point was corrected to the end of the
gap.
(prev cronit)
Return an iterator to the previous time point. The iterator is
tagged with {:unaligned? true, :unaligned-cause :gap}
if its :current
time
point is not aligned with its specification because the previous value felt
within a gap when DST starts, it was not pointing to the gap alreay, and its
current point was corrected to the end of the gap.
(valid? cronit)
Return true
when an iterator is either aligned with its
specification or is not aligned because of a DST gap condition. The :current
position of an iterator can only be invalid once after a call to init
because
the initial time point is neither aligned nor meeting a gap condition already.
(locale cronit)
Return the Locale
used by an iterator.
(show c-expr & [{:keys [:context :format :date :zone-id]}])
Print generated time points for an expression around the current time. Options
are :context
to change the number of time points, :format
to change the
date format of the generated output (can be either a string, a
DateTimeFormatter
or :iso
), :date
to use a different reference date time
(e.g. local "2021-01-01T00:00"
or zoned), and :zone-id
to override the system
time zone with a different one (e.g. "Europe/Brussels") unless a zoned date has
been provided.A hashmap {<field-key> <field-value>}
where:
field-key := :second | :minute | :hour | :day-of-week | :week-of-month | :month | :week-of-year | :day-of-month | :day-of-quarter | :quarter-of-year | :day-of-year
field-value := <range-expression> | < enum-expression>
(see below)The hasmap can be augmented with the following options:
:locale
: specify the locale tag used to interpret week fields.Example
{:minute 1
:hour [:* 10 14]
:day-of-week [:+ :sun]
:day-of-month [:+ [:* 1 14] [:* -7 -1]]})
:*
)Range expressions capture field values that are incremented by <step>
units in a fixed
range from <min>
to, and including, a <max>
value.
Its bounded form is:
[:* <min> <max> <step>]
where min
or max
are inclusive.
Its unbounded form is:
[:* <step>]
in which case the min
and max
bounds will be inferred to the largest range at
a given time point, (see
TemporalField#rangeRefinedBy
). For
example, with {:day-of-month [:* 1]}
, the interval inferred is [1, 29]
in the
month of February of leap years. The range is reset on the next month, therefore
both the 29th of February and the 1st of March are valid dates for this set. With
{:day-of-year [:*]}
, the valid range would be reset at the beginning of the
each year. Even when they are specified, the bounds are clipped to the valid
range given by the upper field. For example, {:day-of-month [:* 0 31 2]}
will
be corrected to {:day-of-month [:* 1 30 2]}
in April.
The following syntactic sugar is supported for range expressions:
[:* <min> <max>] ==> [:* <min> <max> 1]
:* ==> [:*] <==> [:* 1]
Bounds can be negative offsets that express values relative to the end of the
period in which a date is valid, plus one. For example, [:* -7 -1]
represents the
7 last possible value a field can take at a given point in time, and the expression
{:day-of-month [:* -7 -1]}
will match the last 7 days of every month.
:+
)Enumeration expressions enumerate all the allowed values a field can take.
Its standard form is:
[:+ <v_1> ... <v_n>]
where <v_j>
is either:
In its empty form:
[:+]
the enumeration [:+ <min>]
is inferred, where <min>
is the lowest bound of
the corresponding time field at the given time point.
The following syntactic sugar is supported:
v ==> [:+ v]
:+ ==> [:+]
Values can be negative offsets that express values relative to the end of the
period in which a date is valid, plus one. For example, -1
represents the last
possible value a field can take at a given point in time, and the expression
{:day-of-month -1}
will match the last day of every month.
Values of :day-of-week
and :month-of-year
can be specified using keywords
instead of integers.
:day-of-week
value keywords:
:mon :tue :wed :thu :fri :sat :sun
:month-of-year
value keywords:
:jan :feb :mar :apr :may :jun :jul :aug :sep :oct :nov :dec
It is recommended to always use keywords for :day-of-week
instead of integers
because the integer offset of the first day will differ depending the locale.
Missing fields that have a smaller unit than the first field provided by the
client are inferred as the first value of their range, while those that have a
greater unit are inferred as the range of all their possible value. For example,
the library will expand the expression {:hour [:* 12], :day-of-week [:+ :mon :wed]}
to {:second [:+], :minute [:+], :hour [:* 2], :day-of-week [:+ 1 3], :week-of-year [:*], :year [:*]}
.
To do so, the library first attempts to fit the provided expression with one of these possible boards:
c/boards
[[:second :minute :hour :day-of-year]
[:second :minute :hour :day-of-quarter :quarter-of-year]
[:second :minute :hour :day-of-month :month]
[:second :minute :hour :day-of-week :week-of-year]
[:second :minute :hour :day-of-week :week-of-month :month]]
When all fields do not fit on a board, the library use the remaining fields as an
"AND" mask
and time points that satisfy neither the board nor the mask
expressions will be skiped.
The result of the inference can be observed using the boards-for
function, for
example:
(c/board-for {:minute 1
:day-of-week [:+ :sun]
:day-of-month [:+ [:* 1 14] [:* -7 -1]]})
{:board [:second :minute :hour :day-of-week :week-of-year], :mask [:day-of-month]}
As seen in the examples above, the library uses java.time.ZonedDateTime
to
identify the time points of recurrent events and it will simulate the
behavior of regular cron jobs during Daylight Saving Time (DST) transitions.
For zones with daylight saving time, the behavior is the following:
Time points that fall in a gap when DST starts are collapsed to a single time point that is set at the end of the gap.
When DST stops and there is an overlap, time points are returned once and in the earliest zone offset.
This is illustrated in the examples below using Brussels time zone for which DST starts on March 28th at 2am and stops on October 31st at 3am in 2021:
(c/show {:minute [:* 30], :hour [:+ 1 2 4], :day-of-month [:+ 28], :month [+ 3 10]}
{:date "2021-01-01T00:00", :zone-id "Europe/Brussels" :context 4})
"2020-10-28T02:00:00 Wed W5 w44 Q4"
"2020-10-28T02:30:00 Wed W5 w44 Q4"
"2020-10-28T04:00:00 Wed W5 w44 Q4"
"2020-10-28T04:30:00 Wed W5 w44 Q4"
"2021-01-01T00:00:00 Fri W1 w 1 Q1" ;<- :init
"2021-03-28T01:00:00 Sun W5 w14 Q1"
"2021-03-28T01:30:00 Sun W5 w14 Q1"
"2021-03-28T03:00:00 Sun W5 w14 Q1"
"2021-03-28T04:00:00 Sun W5 w14 Q1"
(c/show {:minute [:* 30], :hour [:+ 2 3 4], :day-of-month [:+ 31], :month [:+ 10]}
{:date "2021-01-01T00:00", :format :iso, :zone-id "Europe/Brussels" :context 5})
"2020-10-31T02:30:00+01:00[Europe/Brussels]"
"2020-10-31T03:00:00+01:00[Europe/Brussels]"
"2020-10-31T03:30:00+01:00[Europe/Brussels]"
"2020-10-31T04:00:00+01:00[Europe/Brussels]"
"2020-10-31T04:30:00+01:00[Europe/Brussels]"
"2021-01-01T00:00:00+01:00[Europe/Brussels]" ;<- :init
"2021-10-31T02:00:00+02:00[Europe/Brussels]"
"2021-10-31T02:30:00+02:00[Europe/Brussels]"
"2021-10-31T03:00:00+01:00[Europe/Brussels]"
"2021-10-31T03:30:00+01:00[Europe/Brussels]"
"2021-10-31T04:00:00+01:00[Europe/Brussels]"
The first thing to know about
java.time.temporal.WeekFields
is that their values are offsets dependent on the locale. So depending on the
locale, week 0
may not exist, the first week must have at least 1
, 4
or 5
days, or the first day of a week may begin on Sunday or on Monday.
The week offset can be made independent from the locale by using keywords for each day instead of integers. But for week of year or week of month, there is no good solution, and one must use them wisely.
The second thing to be aware of is that the first or last week of a year may contain days from the previous or next calendar year. And although it is not explicitly mentioned in the documentation, the same goes for week of months.
This can be seen in the example below. If we ask for the first Sunday of the
first week of year 2022, or of month of January 2022, using the SUNDAY_START
definition of week fields, then we get the last Sunday of December 2021.
(as-> (LocalDateTime/parse "2022-04-01T00:00") t
(.adjustInto (.weekOfYear WeekFields/SUNDAY_START) t 1)
(.adjustInto (.dayOfWeek WeekFields/SUNDAY_START) t 1)
(str t))
"2021-12-26T00:00"
(as-> (LocalDateTime/parse "2022-04-01T00:00") t
(.adjustInto ChronoField/MONTH t 1)
(.adjustInto (.weekOfMonth WeekFields/SUNDAY_START) t 1)
(.adjustInto (.dayOfWeek WeekFields/SUNDAY_START) t 1)
(str t))
"2021-12-26T00:00"
(as-> (LocalDateTime/parse "2021-12-26T00:00") t
(.getFrom (.weekOfYear WeekFields/SUNDAY_START) t))
53
(as-> (LocalDateTime/parse "2021-12-26T00:00") t
(.getFrom (.weekOfMonth WeekFields/SUNDAY_START) t))
5
(-> (LocalDateTime/parse "2021-12-26T00:00")
(.format (-> (DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ss E 'W'W 'w'ppw 'Q'Q")
(.withLocale (Locale/forLanguageTag "en-us")))))
"2021-12-26T00:00:00 Sun W5 w 1 Q4"
The problem is that it results into some ambiguities when the last week of one year is also the first week of the next year. A cron expression asking for every last and first Sunday of the first or last week of the year may return twice the same day. First, it would be unexpected behavior for a cron job to run twice, and even then, during initialization, it won't be able to disambiguate whether Sunday 26th December 2021 should be considered as the last Sunday of 2021 or the first Sunday of 2022.
The library solves the ambiguity by restricting days in the first or last week of a month or a year to the days in that same month or year. One important consequence, or caveat, is that some days may not exist in a given week of a given month or year. For example, since the first Sunday of the first week of the year 2021 is in 2020 and the library requires that all days of 2021 stay in 2021 to avoid the ambiguity with the last Sunday of 2020, the first Sunday of 2021 does not exist but the last one of 2020 does. This is illustrated below for 2021 and other years:
(c/show {:day-of-week :sun, :week-of-year :+, :locale "en-us"}
{:date "2021-07-01T00:00", :context 6})
"1984-01-01T00:00:00 Sun W1 w 1 Q1"
"1989-01-01T00:00:00 Sun W1 w 1 Q1"
"1995-01-01T00:00:00 Sun W1 w 1 Q1"
"2006-01-01T00:00:00 Sun W1 w 1 Q1"
"2012-01-01T00:00:00 Sun W1 w 1 Q1"
"2017-01-01T00:00:00 Sun W1 w 1 Q1"
"2021-07-01T00:00:00 Thu W1 w27 Q3" ;<- :init
"2023-01-01T00:00:00 Sun W1 w 1 Q1"
"2034-01-01T00:00:00 Sun W1 w 1 Q1"
"2040-01-01T00:00:00 Sun W1 w 1 Q1"
"2045-01-01T00:00:00 Sun W1 w 1 Q1"
"2051-01-01T00:00:00 Sun W1 w 1 Q1"
"2062-01-01T00:00:00 Sun W1 w 1 Q1"
(c/show {:day-of-week :sun, :week-of-year -1, :locale "en-us"}
{:date "2021-07-01T00:00", :context 6})
"2015-12-27T00:00:00 Sun W5 w 1 Q4"
"2016-12-25T00:00:00 Sun W5 w53 Q4"
"2017-12-31T00:00:00 Sun W6 w 1 Q4"
"2018-12-30T00:00:00 Sun W6 w 1 Q4"
"2019-12-29T00:00:00 Sun W5 w 1 Q4"
"2020-12-27T00:00:00 Sun W5 w 1 Q4"
"2021-07-01T00:00:00 Thu W1 w27 Q3" ;<- :init
"2021-12-26T00:00:00 Sun W5 w 1 Q4"
"2022-12-25T00:00:00 Sun W5 w53 Q4"
"2023-12-31T00:00:00 Sun W6 w 1 Q4"
"2024-12-29T00:00:00 Sun W5 w 1 Q4"
"2025-12-28T00:00:00 Sun W5 w 1 Q4"
"2026-12-27T00:00:00 Sun W5 w 1 Q4"
All this to conclude that weeks of month or year are best used with caution, or at least not in conjunction with a specific day of the week. It behaves as expected when one lets the library pick the first day of the week like shown below:
(c/show {:week-of-year :+, :locale "en-us"}
{:date "2021-07-01T00:00", :context 4})
"2018-01-01T00:00:00 Mon W1 w 1 Q1"
"2019-01-01T00:00:00 Tue W1 w 1 Q1"
"2020-01-01T00:00:00 Wed W1 w 1 Q1"
"2021-01-01T00:00:00 Fri W1 w 1 Q1"
"2021-07-01T00:00:00 Thu W1 w27 Q3" ;<- :init
"2022-01-01T00:00:00 Sat W1 w 1 Q1"
"2023-01-01T00:00:00 Sun W1 w 1 Q1"
"2024-01-01T00:00:00 Mon W1 w 1 Q1"
"2025-01-01T00:00:00 Wed W1 w 1 Q1"
(c/show {:week-of-year -1, :locale "en-us"}
{:date "2021-07-01T00:00", :context 6})
"2017-12-31T00:00:00 Sun W6 w 1 Q4"
"2018-12-30T00:00:00 Sun W6 w 1 Q4"
"2019-12-29T00:00:00 Sun W5 w 1 Q4"
"2020-12-27T00:00:00 Sun W5 w 1 Q4"
"2021-07-01T00:00:00 Thu W1 w27 Q3" ;<- :init
"2021-12-26T00:00:00 Sun W5 w 1 Q4"
"2022-12-25T00:00:00 Sun W5 w53 Q4"
"2023-12-31T00:00:00 Sun W6 w 1 Q4"
"2024-12-29T00:00:00 Sun W5 w 1 Q4"
(c/show {:week-of-year :+, :locale "en-be"}
{:date "2021-07-01T00:00", :context 4})
"2018-01-01T00:00:00 Mon W1 w 1 Q1"
"2019-01-01T00:00:00 Tue W1 w 1 Q1"
"2020-01-01T00:00:00 Wed W1 w 1 Q1"
"2021-01-01T00:00:00 Fri W0 w53 Q1"
"2021-07-01T00:00:00 Thu W1 w26 Q3" ;<- :init
"2022-01-01T00:00:00 Sat W0 w52 Q1"
"2023-01-01T00:00:00 Sun W0 w52 Q1"
"2024-01-01T00:00:00 Mon W1 w 1 Q1"
"2025-01-01T00:00:00 Wed W1 w 1 Q1"
(c/show {:week-of-year -1, :locale "en-be"}
{:date "2021-07-01T00:00", :context 4})
"2017-12-25T00:00:00 Mon W4 w52 Q4"
"2018-12-31T00:00:00 Mon W5 w 1 Q4"
"2019-12-30T00:00:00 Mon W5 w 1 Q4"
"2020-12-28T00:00:00 Mon W5 w53 Q4"
"2021-07-01T00:00:00 Thu W1 w26 Q3" ;<- :init
"2021-12-27T00:00:00 Mon W5 w52 Q4"
"2022-12-26T00:00:00 Mon W5 w52 Q4"
"2023-12-25T00:00:00 Mon W4 w52 Q4"
"2024-12-30T00:00:00 Mon W5 w 1 Q4"
And then, it is not ambiguous, and probably a more frequent question to ask for the first Sunday of the year, instead of the first Sunday of the first week of the year, which is easily expressed like this:
(c/show {:day-of-week :sun, :day-of-year [:* 1 7], :locale "en-us"}
{:date "2021-07-01T00:00", :context 6})
"2016-01-03T00:00:00 Sun W2 w 2 Q1"
"2017-01-01T00:00:00 Sun W1 w 1 Q1"
"2018-01-07T00:00:00 Sun W2 w 2 Q1"
"2019-01-06T00:00:00 Sun W2 w 2 Q1"
"2020-01-05T00:00:00 Sun W2 w 2 Q1"
"2021-01-03T00:00:00 Sun W2 w 2 Q1"
"2021-07-01T00:00:00 Thu W1 w27 Q3" ;<- :init
"2022-01-02T00:00:00 Sun W2 w 2 Q1"
"2023-01-01T00:00:00 Sun W1 w 1 Q1"
"2024-01-07T00:00:00 Sun W2 w 2 Q1"
"2025-01-05T00:00:00 Sun W2 w 2 Q1"
"2026-01-04T00:00:00 Sun W2 w 2 Q1"
"2027-01-03T00:00:00 Sun W2 w 2 Q1"
A last note is that the library is ANDing fields with the same unit such as day of month and day of week, while standard cron is ORing these fields.
Run the project's tests:
$ clojure -M:test:runner
Build the jar:
$ clj -X:build clean
$ clj -X:build jar
The MIT License (MIT)
Copyright © 2021-2021 Sébastien Bocq.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close