Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,54 @@ defaults:
tags: []

guides:
- name: 'grails-jobrunr'
title: 'Background Jobs with JobRunr in Grails 8'
subtitle: 'Run durable background work safely with JobRunr OSS, Grails transactions, and explicit storage wiring.'
authors:
- 'James Fredley'
category: 'Grails Async'
publicationDate: '2026-08-11'
versions:
'8':
sourcePath: guides/grails-jobrunr/v8
publicationDate: '2026-08-11'
tags:
- 'grails8'
- 'jobrunr'
- 'background-jobs'
- 'scheduling'
- 'async'
- 'transactions'
- 'spring-boot'
- 'gorm'
- 'testing'
sampleRef:
repo: 'grails-guides/grails-jobrunr'
branch: 'grails8'
toc:
gettingStarted:
title: Getting Started
requirements: What You Will Build and Need
howto: How to Complete the Guide
dependencySetup:
title: Add JobRunr and Jackson
storageConfiguration:
title: Configure Dedicated Job Storage
jobRequests:
title: Use Job Requests From Groovy
durableEnqueue:
title: Enqueue After Commit
jobApis:
title: Schedule and Track Jobs
runningTheApp:
title: Run and Inspect Jobs
testing:
title: Test the Integration
operations:
title: Production Operations
helpWithGrails:
title: Do You Need Help With Grails?

- name: 'adding-commit-info'
title: 'Adding Commit Info to your Grails Application'
subtitle: 'Knowing the exact version of code that your application is running is important'
Expand Down
33 changes: 33 additions & 0 deletions guides/grails-jobrunr/v8/guide/dependencySetup.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
Grails 8 runs on Spring Boot 4, so use JobRunr's Spring Boot 4 starter. The https://www.jobrunr.io/en/guides/jvm-frameworks/grails/[JobRunr Grails guide] documents this starter family, and the https://search.maven.org/artifact/org.jobrunr/jobrunr-spring-boot-4-starter/8.8.1/jar[Maven Central artifact] identifies the selected GA release.

The initial build has no JobRunr runtime dependency:

[source,groovy]
.initial/build.gradle
----
include::../snippets/initial/build.gradle[]
----

The complete build adds the Boot 4 starter and explicit `jackson-databind`:

[source,groovy]
.complete/build.gradle
----
include::../snippets/complete/build.gradle[]
----

The starter supplies Jackson version constraints but not an application Jackson Databind runtime dependency. Add `com.fasterxml.jackson.core:jackson-databind` explicitly so JobRunr can create its job mapper. Keep the version managed by the Grails dependency platform rather than pinning a competing version.

The version delta is visible in the two Gradle property files:

[source,properties]
.initial/gradle.properties
----
include::../snippets/initial/gradle.properties[]
----

[source,properties]
.complete/gradle.properties
----
include::../snippets/complete/gradle.properties[]
----
39 changes: 39 additions & 0 deletions guides/grails-jobrunr/v8/guide/durableEnqueue.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
The job must not run for a delivery that rolls back. The delivery model records the business state and progress that the job updates:

[source,groovy]
.grails-app/domain/example/grails/Delivery.groovy
----
include::../snippets/complete/grails-app/domain/example/grails/Delivery.groovy[]
----

The service first persists the delivery and publishes a small event within its GORM transaction:

[source,groovy]
.grails-app/services/example/grails/DeliveryService.groovy
----
include::../snippets/complete/grails-app/services/example/grails/DeliveryService.groovy[]
----

[source,groovy]
.src/main/groovy/example/grails/DeliveryCreatedEvent.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/DeliveryCreatedEvent.groovy[]
----

The listener uses Spring's https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html[transaction-bound event support] to run only after that transaction commits, then enqueues the request:

[source,groovy]
.src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy[]
----

Grails does not register Spring's `TransactionalEventListenerFactory` through `@EnableTransactionManagement`, so declare it explicitly or the transactional listener is silently ignored:

[source,groovy]
.grails-app/conf/spring/resources.groovy
----
include::../snippets/complete/grails-app/conf/spring/resources.groovy[]
----

