feat(ext-scheduler): add interval and cron background jobs #16

Merged
Relism merged 2 commits from feature/ext-scheduler/cron-and-interval-jobs into master 2026-09-09 14:36:36 +00:00
Owner

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

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>
Relism added 1 commit 2026-09-09 14:21:29 +00:00
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>
Relism added 1 commit 2026-09-09 14:35:47 +00:00
Resolve conflicts in AGENTS.md, pom.xml and flash-extensions/pom.xml — master
had already picked up ext-validation and ext-cache-core/caffeine (merged after
this branch was cut); union kept alongside this branch's ext-scheduler entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Relism merged commit 9892ad44b7 into master 2026-09-09 14:36:36 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Relism/Flash5#16