jobs.every(Duration.ofMinutes(5), reports::refresh);
jobs.cron("0 0 3 * * *", archive::sweep);
One daemon platform thread keeps time; every job body runs on a virtual thread,
so a slow job delays nothing but its own next run and cannot occupy the timer.
Cron expressions compile once into a bitmask per field — a long for seconds and
minutes, an int for the rest — so matching an instant is a shift and a mask
rather than a parse or a set lookup. Next-fire advances by the largest unit
that cannot match instead of ticking second by second, so a yearly expression
resolves in a few dozen iterations rather than thirty million. Five or six
fields, ranges, steps, lists, named months and weekdays, both Sunday encodings,
and the standard union semantics when both day fields are restricted. A
malformed expression throws when the job is registered, not when it would have
fired.
Overlapping runs are skipped, and deliberately not configurable: two copies of
one job at once is a bug in every case anyone has needed, and a flag would only
let it be set wrongly. A skipped run logs how long the previous one has been
going.
A throwing job is logged and keeps its schedule. scheduleAtFixedRate cancels
the task on first exception, silently — a job that dies at 3am and is never
heard from again is the failure this avoids.
Shutdown goes through FlashContext.onClose, so app.stop() drains HTTP first,
then gives running jobs a grace period before forcing them down. Nothing runs
after the app stops. The grace period is the only knob.
Known ceiling, marked in the source: schedules are per-instance, so two
replicas run every job twice. A distributed lock should not exist until there
is a second replica.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
4.3 KiB
Markdown
126 lines
4.3 KiB
Markdown
# flash-ext-scheduler
|
|
|
|
Background jobs on an interval or a cron schedule. One platform thread keeps time, every job body
|
|
runs on a virtual thread, and the whole thing stops with the app.
|
|
|
|
## What it provides
|
|
|
|
| Component | Description |
|
|
|---|---|
|
|
| `Scheduler` | `every(interval, work)` and `cron(expression, work)` |
|
|
| `Cron` | A cron expression compiled to bitmasks; usable on its own |
|
|
|
|
## Dependency
|
|
|
|
```xml
|
|
<dependency>
|
|
<groupId>dev.relism</groupId>
|
|
<artifactId>flash-ext-scheduler</artifactId>
|
|
<version>${flash.version}</version>
|
|
</dependency>
|
|
```
|
|
|
|
## Quick start
|
|
|
|
```java
|
|
FlashApp.create(8080)
|
|
.install(new SchedulerExtension())
|
|
.apply(new BlogApp())
|
|
.start();
|
|
```
|
|
|
|
```java
|
|
// inside BlogApp.configure(app)
|
|
app.ctx().onReady(() -> {
|
|
Scheduler jobs = app.ctx().require(Scheduler.class);
|
|
|
|
jobs.every(Duration.ofMinutes(5), reports::refresh);
|
|
jobs.cron("0 0 3 * * *", archive::sweep);
|
|
});
|
|
```
|
|
|
|
`onReady` is the right place: the service graph is resolved by then, so a job can close over the
|
|
services it needs.
|
|
|
|
Give a job an explicit name when the logs should be readable — a method reference names itself, a
|
|
lambda cannot:
|
|
|
|
```java
|
|
jobs.every("refresh-reports", Duration.ofMinutes(5), () -> reports.refresh());
|
|
jobs.cron("nightly-archive", "0 0 3 * * *", () -> archive.sweep());
|
|
```
|
|
|
|
`jobNames()` returns them in registration order, for an ops or health endpoint.
|
|
|
|
## Cron expressions
|
|
|
|
Five fields (`min hour dom mon dow`) or six with a leading seconds field.
|
|
|
|
| Expression | Fires |
|
|
|---|---|
|
|
| `0 0 3 * * *` | 03:00:00 daily |
|
|
| `*/15 * * * *` | every 15 minutes |
|
|
| `0 9 * * MON-FRI` | 09:00 on weekdays |
|
|
| `0 0 1 MAR *` | midnight on 1 March |
|
|
| `0,30 * * * *` | on the hour and the half hour |
|
|
|
|
Supports `*`, `?`, single values, `a-b` ranges, `a/n` steps, comma lists, and three-letter month
|
|
and day names. Both `0` and `7` mean Sunday.
|
|
|
|
Standard cron semantics for the two day fields: when **both** are restricted, a match is their
|
|
**union** — `0 0 1 * MON` means "the 1st, or any Monday", not "a Monday that is the 1st".
|
|
|
|
A malformed expression throws when you register the job, not the first time it would have fired.
|
|
|
|
## Overlapping runs are skipped
|
|
|
|
Not configurable. If a run is still going when the next is due, the next is skipped and a WARN
|
|
records how long the previous one has been running.
|
|
|
|
Two copies of the same job running at once is a bug in every case anyone has needed so far, and a
|
|
flag would only let it be configured wrongly. If you genuinely want concurrent runs, register the
|
|
job twice under different names.
|
|
|
|
## Failure
|
|
|
|
A throwing job is logged at ERROR and keeps its schedule. A raw `scheduleAtFixedRate` cancels the
|
|
task on the first exception, silently — a job that dies at 3am and is never heard from again is
|
|
the failure mode this avoids.
|
|
|
|
## Shutdown
|
|
|
|
The scheduler is registered with `FlashContext.onClose`, so `app.stop()` drains in-flight HTTP
|
|
requests first, then gives running jobs their grace period (10s by default) before forcing them
|
|
down. Nothing runs after the app has stopped.
|
|
|
|
```java
|
|
new SchedulerExtension(Duration.ofSeconds(30)) // longer grace for slow jobs
|
|
```
|
|
|
|
That is the only knob.
|
|
|
|
## Threading
|
|
|
|
`Scheduler` holds one daemon platform thread for timing and dispatches every job body to a virtual
|
|
thread. A slow job delays nothing but its own next run; it cannot occupy the timer or starve other
|
|
jobs.
|
|
|
|
## Performance
|
|
|
|
A cron expression is parsed **once**, into a bitmask per field — a `long` for seconds and minutes,
|
|
an `int` for the rest. Matching a candidate instant is a shift and a mask, not a parse or a set
|
|
lookup. Computing the next fire time advances by the largest unit that cannot match rather than
|
|
ticking second by second, so a yearly expression resolves in a few dozen iterations rather than
|
|
thirty million.
|
|
|
|
To be precise about where that matters: this runs once per fire, not once per request. The
|
|
allocation discipline elsewhere in Flash is about the request hot path, and a scheduler that
|
|
computes a `ZonedDateTime` a few times an hour is not on it. The bitmasks are here because parsing
|
|
a string on every candidate instant would be genuinely wasteful, not to shave an allocation.
|
|
|
|
## Known ceiling
|
|
|
|
**Schedules are per-instance.** Two replicas run every job twice. The fix is a distributed lock,
|
|
and it should not exist until there is a second replica — it is marked with a `ponytail:` comment
|
|
in `SchedulerExtension`.
|