This is a best-effort OSS pattern, not an atomic cross-database transaction. A process can fail after the GORM commit but before enqueueing. For business-critical delivery, write an application-managed transactional outbox in the application transaction and dispatch it reliably, or evaluate JobRunr Pro's https://www.jobrunr.io/en/documentation/pro/transactions/[transaction plugin]. The transaction plugin is a Pro feature and is intentionally not used by this sample.
13 changes: 13 additions & 0 deletions guides/grails-jobrunr/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
JobRunr is a durable background-job system: it stores job details, lets workers execute them after a request returns, retries failures, and provides an operational dashboard. This guide builds a small delivery API in which creating a delivery commits a GORM row, then schedules a JobRunr job that marks that delivery complete.

The sample is verified with Grails `8.0.0-M5`, Gradle `9.6.1`, Java 21, and JobRunr OSS `8.8.1`. Grails `8.0.0-M5` is a milestone release, while JobRunr `8.8.1` is a GA release. Check the current https://grails.apache.org[Apache Grails] and https://github.com/jobrunr/jobrunr/releases/tag/v8.8.1[JobRunr 8.8.1 release] before choosing versions for a new production application.

The design deliberately uses JobRunr OSS only. It stores JobRunr tables in a dedicated data source, sends a small delivery ID rather than an entity graph, waits for the delivery transaction to commit before enqueueing, and makes the handler safe to run again.

== What you will build

* A Grails JSON API that creates a `Delivery` and returns `201 Created`.
* A durable `JobRequest` queued after the delivery transaction commits.
* A statically compiled handler that reports progress and marks the delivery successful.
* Endpoints that demonstrate immediate, delayed, recurring, and retrying jobs.
* An H2-backed development dashboard and tests that prove routing, storage separation, and live execution.
1 change: 1 addition & 0 deletions guides/grails-jobrunr/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
10 changes: 10 additions & 0 deletions guides/grails-jobrunr/v8/guide/howto.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
You can work through the code in the guide or clone the finished application:

[source,bash]
----
git clone -b grails8 https://github.com/grails-guides/grails-jobrunr.git
cd grails-jobrunr/complete
./gradlew test
----

`initial/` is a plain Grails 8 web application. `complete/` adds the JobRunr starter, Jackson, the dedicated data source, an after-commit event listener, `JobRequest` handlers, HTTP endpoints, and the tests discussed below.
49 changes: 49 additions & 0 deletions guides/grails-jobrunr/v8/guide/jobApis.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
`JobRequestScheduler` supports immediate enqueueing, delayed work, recurring work, and retries in OSS. The sample keeps each operation on the explicit request type:

[source,groovy]
.grails-app/services/example/grails/JobExamplesService.groovy
----
include::../snippets/complete/grails-app/services/example/grails/JobExamplesService.groovy[]
----

`enqueue` returns a one-off job ID. `schedule` stores a job for an `Instant` in the future. `scheduleRecurrently` uses a stable recurring ID and a cron expression, so a repeated registration updates the same recurring job rather than creating an unbounded set. The handler's `@Job(retries = 3)` controls its retry count; the application-level default in `application.yml` supplies the fallback.

Progress belongs inside the handler, where the `ThreadLocalJobContext` is available. `DeliveryJobRequestHandler` creates a three-step progress bar and increments it as it writes the visible progress value. Progress is operational feedback, not a substitute for an idempotent business state transition.

The retry endpoint deliberately schedules a handler that fails. This makes retry state visible in the dashboard without disguising a failure as successful work:

[source,groovy]
.src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy[]
----

[source,groovy]
.src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy[]
----

`DeliveryController` exposes the delivery creation and lookup API, returning `201 Created`, validation errors, or `404 Not Found` as appropriate:

[source,groovy]
.grails-app/controllers/example/grails/DeliveryController.groovy
----
include::../snippets/complete/grails-app/controllers/example/grails/DeliveryController.groovy[]
----

`JobExamplesController` and the URL mappings separately expose the asynchronous examples as `202 Accepted` operations:

[source,groovy]
.grails-app/controllers/example/grails/JobExamplesController.groovy
----
include::../snippets/complete/grails-app/controllers/example/grails/JobExamplesController.groovy[]
----

[source,groovy]
.grails-app/controllers/example/grails/UrlMappings.groovy
----
include::../snippets/complete/grails-app/controllers/example/grails/UrlMappings.groovy[]
----

