A schedule's cron expression: which ones the node accepts, and when one comes
due next in the node's time zone. The crontab package parses and walks them;
this module decides only what that package leaves open.
What is accepted
Exactly five fields — minute, hour, day of month, month, day of week — with
numbers, ranges, steps, lists, day and month names, and L, W and #; or one
of the nicknames @hourly, @daily, @midnight, @weekly, @monthly,
@yearly and @annually. Five fields fire at most once a minute, which is as
often as a schedule may.
Four shapes the package would parse are refused — the first three before it
sees them, the fourth on the values it reads.
@reboot names an event, not a cadence. A sixth, year field would be a second
way for a schedule to end, and its lifetime is the one bound. Fewer than five
fields is a slip the package would silently pad with *: * * * typed for
0 7 * * * would fire every minute for as long as the schedule lives. And a
day of week 0 carrying L or # never comes due in the package's walk —
0L walks without end and 0#2 answers that no date exists — so Sunday is
written 7 or SUN with them.
A clock that jumps
Every expression is walked with on_ambiguity: [:prior], set after the parse,
because the package's nicknames come back without it. When the clock falls
back and an hour occurs twice, a time in that hour comes due once, on its first
pass, and a cadence of any density keeps the first pass of the hour and skips
the second. When the clock springs forward, a time inside the missing hour
does not come due that day. Both are small-hours misses on two days a year,
met with the simplest rule that never fires anything twice.
The package alone keeps that rule only for a walk that starts before the
repeated hour. Its search includes the moment it starts from, so a walk that
starts on a matching minute of the second pass answers that very minute, and
* * * * * walked on a minute at a time would come due all through the
second pass. So next_firing/2 checks every answer the package gives: one
that is the second pass of a repeated local time is walked past, a minute at
a time, until the answer is a first pass or a time that does not repeat.
Whoever asks, from whichever minute — YmerNode.Schedules.Scheduler after a
restart or a changed expression, or YmerNode.Schedules.list/0 — gets the
same answer.
A walk that gives out
A walk runs in a task of its own under a deadline, and one that raises or runs
past it answers :none and logs a warning naming the expression. The package
has walks that never return, and one schedule's expression must not hold up
the minute every other schedule fires in, or an answer that lists them.
Summary
Functions
The first moment at or after from that the expression comes due, in from's
zone — :none when it never does, or when the walk gives out. A from with
seconds past its minute has left that minute, so the answer is a later one,
and the answer is never the second pass of a repeated local time.
Parses a cron expression the node accepts, set to fire only the first pass of a repeated hour.
Functions
The first moment at or after from that the expression comes due, in from's
zone — :none when it never does, or when the walk gives out. A from with
seconds past its minute has left that minute, so the answer is a later one,
and the answer is never the second pass of a repeated local time.
Examples
iex> {:ok, cron} = YmerNode.Schedules.Cron.parse("0 7 * * *")
iex> YmerNode.Schedules.Cron.next_firing(cron, ~U[2026-09-26 07:00:00Z])
~U[2026-09-26 07:00:00Z]
iex> YmerNode.Schedules.Cron.next_firing(cron, ~U[2026-09-26 07:00:30Z])
~U[2026-09-27 07:00:00Z]
Parses a cron expression the node accepts, set to fire only the first pass of a repeated hour.
Examples
iex> {:ok, cron} = YmerNode.Schedules.Cron.parse("0 7 * * MON-FRI")
iex> cron.on_ambiguity
[:prior]
iex> YmerNode.Schedules.Cron.parse("* * *")
{:error, {:invalid_cron_expression, "* * * has 3 fields; a cron expression has exactly five — minute, hour, day of month, month, day of week"}}
iex> YmerNode.Schedules.Cron.parse("@reboot")
{:error, {:invalid_cron_expression, "@reboot names an event, not a cadence; give five fields or one of @hourly, @daily, @midnight, @weekly, @monthly, @yearly, @annually"}}
iex> YmerNode.Schedules.Cron.parse("0 7 * * 0L")
{:error, {:invalid_cron_expression, "0 7 * * 0L: write Sunday as 7 or SUN with L or # — a day of week 0 with them never comes due"}}
iex> YmerNode.Schedules.Cron.parse("0 7 * * +0#2")
{:error, {:invalid_cron_expression, "0 7 * * +0#2: write Sunday as 7 or SUN with L or # — a day of week 0 with them never comes due"}}