- Kotlin 100%
|
All checks were successful
Publish Package (solera) TeamCity build finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| gradle | ||
| kotlin-js-store | ||
| solera | ||
| solera-compiler-plugin | ||
| solera-coroutines | ||
| solera-gradle-plugin | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
Solera
Solera is a cron library for Kotlin Multiplatform. A schedule carries its own time zone, finds the next and previous run as an Instant, and reads Quartz extensions and hashed fields. An optional compiler plugin checks constant expressions while you type.
val nightly = Solera.parse("CRON_TZ=Asia/Seoul 0 4 * * MON-FRI")
nightly.next(after = Clock.System.now()) // Instant
nightly.upcoming(after = now).take(5) // Sequence<Instant>
nightly.toString() // CRON_TZ=Asia/Seoul 0 4 * * MON-FRI
Or without a string:
val nightly = solera(zone = TimeZone.of("Asia/Seoul")) {
hour(4)
dayOfWeek(MONDAY..FRIDAY)
}
A solera is a way of ageing wine and sherry: at fixed times, part of each barrel is drawn and topped up from the next one.
Contents
- Setup
- Expressions
- Schedules
- Time zones and daylight saving time
- Hashed fields
- Builder
- Serialization
- Coroutines
- Compile-time checks
- Compatibility
- Building
- Publishing
- License
Setup
Solera publishes to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to your repositories:
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://forgejo.verchase.com/api/packages/nillpoe/maven")
mavenCentral()
}
}
Then apply the plugin next to the Kotlin plugin:
// build.gradle.kts
plugins {
kotlin("jvm") version "2.4.10" // or kotlin("multiplatform")
id("xyz.nillpoe.solera") version "1.0.0-2.4.10"
}
dependencies {
implementation("xyz.nillpoe.solera:solera-coroutines:1.0.0-2.4.10") // optional
}
The plugin adds the xyz.nillpoe.solera:solera library to implementation (or commonMainImplementation) and runs the compile-time checks. Without the checks, depend on xyz.nillpoe.solera:solera directly; the plugin is optional.
The library supports JVM, JS, Wasm and Kotlin/Native. It depends on kotlinx-datetime and kotlinx-serialization-core.
Expressions
[CRON_TZ=<zone>] [<second>] <minute> <hour> <day of month> <month> <day of week>
With five fields the second is 0. With six fields the second comes first.
| Field | Values | Names |
|---|---|---|
| second | 0-59 | |
| minute | 0-59 | |
| hour | 0-23 | |
| day of month | 1-31 | |
| month | 1-12 | JAN-DEC |
| day of week | 0-7, 0 and 7 are Sunday |
SUN-SAT |
Names are case-insensitive. Each field is a list of items separated by ,:
| Item | Meaning |
|---|---|
* |
every value |
? |
every value, only in day of month and day of week |
5 |
one value |
1-5 |
a range; 22-2 wraps around: 22, 23, 0, 1, 2 |
*/15, 1-30/5, 5/15 |
every 15th value, from the start of the field, the range, or 5 |
L |
day of month: the last day |
L-3 |
day of month: 3 days before the last day |
15W |
day of month: the weekday nearest to the 15th, in the same month |
LW |
day of month: the last weekday |
FRI#2 |
day of week: the second Friday of the month |
FRIL or 5L |
day of week: the last Friday of the month |
H, H(0-29), H/15, H(0-29)/15 |
a hashed value |
Macros stand for the whole expression: @yearly (@annually), @monthly, @weekly, @daily (@midnight) and @hourly.
When both day of month and day of week are restricted, a date runs if either matches, as in Vixie cron: 0 0 1 * MON runs on the 1st and on every Monday. A field is restricted unless it covers every value (*, ?, 1-31, 0-6, ...).
Solera.parse throws SoleraSyntaxException with the position and a reason:
Invalid cron expression `0 25 * * *` at index 2: Hour `25` is out of range. Expected 0-23.
An expression that never runs is a syntax error too, so next never has to return null:
Invalid cron expression `0 0 30 2 *` at index 4: The expression never runs: no date has day of month `30` in month `FEB`.
Solera.parseOrNull returns null instead of throwing.
Schedules
val schedule = Solera.parse("0 */6 * * *")
schedule.next(after = instant) // first run strictly after
schedule.previous(before = instant) // last run strictly before
schedule.upcoming(after = instant) // every run after, lazily
schedule.matches(instant) // is the second containing `instant` scheduled?
schedule.zone // TimeZone
schedule.withZone(zone) // the same fields in another zone
schedule.seeded("reaper") // see Hashed fields
Schedules are precise to the second. Fractions of a second are ignored when matching.
toString() returns the canonical expression, which parse reads back to an equal schedule. It writes the zone only when it is not UTC, the seconds only when they are not 0, names in capitals, and merges values into ranges and steps:
| Parsed | Canonical |
|---|---|
0 0 4 * * * |
0 4 * * * |
0,15,30,45 * * * * |
*/15 * * * * |
TZ=Asia/Seoul 0 9-17 * * mon-fri |
CRON_TZ=Asia/Seoul 0 9-17 * * MON-FRI |
0 0 * * 1-7 |
0 0 * * * |
@weekly |
0 0 * * SUN |
Two schedules are equal when their canonical expressions and zones are equal, and, for expressions with H, their seeds.
There are shortcuts for common schedules:
Solera.daily(at = LocalTime(4, 0), zone)
Solera.hourly(minute = 10, zone)
Solera.weekly(on = MONDAY, at = LocalTime(9, 0), zone)
Time zones and daylight saving time
The zone comes from a CRON_TZ= (or TZ=) prefix, or from Solera.parse(expression, zone). Without either, the zone is UTC, never the system default. Giving both is an error.
Local times that do not exist or exist twice follow two rules:
- A scheduled time skipped when clocks move forward runs at the moment of the change. In New York,
30 2 * * *runs at 03:00 on the day daylight saving time starts. - A scheduled time that occurs twice when clocks move back runs only the first time.
On JS and Wasm, zones other than UTC need the time zone database that kotlinx-datetime describes in its README.
Hashed fields
H picks a value from a seed. The value is the same every time for the same seed, and different seeds spread over the range. Use it so that many jobs with the same schedule do not all start at the same second.
val cleanup = Solera.parse("H 4 * * *").seeded("cleanup") // 04:mm, mm picked from "cleanup"
val reports = Solera.parse("H 4 * * *").seeded("reports") // most likely another minute
| Item | Picks |
|---|---|
H |
one value from the whole field; days of month stop at 28 |
H(0-29) |
one value from the range |
H/15 |
every 15th value, starting at an offset below 15 |
H(0-29)/10 |
every 10th value in the range, from an offset |
Without seeded, the seed is the canonical expression. The seed is not part of toString() or the serialized form, so call seeded after reading a schedule from a configuration file.
Builder
solera { } builds a schedule with Kotlin values:
solera(zone = TimeZone.of("Asia/Seoul")) {
minute(0..59 step 15)
hour(9..18)
dayOfWeek(MONDAY..FRIDAY)
}
// CRON_TZ=Asia/Seoul */15 9-18 * * MON-FRI
solera {
dayOfMonth(last, nearestWeekday(15))
dayOfWeek(FRIDAY.nth(2), FRIDAY.last)
}
// 0 0 L,15W * FRI#2,FRIL
solera(seed = "reaper") {
minute(hashed)
hour(4)
}
// H 4 * * *
second,minute,hour,dayOfMonthandmonthtake values (hour(9, 18)), progressions (0..59 step 15) orhashed,hashed(0..29),hashed(step = 15),hashed(0..29, 10).monthalso takesMonthvalues and ranges;dayOfWeektakesDayOfWeekvalues and ranges in ISO order (SATURDAY..SUNDAY).- Day of month rules:
last,last(minus = 3),nearestWeekday(15),lastWeekday. Day of week rules:FRIDAY.nth(2),FRIDAY.last. - Calling a field again adds to it.
- Fields left out are
*, except fields finer than the finest one you set, which take their first value.hour(9)alone runs at 09:00:00, not every second from 09:00 to 09:59.month(JANUARY)alone runs at midnight on January 1st. Day of week left out is always*.
Serialization
Solera is @Serializable as its canonical expression:
@Serializable
class Job(val name: String, val schedule: Solera)
Json.encodeToString(Job("reaper", Solera.parse("TZ=Asia/Seoul 0 0 4 * * mon-fri")))
// {"name":"reaper","schedule":"CRON_TZ=Asia/Seoul 0 4 * * MON-FRI"}
An invalid expression fails decoding with a SerializationException that carries the syntax error.
Coroutines
xyz.nillpoe.solera:solera-coroutines adds:
val next: Instant = schedule.delayUntilNext() // suspends until the next run
schedule.ticks().collect { instant -> // emits each run when it comes
cleanUp()
}
Both take an optional Clock. ticks skips runs that pass while the collector is busy instead of emitting them late in a burst.
Compile-time checks
With the Gradle plugin, the compiler checks constants passed to parameters marked @SoleraExpression or @SoleraRange, which Solera's own API uses:
Solera.parse("0 25 * * *")
// error: Invalid cron expression at index 2: Hour `25` is out of range. Expected 0-23.
Solera.parse("CRON_TZ=Mars/Olympus 0 4 * * *")
// error: Unknown time zone `Mars/Olympus`. Use an IANA id such as `Asia/Seoul`.
solera {
minute(75) // error: `75` is out of range. Expected a value from 0 to 59.
hour(30..10) // error: The range `30..10` is empty or counts down.
}
String literals, const vals and their concatenations are checked, as are integer literals, const vals, and .., until, downTo and step with constant ends. Anything computed at run time is left to Solera.parse, which reports the same errors.
Mark your own parameters to get the same checks:
fun schedule(name: String, @SoleraExpression expression: String) = Solera.parse(expression).seeded(name)
The diagnostics are SOLERA_INVALID_EXPRESSION, SOLERA_UNKNOWN_TIME_ZONE, SOLERA_OUT_OF_RANGE and SOLERA_EMPTY_RANGE. Turn the plugin off with:
solera {
enabled = false
}
addLibraryDependency = false stops the plugin from adding the library.
IntelliJ IDEA shows the errors while you type once it loads the plugin: use K2 mode, open Help | Find Action, run Registry..., set kotlin.k2.only.bundled.compiler.plugins.enabled to false, and restart the IDE.
Compatibility
Kotlin compiler plugins use unstable compiler APIs, so each release works with one Kotlin version. The version name ends with that Kotlin version: 1.0.0-2.4.10 is Solera 1.0.0 for Kotlin 2.4.10. All modules share the version.
| Kotlin | Solera |
|---|---|
| 2.4.10 | 1.0.0-2.4.10 |
The library itself only needs a compatible Kotlin standard library; the version suffix matters for the compiler plugin.
Building
./gradlew build
| Module | Contents |
|---|---|
solera |
Parser, schedules, builder and serializer, for every Kotlin target |
solera-coroutines |
delayUntilNext and ticks |
solera-compiler-plugin |
FIR checkers for constant expressions and ranges |
solera-gradle-plugin |
The xyz.nillpoe.solera Gradle plugin |
The parser and the calendar arithmetic live in solera/src/commonMain/kotlin/xyz/nillpoe/solera/internal and use only the Kotlin standard library, because the compiler plugin compiles the same files to check constants. Keep that package free of other dependencies.
Library tests are in solera/src/jvmTest and solera-coroutines/src/jvmTest. The compiler tests use the Kotlin compiler's own test framework; each file in solera-compiler-plugin/testData becomes a test:
box/: compiles and runsfun box(), which returns"OK"on success.diagnostic/: compares the reported errors with the inline markers and the.fir.diag.txtfile.
After changing a message or adding a test, run ./gradlew :solera-compiler-plugin:test -Poverwrite to rewrite the expected files, then review the diff.
The Gradle plugin tests publish all modules to build/testing-repository and build sample projects with Gradle TestKit.
Publishing
The build publishes to the Forgejo package registry with basic authentication. It reads the credentials from two Gradle properties, forgejoUsername and forgejoPassword. For the password, create an access token in Forgejo under Settings | Applications with the write:package scope.
Keep the credentials out of this repository. Put them in your user-level Gradle properties file: ~/.gradle/gradle.properties on macOS and Linux, %USERPROFILE%\.gradle\gradle.properties on Windows.
forgejoUsername=<your Forgejo user name>
forgejoPassword=<access token>
On a CI server, set the environment variables ORG_GRADLE_PROJECT_forgejoUsername and ORG_GRADLE_PROJECT_forgejoPassword instead.
Then publish:
./gradlew publishAllPublicationsToForgejoRepository
License
Apache License 2.0. See LICENSE.