https://www.jobrunr.io/en/documentation/background-methods/[JobRunr's background-method documentation] distinguishes these OSS operations from Pro-only workflows. Batches, chains or continuations, replacement, and custom retry policies are Pro features and do not appear as runnable code here.
28 changes: 28 additions & 0 deletions guides/grails-jobrunr/v8/guide/jobRequests.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
== Do not enqueue Groovy closures

JobRunr needs a durable description of the method to execute. An ordinary Groovy closure does not provide one. More subtly, this tempting cast does not make the job safe:

[source,groovy]
----
jobScheduler.enqueue((JobLambda) (() -> processDelivery(deliveryId)))
----

The cast chooses a Java functional-interface overload, but it does not produce a javac-style JobRunr target. Executed checks against Groovy `4.0.33` and against Grails `8.0.0-M5` with Groovy `5.0.8` both persisted a generated `Closure#doCall` target. The jobs failed before the intended method ran. Never claim this cast-arrow form is a workaround.

Use JobRunr's explicit https://github.com/jobrunr/jobrunr/blob/v8.8.1/core/src/main/java/org/jobrunr/jobs/lambdas/JobRequest.java[`JobRequest`] and https://github.com/jobrunr/jobrunr/blob/v8.8.1/core/src/main/java/org/jobrunr/jobs/lambdas/JobRequestHandler.java[`JobRequestHandler`] contract instead. The request carries only the durable delivery ID, has a no-argument constructor for deserialization, and maps itself to a Spring handler:

[source,groovy]
.src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy[]
----

The handler is a Spring component, statically compiled, and transactional. It reloads current state rather than serializing a `Delivery` instance. It returns for a missing or previously completed delivery, reports progress, and preserves interruption by restoring the interrupt flag before throwing:

[source,groovy]
.src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy[]
----

The `SUCCEEDED` guard makes retries and repeated delivery harmless for this state transition. Real handlers need the same kind of idempotency around every externally visible effect.
25 changes: 25 additions & 0 deletions guides/grails-jobrunr/v8/guide/operations.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
== Persistent storage and schema ownership

Use a supported persistent database and route all JobRunr nodes to the same writer storage. Do not use a read replica for JobRunr storage. Set `jobrunr.database.table-prefix` when the JobRunr tables must be namespaced in a shared schema, and use that same prefix in migrations and every application node. Apply the JobRunr schema migrations before deployment, then set `jobrunr.database.skip-create: true` in production so `DatabaseOptions.SKIP_CREATE` expects a pre-applied schema without application startup owning DDL. The https://www.jobrunr.io/en/documentation/storage/[storage documentation] is the first-party reference for supported databases and schema behavior.

JobRunr `8.8.1` moved retention configuration to `jobrunr.jobs.delete-succeeded-jobs-after` and `jobrunr.jobs.permanently-delete-deleted-jobs-after`. Do not copy deprecated retention keys nested below the background server.

The production sample sets `jobrunr.miscellaneous.allow-anonymous-data-usage: false` to opt out of anonymous JobRunr data usage. Keep that setting unless your organization has intentionally approved participation.

== Dashboard and workers

The background server is disabled by default. Its default poll interval is 15 seconds and default shutdown wait is 10 seconds. Size worker count for the work and downstream capacity, then scale horizontally only when all nodes use the same writer storage and handlers remain idempotent.

The OSS dashboard is a separate server, not a Grails controller. It is disabled by default and uses `http://localhost:8000/dashboard` when enabled. It binds a wildcard address, so keep it disabled by default in production. If you enable it, configure both `jobrunr.dashboard.username` and `jobrunr.dashboard.password` from external secrets, restrict network access, and put it behind a TLS-terminating reverse proxy. OSS Basic authentication protects the dashboard but does not provide the SSO, role authorization, Spring Security integration, or context-path controls offered by Pro. See the https://www.jobrunr.io/en/documentation/background-methods/dashboard/[dashboard documentation].

== Delivery semantics, payloads, and observability

Job execution is at-least-once at the business-effect level. A retry restarts the handler, and a process failure can leave a job eligible to execute again. Make every effect idempotent, preserve interruption as the sample handler does, and record a business idempotency key where the effect cannot be safely repeated.

Pass small, serializable values, preferably IDs, not live GORM entities, request objects, credentials, or large payloads. Jobs may run much later and may be inspected in storage. The https://www.jobrunr.io/en/documentation/background-methods/passing-arguments/[JobRunr argument guidance] explains this boundary.

