Java

Durable Execution You Can Watch, Without a Workflow Engine

A background job charges a customer’s card on step three of four, then throws an exception on step four. The scheduler does what schedulers do and retries the whole job. Step three runs again. The customer pays twice.

Every Java team that runs background jobs has seen some version of this, usually in production, usually involving money or email. In this tutorial, we want to show you how to solve this in plain Java with JobRunr, the open source job scheduling library, using an API called runStepOnce. And it shows off the part that is new in JobRunr 9: the dashboard now draws every attempt and every step of a job on a Gantt chart, so you can watch the retry skip the steps that already ran instead of taking our word for it.

The two fixes everyone already has

The first one you have probably written yourself. Guard flags:

var metadata = jobContext.getMetadata();

if (!metadata.containsKey("invoice-charged")) {

    paymentProvider.charge(customerId, invoiceId);

    jobContext.saveMetadata("invoice-charged", true);

}

Every dangerous step gets its own flag, every flag gets a name nobody dares rename, and when a later step needs a value from an earlier one (the charge id for the receipt email, say) you persist and reload that by hand as well. Copy the pattern across a codebase for two years and you have built a workflow engine with no documentation and one author, who left.

The second fix is a real workflow engine. Temporal and Restate solve this class of problem properly, and when you coordinate long-running processes across many services they earn their keep. They also arrive with a cluster to operate, an SDK with its own programming model, and determinism rules your whole team has to internalize. That is a big bill for one invoice job in one Spring Boot application.

Option three: steps inside an ordinary job

JobRunr has had a middle path. The job stays a plain Java method. You wrap the dangerous parts in runStepOnce on the JobContext:

@Job(name = "Invoice %1 for %0", retries = 5)

public void generateInvoice(String customerId, String period, JobContext jobContext) throws Exception {

    String invoiceId = "INV-" + period + "-" + customerId;

    long usage = jobContext.runStepOnce("calculate-usage", () -> calculateUsage(customerId, period));

    String pdf = jobContext.runStepOnce("generate-pdf", () -> generatePdf(invoiceId, usage));

    String chargeId = jobContext.runStepOnce("charge-card", () -> paymentProvider.charge(customerId, invoiceId));

    jobContext.runStepOnce("send-email", () -> sendInvoiceEmail(customerId, pdf, chargeId));

}

Enqueue it like any other job:

jobScheduler.enqueue(() -> invoiceService.generateInvoice("CUST-0042", "2026-09", JobContext.Null));

When a step completes, its result is stored with the job, in whatever database your StorageProvider already points at. On a retry, a completed step does not run again. Its stored result comes back instead, so chargeId in the email step is always the id of the charge that actually happened, on whichever attempt it happened.

Nothing new to deploy. The state rides along in the Postgres, MySQL, MariaDB, SQL Server, Oracle or MongoDB you already run, or in the in-memory store in your tests.

You need JobRunr 9, which shipped September 30:

<dependency>

    <groupId>org.jobrunr</groupId>

    <artifactId>jobrunr</artifactId>

    <version>9.0.0</version>

</dependency>

or for Gradle:

implementation 'org.jobrunr:jobrunr:9.0.0'

You can find the demo project at github.com/iNicholasBE/durable-jobs-example: a Spring Boot app with the invoice service, a payment provider you can break with a switch, and the JobRunr dashboard on port 8000. Its scenario.sh replays everything you are about to see.

Break it, then watch

Documentation that says “the retry resumes at the failed step” is easy to write. JobRunr 9 lets you check the job progress yourself. The job history in the dashboard now has the Job Progress Chart, which draws the life of a job as a Gantt chart. One row per state, one row per durable step. It is in the open source dashboard and there is nothing to configure.

So we flipped the demo’s switch that makes every charge time out after 2 seconds, enqueued a single invoice for customer CUST-0042, and opened its Chart:

The Chart mode for a durable job in JobRunr 9. The first attempt fails on charge-card, and the retry skips the two steps that had already finished.

Read it left to right. calculate-usage took 218 ms, generate-pdf about a second, and then charge-card sat on the timeout for two full seconds and took the attempt down with it. That is the red bar. JobRunr scheduled retry 1 of 5, and while it waited, we switched the payment provider back on.

The retry row is the payoff. Two hollow diamonds sit where calculate-usage and generate-pdf would run: skipped, because they completed on attempt one. charge-card went through in 697 ms this time, send-email took 290 ms, and the entire retry needed under a second of processing. The customer got one PDF and exactly one charge.

Two things we noticed after staring at this view longer than planned. The Chart works for plain jobs too, splitting each attempt into scheduled, enqueued and processing time, which answers “is this job slow or was it waiting for a worker” in one glance. And that long blue bar in the screenshot is our invoice sitting Enqueued for 10 seconds on a server with nothing else to do. That one is on us: this run used the in-memory store, where job pickup waits for the next poll, and the default poll interval is 15 seconds.

Pass step results forward

Values coming out of steps are what make retries deterministic. Anything a later step depends on should come out of an earlier step, because a stored result is stable across retries and a fresh call is not. If send-email had called paymentProvider.lookupLatestCharge(customerId) instead of using chargeId, a retry could pick up a different charge than the one this invoice made. Branch on stored results.

Since results are persisted with the job, they need to be serializable in JobRunr’s terms. Strings, numbers, UUIDs and the other autoboxable types work as is, and your own types can implement JobContext.StepResult.

The fine print

runStepOnce guarantees that a completed step never runs again, and the whole guarantee hangs on the word completed. A step that is mid-flight when your JVM dies has completed nothing and will run again on the retry, so a side effect inside a step still wants an idempotency key. Our demo hands the payment provider the invoice id as exactly that, which is why even a crash in the middle of charge-card cannot produce two charges. The provider sees the same key twice and deduplicates.

Where a workflow engine still wins

runStepOnce gives you one job with durable steps, and JobRunr chains and batches coordinate jobs into larger flows. Processes that run for weeks, sleep on timers measured in days, pause for a human to click approve, or spread state across a dozen services with signals and queries belong in a workflow engine, and Temporal and Restate are good at exactly that.

The invoice run here is four steps and a few minutes inside one application, and in our experience that shape covers most of what teams actually run. For it, durability as a property of the job library you already have adds zero infrastructure.

Try it

Take the job in your codebase you would least like to see retried. Split its scary parts into runStepOnce steps, break it on purpose in a test environment, and open its History in Chart mode. If the hollow diamonds show up where you expect them, your retries are safe, and you have the picture to prove it in the next incident review.

The full demo, including the breakable payment provider and the replay script, is at github.com/iNicholasBE/durable-jobs-example. The durable executions guide covers the API in more depth, and the maintainers read GitHub Discussions if you hit something odd.

JobRunr 9.0 was released on 30 September 2026, and the Job Progress Chart is only part of it. The v9 release post walks through everything that shipped.

Java Code Geeks

JCGs (Java Code Geeks) is an independent online community focused on creating the ultimate Java to Java developers resource center; targeted at the technical architect, technical team lead (senior developer), project manager and junior developers alike. JCGs serve the Java, SOA, Agile and Telecom communities with daily news written by domain experts, articles, tutorials, reviews, announcements, code snippets and open source projects.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button