Skip to content

Commit 6f47fd1

Browse files
committed
Update README, add max_execution and expires_at support for jobs
1 parent dea16d4 commit 6f47fd1

File tree

3 files changed

+123
-18
lines changed

3 files changed

+123
-18
lines changed

README.md

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,82 @@
1-
Example Julia package repo.
1+
# Tempus.jl
22

3-
[![](https://img.shields.io/badge/docs-stable-blue.svg)](https://JuliaLang.github.io/Example.jl/stable)
4-
[![](https://img.shields.io/badge/docs-dev-blue.svg)](https://JuliaLang.github.io/Example.jl/dev)
3+
[![Build Status](https://github.com/JuliaServices/Tempus.jl/actions/workflows/ci.yml/badge.svg)](https://github.com/JuliaServices/Tempus.jl/actions)
4+
[![Coverage](https://codecov.io/gh/JuliaServices/Tempus.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/JuliaServices/Tempus.jl)
5+
[![Documentation](https://img.shields.io/badge/docs-stable-blue.svg)](https://JuliaServices.github.io/Tempus.jl/stable/)
56

6-
GitHub Actions : [![Build Status](https://github.com/JuliaLang/Example.jl/workflows/CI/badge.svg)](https://github.com/JuliaLang/Example.jl/actions?query=workflow%3ACI+branch%3Amaster)
7+
## Overview
8+
**Tempus.jl** is a lightweight, Quartz-inspired job scheduling library for Julia. It provides an easy-to-use API for defining cron-like schedules and executing jobs at specified times.
79

8-
[![codecov.io](http://codecov.io/github/JuliaLang/Example.jl/coverage.svg?branch=master)](http://codecov.io/github/JuliaLang/Example.jl?branch=master)
10+
### Features
11+
- Define jobs using cron-style scheduling expressions
12+
- Support for job execution policies (overlap handling, retries, failure strategies)
13+
- In-memory and file-based job storage backends
14+
- Thread-safe scheduling with concurrency-aware execution
15+
- Dynamic job control (enable, disable, unschedule jobs)
16+
- Supports retry policies with exponential backoff
17+
18+
## Installation
19+
Tempus.jl is not yet registered in the Julia General registry. You can install it directly from GitHub:
20+
21+
```julia
22+
using Pkg
23+
Pkg.add(url="https://github.com/JuliaServices/Tempus.jl")
24+
```
25+
26+
## Quick Start
27+
28+
### Defining and Scheduling a Job
29+
```julia
30+
using Tempus
31+
32+
# Define a job that prints a message every minute
33+
job = Tempus.Job("example_job", "* * * * *", () -> println("Hello from Tempus!"))
34+
35+
# Create an in-memory scheduler
36+
scheduler = Tempus.Scheduler()
37+
38+
# Add the job to the scheduler
39+
push!(scheduler, job)
40+
41+
# Start the scheduler (runs in a background thread)
42+
Tempus.run!(scheduler)
43+
```
44+
45+
### Disabling and Enabling Jobs
46+
```julia
47+
Tempus.disable!(job) # Prevents the job from running
48+
Tempus.enable!(job) # Allows it to run again
49+
```
50+
51+
### Removing a Job
52+
```julia
53+
Tempus.unschedule!(scheduler, job)
54+
```
55+
56+
### Using a File-Based Job Store
57+
To persist job execution history across restarts, use a file-based store:
58+
```julia
59+
store = Tempus.FileStore("jobs.dat")
60+
scheduler = Tempus.Scheduler(store)
61+
```
62+
63+
## Cron Syntax
64+
Tempus.jl uses a familiar cron syntax for scheduling:
65+
```
66+
* * * * * → Every minute
67+
0 12 * * * → Every day at noon
68+
*/5 * * * * → Every 5 minutes
69+
# also supports second-level precision
70+
* * * * * * → Every second
71+
```
72+
73+
## Contributing
74+
Contributions are welcome! To contribute:
75+
1. Fork the repository.
76+
2. Create a new branch with your feature or bugfix.
77+
3. Submit a pull request with your changes.
78+
79+
Make sure to include tests for any new functionality.
80+
81+
## License
82+
Tempus.jl is licensed under the MIT License.

src/Tempus.jl

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ Defines options for job execution behavior.
3131
- `retry_delays::Union{Base.ExponentialBackOff, Nothing}`: Delay strategy for retries (defaults to exponential backoff if `retries > 0`).
3232
- `retry_check`: Custom function to determine retry behavior (`check` argument from `Base.retry`).
3333
- `on_fail_policy::Union{Tuple{Symbol, Int}, Nothing}`: Determines job failure handling (`:ignore`, `:disable`, `:unschedule`), with a threshold for consecutive failures.
34+
- `max_executions::Union{Int, Nothing}`: Maximum number of executions allowed for a job.
35+
- `expires_at::Union{DateTime, Nothing}`: Expiration time for a job.
3436
"""
3537
@kwdef struct JobOptions
3638
overlap_policy::Union{Symbol, Nothing} = nothing # :skip, :queue, :concurrent
3739
retries::Int = 0
3840
retry_delays::Union{Base.ExponentialBackOff, Nothing} = retries > 0 ? Base.ExponentialBackOff(; n=retries) : nothing # see Base.ExponentialBackOff
3941
retry_check = nothing # see Base.retry `check` keyword argument
4042
on_fail_policy::Union{Tuple{Symbol, Int}, Nothing} = nothing # :ignore, :disable, :unschedule + max consecutive failures for disable/unschedule
43+
max_executions::Union{Int, Nothing} = nothing # max number of executions job is allowed to run
44+
expires_at::Union{DateTime, Nothing} = nothing # expiration time for job
4145
end
4246

4347
"""
@@ -53,16 +57,15 @@ Represents a scheduled job in the Tempus scheduler.
5357
- `disabledAt::Union{DateTime, Nothing}`: Timestamp when the job was disabled (if applicable).
5458
"""
5559
mutable struct Job
60+
const action::Function
5661
const name::String
5762
const schedule::Cron
58-
const action::Function
5963
const options::JobOptions
6064
# fields managed by scheduler
6165
disabledAt::Union{DateTime, Nothing}
6266
end
6367

64-
Job(name, schedule, action::Function; kw...) = Job(string(name), schedule isa Cron ? schedule : parseCron(schedule), action, JobOptions(kw...), nothing)
65-
Job(action::Function, name, schedule; kw...) = Job(name, schedule, action; kw...)
68+
Job(action::Function, name, schedule; kw...) = Job(action, string(name), schedule isa Cron ? schedule : parseCron(schedule), JobOptions(kw...), nothing)
6669

6770
"""
6871
disable!(job::Job)
@@ -325,6 +328,25 @@ function checkDisable!(scheduler::Scheduler, store::Store, job::Job, on_fail_pol
325328
return
326329
end
327330

331+
function checkExpirationAndMaxExecutions!(scheduler::Scheduler, job::Job)
332+
max_executions = _some(job.options.max_executions, scheduler.jobOptions.max_executions)
333+
expires_at = _some(job.options.expires_at, scheduler.jobOptions.expires_at)
334+
if max_executions !== nothing
335+
execs = getNMostRecentJobExecutions(scheduler.store, job.name, max_executions)
336+
if length(execs) >= max_executions
337+
unschedule!(scheduler, job)
338+
@info "Unscheduling job $(job.name) after reaching maximum executions of $(max_executions)."
339+
return true
340+
end
341+
end
342+
if expires_at !== nothing && expires_at < Dates.now(UTC)
343+
unschedule!(scheduler, job)
344+
@info "Unscheduling job $(job.name) after expiration at $(expires_at)."
345+
return true
346+
end
347+
return false
348+
end
349+
328350
"""
329351
run!(scheduler::Scheduler)
330352
@@ -371,8 +393,10 @@ function run!(scheduler::Scheduler)
371393
@warn "Job $(je.job.name) already executing, keeping scheduled execution queued until current execution finishes. There are $nexecs queued for this job."
372394
# check if we need to schedule the next execution
373395
next = getnext(je.job.schedule)
374-
if any(j -> j.job.name == je.job.name && j.scheduledStart == next, scheduler.jobExecutions)
375-
# we've already scheduled this execution, skip
396+
# check job expiration and max executions
397+
unscheduled = checkExpirationAndMaxExecutions!(scheduler, je.job)
398+
if unscheduled || any(j -> j.job.name == je.job.name && j.scheduledStart == next, scheduler.jobExecutions)
399+
# job is unscheduled or we've already scheduled this execution, skip
376400
else
377401
push!(scheduler.jobExecutions, JobExecution(je.job, next))
378402
end
@@ -390,6 +414,8 @@ function run!(scheduler::Scheduler)
390414
for (i, toSkip, je) in readyToExecute
391415
deleteat!(scheduler.jobExecutions, i)
392416
next = getnext(je.job.schedule)
417+
# check job expiration and max executions
418+
checkExpirationAndMaxExecutions!(scheduler, je.job) && continue
393419
newje = JobExecution(je.job, next)
394420
push!(scheduler.jobExecutions, newje)
395421
if je.job.disabledAt !== nothing
@@ -466,18 +492,23 @@ function executeJob!(scheduler::Scheduler, jobExecution::JobExecution)
466492
return
467493
end
468494

469-
currentlyExecutionJobs(sch::Scheduler) = [je.jobExecutionId for je in sch.executingJobExecutions]
470-
471495
"""
472496
close(scheduler::Scheduler)
473497
474498
Closes the scheduler, stopping job execution; waits for any currently executing jobs to finish.
499+
Will wait `timeout` seconds (5 by default) for any currently executing jobs to finish before returning.
475500
"""
476-
function Base.close(scheduler::Scheduler)
477-
@info "Closing scheduler and stopping job execution."
501+
function Base.close(scheduler::Scheduler; timeout::Real=5)
502+
@info "Closing scheduler and waiting $(timeout)s for job executions to stop."
478503
@lock scheduler.lock begin
479504
scheduler.running = false
480505
end
506+
# we use a Timer here to notify jobExecutionFinished ourself if the scheduler
507+
# or last executing job doesn't do it themselves in time
508+
Timer(timeout) do t
509+
@warn "Scheduler closing timeout reached, returning without waiting for job executions to finish."
510+
notify(scheduler.jobExecutionFinished)
511+
end
481512
wait(scheduler.jobExecutionFinished)
482513
@info "Scheduler closed and job execution stopped."
483514
return

test/runtests.jl

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ executed = DateTime[]
232232

233233
@testset "Scheduler Scheduling and Execution" begin
234234
# We'll create a simple job that records its execution time.
235-
test_job = Tempus.Job(:testjob, "* * * * * *") do
235+
test_job = Tempus.Job("testjob", "* * * * * *") do
236236
global executed
237237
push!(executed, Dates.now(UTC))
238238
end
@@ -262,7 +262,7 @@ executed = DateTime[]
262262
# overlap policy
263263
# skip
264264
# job that takes 2 seconds to run, but runs every second
265-
sleep_job = Tempus.Job(:sleepjob, "* * * * * *") do
265+
sleep_job = Tempus.Job("sleepjob", "* * * * * *") do
266266
sleep(2)
267267
push!(executed, Dates.now(UTC))
268268
end
@@ -288,7 +288,7 @@ executed = DateTime[]
288288
@test length(executed) == 2
289289
# retry settings
290290
# retry n times
291-
fail_job = Tempus.Job(:failjob, "* * * * * *") do
291+
fail_job = Tempus.Job("failjob", "* * * * * *") do
292292
println("length(executed): ", length(executed))
293293
if length(executed) < 2
294294
push!(executed, Dates.now(UTC))
@@ -322,7 +322,7 @@ executed = DateTime[]
322322
@test toggle[] == false
323323
# on_fail_policy
324324
# ignore
325-
always_fail_job = Tempus.Job(:failjob, "* * * * * *") do
325+
always_fail_job = Tempus.Job("failjob", "* * * * * *") do
326326
push!(executed, Dates.now(UTC))
327327
error("always fail job")
328328
end

0 commit comments

Comments
 (0)