Job hierarchy
Unlimited nesting of parent and child jobs, sequential or parallel. Children are idempotent by construction: same method + same args → executed once, result cached.
Apache 2.0 · Java & Kotlin · Spring Boot 2/3/4 · PostgreSQL 15+
JES runs your jobs and workflows reliably: they survive crashes, restarts and moving between machines - with their state intact. No server to operate. If you already have Spring Boot and PostgreSQL, you already have everything.
One dependency. Three tables. Zero infrastructure.
@Service
public class OrderWorkflow {
// Start the order processing workflow
public void start(String orderId) {
JobOptions options =
JobOptions.jobWithExternalId(orderId);
jobSystem.startJob(options,
this::processOrder, String.class, orderId);
}
// Survives crashes and retries automatically
public String processOrder(String orderId) {
String receipt = jobSystem.execute(
paymentService::chargePayment,
String.class, orderId);
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId);
} catch (JobFailure e) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt);
throw e;
}
return receipt;
}
}
@Service
class OrderWorkflow(
private val jobSystem: JobSystem,
private val paymentService: PaymentService,
private val deliveryService: DeliveryService,
) {
// Start the order processing workflow
fun start(orderId: String) {
val options =
JobOptions.jobWithExternalId(orderId)
jobSystem.startJob(options,
this::processOrder, String::class.java, orderId)
}
// Survives crashes and retries automatically
fun processOrder(orderId: String): String {
val receipt = jobSystem.execute(
paymentService::chargePayment,
String::class.java, orderId)
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId)
} catch (e: JobFailure) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt)
throw e
}
return receipt
}
}
Unlimited nesting of parent and child jobs, sequential or parallel. Children are idempotent by construction: same method + same args → executed once, result cached.
At-least-once execution, infinite retries, automatic recovery after faults - up to the complete loss of all application instances.
Named, typed state slots survive job restarts and are readable from outside. Typed context passes between jobs - with polymorphism support.
Send arbitrary messages to a running job, cancel it externally, interrupt when needed. Signals travel down the hierarchy.
Cap total concurrent jobs or per job type and per resource. Even distribution across application instances, out of the box.
Micrometer metrics, trace_id/span_id propagation down the hierarchy, Actuator HealthIndicator, built-in HTTP API and job history search.
A lightweight TestJobSystem runs real jobs in unit tests - no PostgreSQL required, delays skipped, reruns capped.
Documented strategies for evolving jobs while the app keeps running: method versioning, branching on first run time, drain-then-update.
JES is on Maven Central. Spring Boot 2.7+, Java 17+, PostgreSQL 15+.
<dependency>
<groupId>dev.jesproject</groupId>
<artifactId>job-execution-system</artifactId>
<version>2.0.0</version>
</dependency>
implementation("dev.jesproject:job-execution-system:2.0.0")
Three tables via Liquibase: copy db/job_execution_system from the repo into your migrations and include the changelog.
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
<!-- JES tables ship with the library:
copy db/job_execution_system into your migrations -->
<include file="job_execution_system/init/v2.0.xml"
relativeToChangelogFile="true"/>
</databaseChangeLog>
databaseChangeLog:
# JES tables ship with the library:
# copy db/job_execution_system into your migrations
include:
file: job_execution_system/init/v2.0.xml
relativeToChangelogFile: true
One annotation on the application's main class.
@SpringBootApplication
@EnableJobExecutionSystem
public class ExampleApp {
public static void main(String[] args) {
SpringApplication.run(ExampleApp.class, args);
}
}
@SpringBootApplication
@EnableJobExecutionSystem
class ExampleApp
fun main(args: Array<String>) {
SpringApplication.run(ExampleApp::class.java, *args)
}
Any public method of a Spring bean becomes a durable job - retried until it succeeds.
@Service
public class HelloService {
private final JobSystem jobSystem;
public void runHelloJobs() {
jobSystem.startJob(this::hello, "World");
}
public void hello(String name) {
// executes at least once, survives failures
log.info("Hello, {}!", name);
}
}
@Service
class HelloService(
private val jobSystem: JobSystem,
) {
fun runHelloJobs() {
jobSystem.startJob(this::hello, "World")
}
fun hello(name: String) {
// executes at least once, survives failures
log.info("Hello, {}!", name)
}
}
A job is just a row in a PostgreSQL table: its method, arguments, parent, status and schedule. No hidden state, no mystery - SELECT the jobs, job_signals and job_data tables and you see everything.
Application instances poll the tables and take the next job in a short transaction with FOR UPDATE SKIP LOCKED - so many instances scale horizontally without stepping on each other, and work flows to free instances by itself.
Every runner sends a heartbeat while it works. If an instance dies, another one takes its jobs and runs them again - at-least-once, with the persistent state intact. The same applies to the whole cluster going down.
A workflow job spawns child jobs - steps of the business process, sequential or parallel. Child jobs are deterministic by construction: same method + same args → the result is already in the database. So when the parent job restarts anywhere, finished steps are not re-executed - the workflow simply continues where it left off.
A few small snippets - each one is a guarantee you get without writing any infrastructure code.
One child job per order - they run in parallel, retry independently, and results are collected when all are done.
// One child job per order: parallel, independent retries
public List<Report> processAll(List<Order> orders) {
List<Job> jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders));
// wait for all children and collect results
return Job.getAll(jobs, Report.class).toList();
}
public Report processOrder(Order order) {
// same args → runs once, result cached on reruns
return warehouse.reserve(order);
}
// One child job per order: parallel, independent retries
fun processAll(orders: List<Order>): List<Report> {
val jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders))
// wait for all children and collect results
return Job.getAll(jobs, Report::class.java).toList()
}
fun processOrder(order: Order): Report {
// same args → runs once, result cached on reruns
return warehouse.reserve(order)
}
A duplicated Kafka delivery won't start a second job: the event id is the idempotency key, and an already existing job is simply reused.
// Kafka listener: a duplicated delivery must not
// start a second job - event id is the idempotency key
@KafkaListener(topics = "orders")
public void onOrderCreated(
String eventId, String orderId, String userId) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId);
} catch (JobAlreadyExistsException e) {
// duplicated delivery: job already exists
log.info("Event {} already has a job", eventId);
}
}
public void processOrder(String orderId, String userId) {
// retried until success - no lost events
}
// Kafka listener: a duplicated delivery must not
// start a second job - event id is the idempotency key
@KafkaListener(topics = "orders")
fun onOrderCreated(
eventId: String, orderId: String, userId: String
) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId)
} catch (e: JobAlreadyExistsException) {
// duplicated delivery: job already exists
log.info("Event {} already has a job", eventId)
}
}
fun processOrder(orderId: String, userId: String) {
// retried until success - no lost events
}
Send arbitrary messages to a job while it runs - the job itself decides when and how to handle them.
// The job receives commands while it is running
public void exportReport(ExportRequest request) {
Signal<ExportCommand> command = Signal.of();
for (String page : request.pages()) {
ExportCommand cmd = command.waitAndPop();
render(page, cmd);
}
}
// From anywhere - e.g. a REST controller:
jobSystem.signal(job, new ExportCommand("change-scope"));
// The job receives commands while it is running
fun exportReport(request: ExportRequest) {
val command: Signal<ExportCommand> = Signal.of()
for (page in request.pages()) {
val cmd = command.waitAndPop()
render(page, cmd)
}
}
// From anywhere - e.g. a REST controller:
jobSystem.signal(job, ExportCommand("change-scope"))
A schedule is itself a durable job: after an application crash the missed run is recovered and continues.
@Service
public class ReportScheduler {
private final RecurringJobScheduler recurring;
public void register() {
// nightly at 02:00 - itself a durable job
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport);
}
public void generateReport() {
// recovered automatically after crashes
}
}
@Service
class ReportScheduler(
private val recurring: RecurringJobScheduler,
) {
fun register() {
// nightly at 02:00 - itself a durable job
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport)
}
fun generateReport() {
// recovered automatically after crashes
}
}
Chunks are fetched from the database starting from the saved index; after a restart processing continues from where it stopped - finished chunks are not re-fetched.
// Chunks from the DB: the saved index lets us resume
// after a restart without reprocessing finished chunks
public void processBacklog() {
// returns the saved index or 0 on the first run
Integer index = Job.computeState("index",
Integer.class, current ->
current == null ? 0 : current);
// fetch chunks starting from the saved index
List<Chunk> chunks =
chunkRepository.findFrom(index);
for (int i = 0; i < chunks.size(); i++) {
Job.throwIfInterruptedOrCancelled();
chunkService.handle(chunks.get(i));
Job.setState("index", index + i + 1);
}
}// Chunks from the DB: the saved index lets us resume
// after a restart without reprocessing finished chunks
fun processBacklog() {
// returns the saved index or 0 on the first run
val index = Job.computeState("index",
Integer::class.java) { it ?: 0 }
// fetch chunks starting from the saved index
val chunks =
chunkRepository.findFrom(index)
chunks.forEachIndexed { i, chunk ->
Job.throwIfInterruptedOrCancelled()
chunkService.handle(chunk)
Job.setState("index", index + i + 1)
}
}
Each step registers its compensation. If a later step fails, the completed ones are rolled back in reverse order - and every job is retried until it succeeds.
// Saga: each step registers its compensation.
// On failure, completed steps roll back in reverse order.
public String placeOrder(String orderId) {
Deque<Runnable> compensate = new ArrayDeque<>();
try {
String paymentId = jobSystem.execute(
paymentService::charge, String.class, orderId);
compensate.push(() -> jobSystem.execute(
paymentService::refund, orderId, paymentId));
String shipmentId = jobSystem.execute(
deliveryService::ship, String.class, orderId);
compensate.push(() -> jobSystem.execute(
deliveryService::cancelShipment, shipmentId));
jobSystem.execute(loyaltyService::addPoints, orderId);
return paymentId;
} catch (JobFailure e) {
compensate.forEach(Runnable::run); // reverse order
throw e;
}
}
// Saga: each step registers its compensation.
// On failure, completed steps roll back in reverse order.
fun placeOrder(orderId: String): String {
val compensate = ArrayDeque<() -> Unit>()
try {
val paymentId = jobSystem.execute(
paymentService::charge, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
paymentService::refund, orderId, paymentId)
}
val shipmentId = jobSystem.execute(
deliveryService::ship, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
deliveryService::cancelShipment, shipmentId)
}
jobSystem.execute(loyaltyService::addPoints, orderId)
return paymentId
} catch (e: JobFailure) {
compensate.forEach { it() } // reverse order
throw e
}
}
Cancel a job from outside at any moment: the signal cascades to child jobs, and the job stops itself between steps.
// The job checks for cancellation between steps
public void exportReport(ExportRequest request) {
for (String page : request.pages()) {
Job.throwIfInterruptedOrCancelled();
render(page);
}
}
// From anywhere: cancellation cascades to child jobs
jobSystem.cancel(job);
// The job checks for cancellation between steps
fun exportReport(request: ExportRequest) {
for (page in request.pages()) {
Job.throwIfInterruptedOrCancelled()
render(page)
}
}
// From anywhere: cancellation cascades to child jobs
jobSystem.cancel(job)
JES works out of the box with sensible defaults - nothing to configure. Still, every knob is exposed: thread pools, retry delays, heartbeats, auto-cleanup and the built-in management API.
job-execution-system:
max-runner-count: 100 # job threads per instance
jobs:
new-check-interval: 200ms # how fast jobs start
heartbeat-rate: 850ms # dead instance detection
heartbeat-timeout: 5000ms # failover threshold
delay-on-error: 0ms # pause before retry
signals:
new-check-interval: 1000ms # signal delivery
cleanup:
enabled: false # auto-cleanup of old jobs
expiration-period: P1Y8M5D
cron: "0 0 */20 * * *" # cleanup schedule
api:
enabled: false # HTTP management API
Every value above is optional - omit the whole block and JES runs with the same defaults.
Threads, prefetch and heartbeats are set per application instance; instances balance themselves.
Polling rates and Hikari pool size are tunable - the number of connections is strictly limited.
All settings are optional - the values shown are the defaults.