Expose JobRunr logs, job counts, failure rates, queue latency, and worker capacity to your telemetry system. JobRunr's https://www.jobrunr.io/en/documentation/configuration/metrics/[metrics configuration] documents its metrics integrations. Alert on sustained failed jobs and on a growing scheduled or enqueued backlog, not only on process health.

== OSS and Pro boundary

This guide uses the supported OSS path: durable storage, background workers, dashboard, immediate and delayed jobs, recurring jobs, progress, and standard retries. It intentionally excludes Pro-only batches, chains or continuations, replacement, custom retry policy, and the transaction plugin. Use an application outbox when OSS needs reliable coupling to a GORM transaction; do not paste Pro APIs into this sample and expect them to work on OSS.
8 changes: 8 additions & 0 deletions guides/grails-jobrunr/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
To complete this guide, you will need:

* JDK 21. Grails 8 requires Java 21.
* About 45 minutes.
* An IDE with Groovy support.
* The Gradle wrapper included with the sample. It is pinned to Gradle `9.6.1`.

The sample uses in-memory H2 databases for application and JobRunr storage. That is suitable for learning and tests only. The production section explains the persistent writer database and schema workflow.
29 changes: 29 additions & 0 deletions guides/grails-jobrunr/v8/guide/runningTheApp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Start the completed application:

[source,bash]
----
cd complete
./gradlew bootRun
----

In the development environment, the sample starts two workers and enables the dashboard on port `8000`. Create a delivery, then read it after the worker completes:

[source,bash]
----
curl -i -X POST http://localhost:8080/deliveries -H "Accept: application/json" -d "reference=guide-delivery-1"
curl http://localhost:8080/deliveries/1
----

The first response is `201 Created` with a `PENDING` delivery. The second eventually reports `SUCCEEDED`, `progress: 100`, and a completion time. Open `http://localhost:8000/dashboard` to inspect the job and its progress.

Exercise the scheduler endpoints with the delivery ID returned by the create request:

[source,bash]
----
curl -X POST http://localhost:8080/jobs/immediate/1
curl -X POST http://localhost:8080/jobs/delayed/1
curl -X POST http://localhost:8080/jobs/recurring/1
curl -X POST http://localhost:8080/jobs/retry/1
----

Each responds with `202 Accepted`. The retry example intentionally transitions through retry states before failing. Do not enable this development dashboard configuration unchanged in production; see xref:operations.adoc#operations[Production Operations and OSS Boundaries].
29 changes: 29 additions & 0 deletions guides/grails-jobrunr/v8/guide/storageConfiguration.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
JobRunr needs one durable writer data source. Do not point JobRunr at a read replica: workers both read and write job state. The https://www.jobrunr.io/en/documentation/storage/[JobRunr storage documentation] explains the storage requirements.

The sample separates application persistence from JobRunr persistence. Grails names the default bean `dataSource` and the named `jobrunr` source `dataSource_jobrunr`:

[source,yaml]
.grails-app/conf/application.yml
----
include::../snippets/complete/grails-app/conf/application.yml[]
----

With multiple data sources, relying on type-only auto-configuration can be ambiguous. Instead, the sample supplies an explicit `StorageProvider` and qualifies the dedicated source. It also installs the `JobMapper` eagerly:

[source,groovy]
.src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy
----
include::../snippets/complete/src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy[]
----

The configuration reads `jobrunr.database.table-prefix` and `jobrunr.database.skip-create` from JobRunr properties. Set a table prefix when JobRunr must share a schema with other applications or deployments. With `skip-create: false`, the provider creates its tables, which is useful for this guide's disposable H2 storage. With `skip-create: true`, it uses `DatabaseOptions.SKIP_CREATE` and does not create tables at application startup.

Before any production node starts with `skip-create: true`, apply the JobRunr schema migrations for the configured database and table prefix. Missing tables must fail startup rather than allowing an application instance to take ownership of production DDL. Production schema management is covered in xref:operations.adoc#operations[Production Operations and OSS Boundaries].

Grails must see the configuration class and its components. The application imports the configuration and scans the package containing the listener and handlers:

[source,groovy]
.grails-app/init/example/grails/Application.groovy
----
include::../snippets/complete/grails-app/init/example/grails/Application.groovy[]
----
Loading