Skip to content

Commit

Permalink
add second granularity to Cron (#4088)
Browse files Browse the repository at this point in the history
  • Loading branch information
fubhy authored Dec 10, 2024
1 parent 29dff66 commit 07ad068
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/khaki-suns-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Added support for `second` granularity to `Cron`.
69 changes: 52 additions & 17 deletions packages/effect/src/Cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ export type TypeId = typeof TypeId
export interface Cron extends Pipeable, Equal.Equal, Inspectable {
readonly [TypeId]: TypeId
readonly tz: Option.Option<DateTime.TimeZone>
readonly seconds: ReadonlySet<number>
readonly minutes: ReadonlySet<number>
readonly hours: ReadonlySet<number>
readonly days: ReadonlySet<number>
readonly months: ReadonlySet<number>
readonly weekdays: ReadonlySet<number>
/** @internal */
readonly first: {
readonly second: number
readonly minute: number
readonly hour: number
readonly day: number
Expand All @@ -51,6 +53,7 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
}
/** @internal */
readonly next: {
readonly second: ReadonlyArray<number | undefined>
readonly minute: ReadonlyArray<number | undefined>
readonly hour: ReadonlyArray<number | undefined>
readonly day: ReadonlyArray<number | undefined>
Expand All @@ -67,6 +70,7 @@ const CronProto = {
[Hash.symbol](this: Cron): number {
return pipe(
Hash.hash(this.tz),
Hash.combine(Hash.array(Arr.fromIterable(this.seconds))),
Hash.combine(Hash.array(Arr.fromIterable(this.minutes))),
Hash.combine(Hash.array(Arr.fromIterable(this.hours))),
Hash.combine(Hash.array(Arr.fromIterable(this.days))),
Expand All @@ -82,6 +86,7 @@ const CronProto = {
return {
_id: "Cron",
tz: this.tz,
seconds: Arr.fromIterable(this.seconds),
minutes: Arr.fromIterable(this.minutes),
hours: Arr.fromIterable(this.hours),
days: Arr.fromIterable(this.days),
Expand Down Expand Up @@ -116,6 +121,7 @@ export const isCron = (u: unknown): u is Cron => hasProperty(u, TypeId)
* @category constructors
*/
export const make = (values: {
readonly seconds?: Iterable<number> | undefined
readonly minutes: Iterable<number>
readonly hours: Iterable<number>
readonly days: Iterable<number>
Expand All @@ -124,20 +130,23 @@ export const make = (values: {
readonly tz?: DateTime.TimeZone | undefined
}): Cron => {
const o: Mutable<Cron> = Object.create(CronProto)
o.seconds = new Set(Arr.sort(values.seconds ?? [0], N.Order))
o.minutes = new Set(Arr.sort(values.minutes, N.Order))
o.hours = new Set(Arr.sort(values.hours, N.Order))
o.days = new Set(Arr.sort(values.days, N.Order))
o.months = new Set(Arr.sort(values.months, N.Order))
o.weekdays = new Set(Arr.sort(values.weekdays, N.Order))
o.tz = Option.fromNullable(values.tz)

const seconds = Array.from(o.seconds)
const minutes = Array.from(o.minutes)
const hours = Array.from(o.hours)
const days = Array.from(o.days)
const months = Array.from(o.months)
const weekdays = Array.from(o.weekdays)

o.first = {
second: seconds[0] ?? 0,
minute: minutes[0] ?? 0,
hour: hours[0] ?? 0,
day: days[0] ?? 1,
Expand All @@ -146,6 +155,7 @@ export const make = (values: {
}

o.next = {
second: nextLookupTable(seconds, 60),
minute: nextLookupTable(minutes, 60),
hour: nextLookupTable(hours, 24),
day: nextLookupTable(days, 32),
Expand Down Expand Up @@ -233,7 +243,8 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars
* import { Cron, Either } from "effect"
*
* // At 04:00 on every day-of-month from 8 through 14.
* assert.deepStrictEqual(Cron.parse("0 4 8-14 * *"), Either.right(Cron.make({
* assert.deepStrictEqual(Cron.parse("0 0 4 8-14 * *"), Either.right(Cron.make({
* seconds: [0],
* minutes: [0],
* hours: [4],
* days: [8, 9, 10, 11, 12, 13, 14],
Expand All @@ -247,12 +258,17 @@ export const isParseError = (u: unknown): u is ParseError => hasProperty(u, Pars
*/
export const parse = (cron: string, tz?: DateTime.TimeZone): Either.Either<Cron, ParseError> => {
const segments = cron.split(" ").filter(String.isNonEmpty)
if (segments.length !== 5) {
if (segments.length !== 5 && segments.length !== 6) {
return Either.left(ParseError(`Invalid number of segments in cron expression`, cron))
}

const [minutes, hours, days, months, weekdays] = segments
if (segments.length === 5) {
segments.unshift("0")
}

const [seconds, minutes, hours, days, months, weekdays] = segments
return Either.all({
seconds: parseSegment(seconds, secondOptions),
minutes: parseSegment(minutes, minuteOptions),
hours: parseSegment(hours, hourOptions),
days: parseSegment(days, dayOptions),
Expand Down Expand Up @@ -285,6 +301,10 @@ export const match = (cron: Cron, date: DateTime.DateTime.Input): boolean => {
timeZone: Option.getOrUndefined(cron.tz)
}).pipe(dateTime.toParts)

if (cron.seconds.size !== 0 && !cron.seconds.has(parts.seconds)) {
return false
}

if (cron.minutes.size !== 0 && !cron.minutes.has(parts.minutes)) {
return false
}
Expand Down Expand Up @@ -358,19 +378,34 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
}

const result = dateTime.mutate(zoned, (current) => {
current.setUTCMinutes(current.getUTCMinutes() + 1, 0, 0)
current.setUTCSeconds(current.getUTCSeconds() + 1, 0)

for (let i = 0; i < 10_000; i++) {
if (cron.seconds.size !== 0) {
const currentSecond = current.getUTCSeconds()
const nextSecond = cron.next.second[currentSecond]
if (nextSecond === undefined) {
current.setUTCMinutes(current.getUTCMinutes() + 1, cron.first.second)
adjustDst(current)
continue
}
if (nextSecond > currentSecond) {
current.setUTCSeconds(nextSecond)
adjustDst(current)
continue
}
}

if (cron.minutes.size !== 0) {
const currentMinute = current.getUTCMinutes()
const nextMinute = cron.next.minute[currentMinute]
if (nextMinute === undefined) {
current.setUTCHours(current.getUTCHours() + 1, cron.first.minute)
current.setUTCHours(current.getUTCHours() + 1, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
if (nextMinute > currentMinute) {
current.setUTCMinutes(nextMinute)
current.setUTCMinutes(nextMinute, cron.first.second)
adjustDst(current)
continue
}
Expand All @@ -381,12 +416,12 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
const nextHour = cron.next.hour[currentHour]
if (nextHour === undefined) {
current.setUTCDate(current.getUTCDate() + 1)
current.setUTCHours(cron.first.hour, cron.first.minute)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
if (nextHour > currentHour) {
current.setUTCHours(nextHour, cron.first.minute)
current.setUTCHours(nextHour, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
Expand All @@ -411,7 +446,7 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
const addDays = Math.min(a, b)
if (addDays !== 0) {
current.setUTCDate(current.getUTCDate() + addDays)
current.setUTCHours(cron.first.hour, cron.first.minute)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
Expand All @@ -423,13 +458,13 @@ export const next = (cron: Cron, now?: DateTime.DateTime.Input): Date => {
if (nextMonth === undefined) {
current.setUTCFullYear(current.getUTCFullYear() + 1)
current.setUTCMonth(cron.first.month, cron.first.day)
current.setUTCHours(cron.first.hour, cron.first.minute)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
if (nextMonth > currentMonth) {
current.setUTCMonth(nextMonth - 1, cron.first.day)
current.setUTCHours(cron.first.hour, cron.first.minute)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
adjustDst(current)
continue
}
Expand Down Expand Up @@ -463,6 +498,7 @@ export const sequence = function*(cron: Cron, now?: DateTime.DateTime.Input): It
* @since 2.0.0
*/
export const Equivalence: equivalence.Equivalence<Cron> = equivalence.make((self, that) =>
restrictionsEquals(self.seconds, that.seconds) &&
restrictionsEquals(self.minutes, that.minutes) &&
restrictionsEquals(self.hours, that.hours) &&
restrictionsEquals(self.days, that.days) &&
Expand All @@ -486,32 +522,32 @@ export const equals: {
} = dual(2, (self: Cron, that: Cron): boolean => Equivalence(self, that))

interface SegmentOptions {
segment: string
min: number
max: number
aliases?: Record<string, number> | undefined
}

const secondOptions: SegmentOptions = {
min: 0,
max: 59
}

const minuteOptions: SegmentOptions = {
segment: "minute",
min: 0,
max: 59
}

const hourOptions: SegmentOptions = {
segment: "hour",
min: 0,
max: 23
}

const dayOptions: SegmentOptions = {
segment: "day",
min: 1,
max: 31
}

const monthOptions: SegmentOptions = {
segment: "month",
min: 1,
max: 12,
aliases: {
Expand All @@ -531,7 +567,6 @@ const monthOptions: SegmentOptions = {
}

const weekdayOptions: SegmentOptions = {
segment: "weekday",
min: 0,
max: 6,
aliases: {
Expand Down
4 changes: 2 additions & 2 deletions packages/effect/src/Schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,9 @@ export const mapInputEffect: {
export const count: Schedule<number> = internal.count

/**
* Cron schedule that recurs every `minute` that matches the schedule.
* Cron schedule that recurs every interval that matches the schedule.
*
* It triggers at zero second of the minute. Producing the timestamps of the cron window.
* It triggers at the beginning of each cron interval, producing the timestamps of the cron window.
*
* NOTE: `expression` parameter is validated lazily. Must be a valid cron expression.
*
Expand Down
4 changes: 2 additions & 2 deletions packages/effect/src/internal/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,8 @@ export const cron = (expression: string | Cron.Cron): Schedule.Schedule<[number,
}

next = Cron.next(cron, date).getTime()
const start = beginningOfMinute(next)
const end = endOfMinute(next)
const start = beginningOfSecond(next)
const end = endOfSecond(next)
return core.succeed([
[false, [next, start, end]],
[start, end],
Expand Down
5 changes: 5 additions & 0 deletions packages/effect/test/Cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ describe("Cron", () => {
assertFalse(match("5 4 * * SUN", "2024-01-08 04:05:00"))
assertFalse(match("5 4 * * SUN", "2025-01-07 04:05:00"))

assertTrue(match("42 5 0 * 8 *", "2024-08-01 00:05:42"))
assertFalse(match("42 5 0 * 8 *", "2024-09-01 00:05:42"))
assertFalse(match("42 5 0 * 8 *", "2024-08-01 01:05:42"))

const london = DateTime.zoneUnsafeMakeNamed("Europe/London")
const londonTime = DateTime.unsafeMakeZoned("2024-06-01 14:15:00Z", {
timeZone: london,
Expand All @@ -98,6 +102,7 @@ describe("Cron", () => {
deepStrictEqual(next("23 0-20/2 * * 0", after), new Date("2024-01-07 00:23:00"))
deepStrictEqual(next("5 4 * * SUN", after), new Date("2024-01-07 04:05:00"))
deepStrictEqual(next("5 4 * DEC SUN", after), new Date("2024-12-01 04:05:00"))
deepStrictEqual(next("30 5 0 8 2 *", after), new Date("2024-02-08 00:05:30"))

const london = DateTime.zoneUnsafeMakeNamed("Europe/London")
const londonTime = DateTime.unsafeMakeZoned("2024-02-08 00:05:00Z", {
Expand Down
29 changes: 28 additions & 1 deletion packages/effect/test/Schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,6 @@ describe("Schedule", () => {
]
assert.deepStrictEqual(result, expected)
}))

it.effect("recur at time matching cron expression", () =>
Effect.gen(function*($) {
const ref = yield* $(Ref.make<ReadonlyArray<string>>([]))
Expand All @@ -620,6 +619,34 @@ describe("Schedule", () => {
]
assert.deepStrictEqual(result, expected)
}))
it.effect("recur at time matching cron expression (second granularity)", () =>
Effect.gen(function*($) {
const ref = yield* $(Ref.make<ReadonlyArray<string>>([]))
yield* $(TestClock.setTime(new Date(2024, 0, 1, 0, 0, 0).getTime()))
const schedule = Schedule.cron("*/3 * * * * *")
yield* $(
TestClock.currentTimeMillis,
Effect.tap((instant) => Ref.update(ref, Array.append(format(instant)))),
Effect.repeat(schedule),
Effect.fork
)
yield* $(TestClock.adjust("30 seconds"))
const result = yield* $(Ref.get(ref))
const expected = [
"Mon Jan 01 2024 00:00:00",
"Mon Jan 01 2024 00:00:03",
"Mon Jan 01 2024 00:00:06",
"Mon Jan 01 2024 00:00:09",
"Mon Jan 01 2024 00:00:12",
"Mon Jan 01 2024 00:00:15",
"Mon Jan 01 2024 00:00:18",
"Mon Jan 01 2024 00:00:21",
"Mon Jan 01 2024 00:00:24",
"Mon Jan 01 2024 00:00:27",
"Mon Jan 01 2024 00:00:30"
]
assert.deepStrictEqual(result, expected)
}))
it.effect("recur at 01 second of each minute", () =>
Effect.gen(function*($) {
const originOffset = new Date(new Date(new Date().setMinutes(0)).setSeconds(0)).setMilliseconds(0)
Expand Down

0 comments on commit 07ad068

Please sign in to comment.