Apache 2.0 · Java & Kotlin · Spring Boot 2/3/4 · PostgreSQL 15+

Workflow durable execution, as a Java library

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.

OrderWorkflow.java
@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;
    }
}
30,000 / sjobs per second on a c8r16 DB
1-2 msfrom job submission to execution
3 tablesof PostgreSQL - the whole state
∞job execution duration, unlimited

Everything a workflow engine should do - inside your app

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.

Fault-tolerant by default

At-least-once execution, infinite retries, automatic recovery after faults - up to the complete loss of all application instances.

Persistent state & context

Named, typed state slots survive job restarts and are readable from outside. Typed context passes between jobs - with polymorphism support.

Signals & cancellation

Send arbitrary messages to a running job, cancel it externally, interrupt when needed. Signals travel down the hierarchy.

Resource limits

Cap total concurrent jobs or per job type and per resource. Even distribution across application instances, out of the box.

Observability

Micrometer metrics, trace_id/span_id propagation down the hierarchy, Actuator HealthIndicator, built-in HTTP API and job history search.

Tests without a database

A lightweight TestJobSystem runs real jobs in unit tests - no PostgreSQL required, delays skipped, reruns capped.

Zero-downtime upgrades

Documented strategies for evolving jobs while the app keeps running: method versioning, branching on first run time, drain-then-update.

Running in four steps

Add the dependency

JES is on Maven Central. Spring Boot 2.7+, Java 17+, PostgreSQL 15+.

pom.xml
<dependency>
  <groupId>dev.jesproject</groupId>
  <artifactId>job-execution-system</artifactId>
  <version>2.0.0</version>
</dependency>

Create the schema

Three tables via Liquibase: copy db/job_execution_system from the repo into your migrations and include the changelog.

changelog-master.xml
<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>

Enable JES

One annotation on the application's main class.

Application.java
@SpringBootApplication
@EnableJobExecutionSystem
public class ExampleApp {
    public static void main(String[] args) {
        SpringApplication.run(ExampleApp.class, args);
    }
}

Start your first job

Any public method of a Spring bean becomes a durable job - retried until it succeeds.

HelloService.java
@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);
    }
}

How it works

Three tables are the whole engine

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.

Instances pull work from the queue

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.

Crash of an instance is not a crash of the job

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 is a tree of jobs that resumes itself

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.

What it looks like in practice

A few small snippets - each one is a guarantee you get without writing any infrastructure code.

Fan-out to parallel jobs

One child job per order - they run in parallel, retry independently, and results are collected when all are done.

OrderProcessor.java
// 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);
}

Tuned to your workload

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.

application.yml
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
  • Defaults that just work

    Every value above is optional - omit the whole block and JES runs with the same defaults.

  • Scaled per instance

    Threads, prefetch and heartbeats are set per application instance; instances balance themselves.

  • DB-friendly

    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.

Built for the hard distributed-systems chores

Idempotent execution At-least-once code execution Scheduled & delayed jobs MapReduce-style processing Distributed multithreading Transactional Outbox Distributed retry Saga orchestration

Your Postgres is already a workflow engine