Small Scala library for parsing systemd.time like calendar event expressions. It is available for Scala (JVM and ScalaJS) 2.12, 2.13 and 3.0. The core module has no dependencies.
It serves the same purpose as cron expressions, but uses a different
syntax: a "normal" timestamp where each part is a pattern. A pattern
is a list of values, a range or *
meaning every value. Some
examples:
Expression | Meaning |
---|---|
*-*-* 12:15:00 |
every day at 12:15 |
2020-1,5,9-* 10:00:00 |
every day on Jan, May and Sept of 2020 at 10:00 |
Mon *-*-* 09:00:00 |
every monday at 9:00 |
Mon..Fri *-*-1/7 15:00:00 |
on 1.,8.,15. etc of every month at 15:00 but not on weekends |
The 1/7
means value 1
and all multiples of 7
added to it. A
range with repetition, like 1..12/2
means 1
and all multiples of
2
addet to it within the range 1..12
.
For more information see
man systemd.time
or
https://man.cx/systemd.time#heading7
This library has some limitations when parsing calendar events compared to systemd:
- The
~
in the date part for refering last days of a month is not supported. - No parts except weekdays may be absent. Date and time parts must all be specified, except seconds are optional.
- The core module has zero dependencies and implements the parser
and generator for calendar events. It is also published for ScalaJS.
With sbt, use:
libraryDependencies += "com.github.eikek" %% "calev-core" % "0.7.1"
- The fs2 module contains utilities to work with
FS2 streams.
These were taken, thankfully and slightly modified to exchange cron expressions
for calendar events, from the
fs2-cron library. It is also published
for ScalaJS. With sbt, use
libraryDependencies += "com.github.eikek" %% "calev-fs2" % "0.7.1"
- The doobie module contains
Meta
,Read
andWrite
instances forCalEvent
to use with doobie.libraryDependencies += "com.github.eikek" %% "calev-doobie" % "0.7.1"
- The circe module defines a json decoder and encoder for
CalEvent
instances to use with circe. It is also published for ScalaJS.libraryDependencies += "com.github.eikek" %% "calev-circe" % "0.7.1"
- The jackson module defines
CalevModule
for JacksonlibraryDependencies += "com.github.eikek" %% "calev-jackson" % "0.7.1"
- The akka module allows to use calendar events with Akka Scheduler
and Akka Timers.
libraryDependencies += "com.github.eikek" %% "calev-akka" % "0.7.1"
Note that the fs2 module is also available via fs2-cron library.
Calendar events can be read from a string:
import com.github.eikek.calev._
CalEvent.parse("Mon..Fri *-*-* 6,14:0:0")
// res0: Either[String, CalEvent] = Right(
// value = CalEvent(
// weekday = List(
// values = Vector(Range(range = WeekdayRange(start = Mon, end = Fri)))
// ),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(
// values = Vector(
// Single(value = 6, rep = None),
// Single(value = 14, rep = None)
// )
// ),
// minute = List(values = Vector(Single(value = 0, rep = None))),
// seconds = List(values = Vector(Single(value = 0, rep = None)))
// ),
// zone = None
// )
// )
CalEvent.parse("Mon *-*-* 6,88:0:0")
// res1: Either[String, CalEvent] = Left(
// value = "Value 88 not in range [0,23]"
// )
There is an unsafe
way that throws exceptions:
CalEvent.unsafe("*-*-* 0/2:0:0")
// res2: CalEvent = CalEvent(
// weekday = All,
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(values = Vector(Single(value = 0, rep = Some(value = 2)))),
// minute = List(values = Vector(Single(value = 0, rep = None))),
// seconds = List(values = Vector(Single(value = 0, rep = None)))
// ),
// zone = None
// )
There is a tiny dsl for more conveniently defining events in code:
import com.github.eikek.calev.Dsl._
val ce = CalEvent(AllWeekdays, DateEvent.All, time(0 #/ 2, 0.c, 0.c))
// ce: CalEvent = CalEvent(
// weekday = All,
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(values = List(Single(value = 0, rep = Some(value = 2)))),
// minute = List(values = List(Single(value = 0, rep = None))),
// seconds = List(values = List(Single(value = 0, rep = None)))
// ),
// zone = None
// )
ce.asString
// res3: String = "*-*-* 00/2:00:00"
Once there is a calendar event, the times it will elapse next can be generated:
import java.time._
ce.asString
// res4: String = "*-*-* 00/2:00:00"
val now = LocalDateTime.now
// now: LocalDateTime = 2023-09-03T01:42:02.057451594
ce.nextElapse(now)
// res5: Option[LocalDateTime] = Some(value = 2023-09-03T02:00)
ce.nextElapses(now, 5)
// res6: List[LocalDateTime] = List(
// 2023-09-03T02:00,
// 2023-09-03T04:00,
// 2023-09-03T06:00,
// 2023-09-03T08:00,
// 2023-09-03T10:00
// )
If an event is in the past, the nextElapsed
returns a None
:
CalEvent.unsafe("1900-01-* 12,14:0:0").nextElapse(LocalDateTime.now)
// res7: Option[LocalDateTime] = None
The fs2 utilities allow to schedule things based on calendar events. This is the same as fs2-cron provides, only adopted to use calendar events instead of cron expressions. The example is also from there.
import cats.effect.IO
import _root_.fs2.Stream
import com.github.eikek.calev.fs2.Scheduler
import java.time.LocalTime
val everyTwoSeconds = CalEvent.unsafe("*-*-* *:*:0/2")
// everyTwoSeconds: CalEvent = CalEvent(
// weekday = All,
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = All,
// minute = All,
// seconds = List(values = Vector(Single(value = 0, rep = Some(value = 2))))
// ),
// zone = None
// )
val scheduler = Scheduler.systemDefault[IO]
// scheduler: Scheduler[IO] = com.github.eikek.calev.fs2.Scheduler$$anon$1@357f0cc9
val printTime = Stream.eval(IO(println(LocalTime.now)))
// printTime: Stream[IO, Unit] = Stream(..)
val task = scheduler.awakeEvery(everyTwoSeconds) >> printTime
// task: Stream[[x]IO[x], Unit] = Stream(..)
import cats.effect.unsafe.implicits._
task.take(3).compile.drain.unsafeRunSync()
// 01:42:04.006338192
// 01:42:06.000327762
// 01:42:08.000910107
When using doobie, this module contains instances to write and read calendar event expressions through SQL.
import com.github.eikek.calev._
import com.github.eikek.calev.doobie.CalevDoobieMeta._
import _root_.doobie._
import _root_.doobie.implicits._
case class Record(event: CalEvent)
val r = Record(CalEvent.unsafe("Mon *-*-* 0/2:15"))
// r: Record = Record(
// event = CalEvent(
// weekday = List(values = Vector(Single(day = Mon))),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(values = Vector(Single(value = 0, rep = Some(value = 2)))),
// minute = List(values = Vector(Single(value = 15, rep = None))),
// seconds = List(values = List(Single(value = 0, rep = None)))
// ),
// zone = None
// )
// )
val insert =
sql"INSERT INTO mytable (event) VALUES (${r.event})".update.run
// insert: ConnectionIO[Int] = Suspend(
// a = Uncancelable(
// body = cats.effect.kernel.MonadCancel$$Lambda$2205/0x000000080187f010@16bed816
// )
// )
val select =
sql"SELECT event FROM mytable WHERE id = 1".query[Record].unique
// select: ConnectionIO[Record] = Suspend(
// a = Uncancelable(
// body = cats.effect.kernel.MonadCancel$$Lambda$2205/0x000000080187f010@4cae7d5
// )
// )
The defined encoders/decoders can be put in scope to use calendar event expressions in json.
import com.github.eikek.calev._
import com.github.eikek.calev.circe.CalevCirceCodec._
import io.circe._
import io.circe.generic.semiauto._
import io.circe.syntax._
case class Meeting(name: String, event: CalEvent)
object Meeting {
implicit val jsonDecoder = deriveDecoder[Meeting]
implicit val jsonEncoder = deriveEncoder[Meeting]
}
val meeting = Meeting("trash can", CalEvent.unsafe("Mon..Fri *-*-* 14,18:0"))
// meeting: Meeting = Meeting(
// name = "trash can",
// event = CalEvent(
// weekday = List(
// values = Vector(Range(range = WeekdayRange(start = Mon, end = Fri)))
// ),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(
// values = Vector(
// Single(value = 14, rep = None),
// Single(value = 18, rep = None)
// )
// ),
// minute = List(values = Vector(Single(value = 0, rep = None))),
// seconds = List(values = List(Single(value = 0, rep = None)))
// ),
// zone = None
// )
// )
val json = meeting.asJson.noSpaces
// json: String = "{\"name\":\"trash can\",\"event\":\"Mon..Fri *-*-* 14,18:00:00\"}"
val read = for {
parsed <- parser.parse(json)
value <- parsed.as[Meeting]
} yield value
// read: Either[Error, Meeting] = Right(
// value = Meeting(
// name = "trash can",
// event = CalEvent(
// weekday = List(
// values = Vector(Range(range = WeekdayRange(start = Mon, end = Fri)))
// ),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(
// values = Vector(
// Single(value = 14, rep = None),
// Single(value = 18, rep = None)
// )
// ),
// minute = List(values = Vector(Single(value = 0, rep = None))),
// seconds = List(values = Vector(Single(value = 0, rep = None)))
// ),
// zone = None
// )
// )
// )
Add CalevModule
to use calendar event expressions in json:
import com.github.eikek.calev._
import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.databind.json.JsonMapper
import com.github.eikek.calev.jackson.CalevModule
val jackson = JsonMapper
.builder()
.addModule(new CalevModule())
.build()
// jackson: JsonMapper = com.fasterxml.jackson.databind.json.JsonMapper@4c821dc7
val myEvent = CalEvent.unsafe("Mon *-*-* 05:00/10:00")
// myEvent: CalEvent = CalEvent(
// weekday = List(values = Vector(Single(day = Mon))),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(values = Vector(Single(value = 5, rep = None))),
// minute = List(values = Vector(Single(value = 0, rep = Some(value = 10)))),
// seconds = List(values = Vector(Single(value = 0, rep = None)))
// ),
// zone = None
// )
val eventSerialized = jackson.writeValueAsString(myEvent)
// eventSerialized: String = "\"Mon *-*-* 05:00/10:00\""
val eventDeserialized = jackson.readValue(eventSerialized, new TypeReference[CalEvent] {})
// eventDeserialized: CalEvent = CalEvent(
// weekday = List(values = Vector(Single(day = Mon))),
// date = DateEvent(year = All, month = All, day = All),
// time = TimeEvent(
// hour = List(values = Vector(Single(value = 5, rep = None))),
// minute = List(values = Vector(Single(value = 0, rep = Some(value = 10)))),
// seconds = List(values = Vector(Single(value = 0, rep = None)))
// ),
// zone = None
// )
When building actor behavior, use CalevBehaviors.withCalevTimers
to get access to CalevTimerScheduler
.
Use CalevTimerScheduler
to start single Akka Timer
for the upcoming event according to given calendar event definition.
import com.github.eikek.calev.CalEvent
import java.time._
import com.github.eikek.calev.akka._
import com.github.eikek.calev.akka.dsl.CalevBehaviors
import _root_.akka.actor.typed._
import _root_.akka.actor.typed.scaladsl.Behaviors._
sealed trait Message
case class Tick(timestamp: ZonedDateTime) extends Message
case class Ping() extends Message
// every day, every full minute
def calEvent = CalEvent.unsafe("*-*-* *:0/1:0")
CalevBehaviors.withCalevTimers[Message]() { scheduler =>
scheduler.startSingleTimer(calEvent, Tick)
receiveMessage[Message] {
case tick: Tick =>
println(
s"Tick scheduled at ${tick.timestamp.toLocalTime} received at: ${LocalTime.now}"
)
same
case ping: Ping =>
println("Ping received")
same
}
}
// res9: Behavior[Message] = Deferred(TimerSchedulerImpl.scala:29)
Use CalevBehaviors.withCalendarEvent
to schedule messages according
to the given calendar event definition.
CalevBehaviors.withCalendarEvent(calEvent)(
Tick,
receiveMessage[Message] {
case tick: Tick =>
println(
s"Tick scheduled at ${tick.timestamp.toLocalTime} received at: ${LocalTime.now}"
)
same
case ping: Ping =>
println("Ping received")
same
}
)
// res10: Behavior[Message] = Deferred(InterceptorImpl.scala:29-30)
Schedule the sending of a message to the given target Actor at the time of the upcoming event according to the given calendar event definition.
def behavior(tickReceiver: ActorRef[Tick]): Behavior[Message] =
setup { actorCtx =>
actorCtx.scheduleOnceWithCalendarEvent(calEvent, tickReceiver, Tick)
same
}
Schedule the running of a Runnable
at the time of the upcoming
event according to the given calendar event definition.
implicit val system: ActorSystem[_] = ActorSystem(empty, "my-system")
// system: ActorSystem[_] = akka://my-system
import system.executionContext
calevScheduler().scheduleOnceWithCalendarEvent(calEvent, () => {
println(
s"Called at: ${LocalTime.now}"
)
})
// res11: Option[<none>.<root>.akka.actor.Cancellable] = Some(
// value = akka.actor.LightArrayRevolverScheduler$TaskHolder@432e242d
// )
system.terminate()