Background Jobs with JobRunr in Grails 8
Run durable background work safely with JobRunr OSS, Grails transactions, and explicit storage wiring.
Authors: James Fredley
Grails Version: 8
1 Getting Started
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 Apache Grails and 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
Deliveryand returns201 Created. -
A durable
JobRequestqueued 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.1 What you will need
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.
1.2 How to Complete the Guide
You can work through the code in the guide or clone the finished application:
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.
2 Add JobRunr and Jackson
Grails 8 runs on Spring Boot 4, so use JobRunr’s Spring Boot 4 starter. The JobRunr Grails guide documents this starter family, and the Maven Central artifact identifies the selected GA release.
The initial build has no JobRunr runtime dependency:
plugins {
id 'org.apache.grails.gradle.grails-web' version "${grailsVersion}"
id 'org.apache.grails.gradle.grails-gsp' version "${grailsVersion}"
}
group = 'example.grails'
version = '0.1.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.apache.grails:grails-dependencies-starter-web'
implementation 'org.apache.grails:grails-data-hibernate5'
implementation 'org.apache.grails:grails-data-hibernate5-spring-boot'
implementation 'org.apache.grails:grails-datasource'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.apache.grails:grails-testing-support-web'
testImplementation 'org.apache.grails:grails-testing-support-datamapping'
}
tasks.named('test') {
useJUnitPlatform()
}
The complete build adds the Boot 4 starter and explicit jackson-databind:
plugins {
id 'org.apache.grails.gradle.grails-web' version "${grailsVersion}"
id 'org.apache.grails.gradle.grails-gsp' version "${grailsVersion}"
}
group = 'example.grails'
version = '0.1.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.apache.grails:grails-dependencies-starter-web'
implementation 'org.apache.grails:grails-data-hibernate5'
implementation 'org.apache.grails:grails-data-hibernate5-spring-boot'
implementation 'org.apache.grails:grails-datasource'
implementation "org.jobrunr:jobrunr-spring-boot-4-starter:${jobrunrVersion}"
implementation 'com.fasterxml.jackson.core:jackson-databind'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.apache.grails:grails-testing-support-web'
testImplementation 'org.apache.grails:grails-testing-support-datamapping'
}
tasks.named('test') {
useJUnitPlatform()
}
bootRun {
jvmArgs '-Dgrails.env=development'
sourceResources sourceSets.main
}
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:
grailsVersion=8.0.0-M5
org.gradle.caching=true
org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1g
grailsVersion=8.0.0-M5
jobrunrVersion=8.8.1
org.gradle.caching=true
org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1g
3 Configure Dedicated Job Storage
JobRunr needs one durable writer data source. Do not point JobRunr at a read replica: workers both read and write job state. The 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:
grails:
profile: web
codegen:
defaultPackage: example.grails
gorm:
reactor:
events: false
dataSource:
dbCreate: create-drop
url: jdbc:h2:mem:application;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driverClassName: org.h2.Driver
username: sa
password: ''
dataSources:
jobrunr:
dbCreate: none
url: jdbc:h2:mem:jobrunr;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driverClassName: org.h2.Driver
username: sa
password: ''
hibernate:
cache:
queries: false
use_second_level_cache: false
use_query_cache: false
jobrunr:
jobs:
default-number-of-retries: 3
server:
port: 8080
environments:
development:
jobrunr:
background-job-server:
enabled: true
worker-count: 2
poll-interval-in-seconds: 5
dashboard:
enabled: true
port: 8000
test:
jobrunr:
background-job-server:
enabled: true
worker-count: 1
poll-interval-in-seconds: 5
dashboard:
enabled: false
production:
jobrunr:
database:
skip-create: true
background-job-server:
enabled: false
dashboard:
enabled: false
miscellaneous:
allow-anonymous-data-usage: false
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:
package example.grails.jobrunr
import javax.sql.DataSource
import org.jobrunr.jobs.mappers.JobMapper
import org.jobrunr.spring.autoconfigure.JobRunrProperties
import org.jobrunr.storage.StorageProvider
import org.jobrunr.storage.StorageProviderUtils.DatabaseOptions
import org.jobrunr.storage.sql.common.SqlStorageProviderFactory
import org.jobrunr.utils.mapper.jackson.JacksonJsonMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JobRunrStorageConfig {
@Bean
StorageProvider storageProvider(
@Qualifier('dataSource_jobrunr') DataSource jobrunrDataSource,
JobRunrProperties jobRunrProperties
) {
StorageProvider storageProvider = SqlStorageProviderFactory.using(
jobrunrDataSource,
jobRunrProperties.database.tablePrefix,
jobRunrProperties.database.skipCreate ? DatabaseOptions.SKIP_CREATE : DatabaseOptions.CREATE
)
storageProvider.setJobMapper(new JobMapper(new JacksonJsonMapper()))
storageProvider
}
}
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 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:
package example.grails
import example.grails.jobrunr.JobRunrStorageConfig
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import org.grails.datastore.gorm.boot.autoconfigure.HibernateGormAutoConfiguration
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Import
@Import([HibernateGormAutoConfiguration, JobRunrStorageConfig])
@ComponentScan('example.grails')
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
4 Use Job Requests From Groovy
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:
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 JobRequest and 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:
package example.grails.jobrunr
import groovy.transform.CompileStatic
import org.jobrunr.jobs.lambdas.JobRequest
@CompileStatic
class ProcessDeliveryJobRequest implements JobRequest {
Long deliveryId
ProcessDeliveryJobRequest() {
}
ProcessDeliveryJobRequest(Long deliveryId) {
this.deliveryId = deliveryId
}
@Override
Class<DeliveryJobRequestHandler> getJobRequestHandler() {
DeliveryJobRequestHandler
}
}
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:
package example.grails.jobrunr
import example.grails.Delivery
import grails.compiler.GrailsCompileStatic
import grails.gorm.transactions.Transactional
import org.jobrunr.jobs.annotations.Job
import org.jobrunr.jobs.lambdas.JobRequestHandler
import org.jobrunr.server.runner.ThreadLocalJobContext
import org.springframework.stereotype.Component
@Component
@GrailsCompileStatic
@Transactional
class DeliveryJobRequestHandler implements JobRequestHandler<ProcessDeliveryJobRequest> {
@Override
@Job(name = 'Process delivery', retries = 3, labels = ['delivery'])
void run(ProcessDeliveryJobRequest request) throws Exception {
try {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException('Delivery worker was interrupted')
}
if (request.deliveryId == null) {
return
}
Delivery delivery = Delivery.get(request.deliveryId)
if (delivery == null || delivery.status == 'SUCCEEDED') {
return
}
def context = ThreadLocalJobContext.getJobContext()
def progressBar = context.progressBar(3)
for (int step = 1; step <= 3; step++) {
delivery.progress = step * 33
progressBar.incrementSucceeded()
}
delivery.progress = 100
delivery.status = 'SUCCEEDED'
delivery.completedAt = new Date()
delivery.save(failOnError: true, flush: true)
} catch (InterruptedException exception) {
Thread.currentThread().interrupt()
throw exception
}
}
}
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.
5 Enqueue After Commit
The job must not run for a delivery that rolls back. The delivery model records the business state and progress that the job updates:
package example.grails
class Delivery {
String reference
String status = 'PENDING'
Integer progress = 0
Date completedAt
static constraints = {
reference nullable: false, blank: false, unique: true
status blank: false
completedAt nullable: true
}
}
The service first persists the delivery and publishes a small event within its GORM transaction:
package example.grails
import grails.compiler.GrailsCompileStatic
import grails.gorm.transactions.Transactional
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationEventPublisher
@GrailsCompileStatic
@Transactional
class DeliveryService {
@Autowired
ApplicationEventPublisher applicationEventPublisher
Delivery create(String reference) {
Delivery delivery = new Delivery(reference: reference)
if (!delivery.validate()) {
return delivery
}
delivery.save(failOnError: true, flush: true)
applicationEventPublisher.publishEvent(new DeliveryCreatedEvent(delivery.id))
delivery
}
}
package example.grails
import groovy.transform.CompileStatic
@CompileStatic
class DeliveryCreatedEvent {
final Long deliveryId
DeliveryCreatedEvent(Long deliveryId) {
this.deliveryId = deliveryId
}
}
The listener uses Spring’s transaction-bound event support to run only after that transaction commits, then enqueues the request:
package example.grails
import example.grails.jobrunr.ProcessDeliveryJobRequest
import grails.compiler.GrailsCompileStatic
import org.jobrunr.scheduling.JobRequestScheduler
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Component
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
@Component
@GrailsCompileStatic
class DeliveryJobEnqueuer {
@Autowired
JobRequestScheduler jobRequestScheduler
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void enqueue(DeliveryCreatedEvent event) {
jobRequestScheduler.enqueue(new ProcessDeliveryJobRequest(event.deliveryId))
}
}
Grails does not register Spring’s TransactionalEventListenerFactory through @EnableTransactionManagement, so declare it explicitly or the transactional listener is silently ignored:
import org.springframework.transaction.event.TransactionalEventListenerFactory
beans = {
transactionalEventListenerFactory(TransactionalEventListenerFactory)
}
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 transaction plugin. The transaction plugin is a Pro feature and is intentionally not used by this sample.
6 Schedule and Track Jobs
JobRequestScheduler supports immediate enqueueing, delayed work, recurring work, and retries in OSS. The sample keeps each operation on the explicit request type:
package example.grails
import example.grails.jobrunr.ProcessDeliveryJobRequest
import example.grails.jobrunr.RetryDeliveryJobRequest
import grails.compiler.GrailsCompileStatic
import java.time.Instant
import org.jobrunr.scheduling.JobRequestScheduler
import org.springframework.beans.factory.annotation.Autowired
@GrailsCompileStatic
class JobExamplesService {
@Autowired
JobRequestScheduler jobRequestScheduler
String enqueueImmediately(Long deliveryId) {
jobRequestScheduler.enqueue(new ProcessDeliveryJobRequest(deliveryId)).asUUID().toString()
}
String enqueueForLater(Long deliveryId) {
jobRequestScheduler.schedule(Instant.now().plusSeconds(30), new ProcessDeliveryJobRequest(deliveryId)).asUUID().toString()
}
String registerRecurringDelivery(Long deliveryId) {
jobRequestScheduler.scheduleRecurrently(
"delivery-${deliveryId}",
'0 3 * * *',
new ProcessDeliveryJobRequest(deliveryId)
)
}
String enqueueRetryDemo(Long deliveryId) {
jobRequestScheduler.enqueue(new RetryDeliveryJobRequest(deliveryId)).asUUID().toString()
}
}
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:
package example.grails.jobrunr
import groovy.transform.CompileStatic
import org.jobrunr.jobs.lambdas.JobRequest
@CompileStatic
class RetryDeliveryJobRequest implements JobRequest {
Long deliveryId
RetryDeliveryJobRequest() {
}
RetryDeliveryJobRequest(Long deliveryId) {
this.deliveryId = deliveryId
}
@Override
Class<RetryDeliveryJobRequestHandler> getJobRequestHandler() {
RetryDeliveryJobRequestHandler
}
}
package example.grails.jobrunr
import grails.compiler.GrailsCompileStatic
import org.jobrunr.jobs.annotations.Job
import org.jobrunr.jobs.lambdas.JobRequestHandler
import org.springframework.stereotype.Component
@Component
@GrailsCompileStatic
class RetryDeliveryJobRequestHandler implements JobRequestHandler<RetryDeliveryJobRequest> {
@Override
@Job(name = 'Retry delivery example', retries = 2, labels = ['delivery', 'retry'])
void run(RetryDeliveryJobRequest request) {
throw new IllegalStateException("Retry example for delivery ${request.deliveryId}")
}
}
DeliveryController exposes the delivery creation and lookup API, returning 201 Created, validation errors, or 404 Not Found as appropriate:
package example.grails
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class DeliveryController {
static allowedMethods = [create: 'POST', show: 'GET']
static responseFormats = ['json']
DeliveryService deliveryService
Object create(String reference) {
Delivery delivery = deliveryService.create(reference)
if (delivery.hasErrors()) {
respond(delivery.errors, status: 422)
return
}
respond(delivery, status: 201)
}
Object show(Long id) {
Delivery delivery = Delivery.get(id)
if (delivery == null) {
render status: 404
return
}
respond(delivery)
}
}
JobExamplesController and the URL mappings separately expose the asynchronous examples as 202 Accepted operations:
package example.grails
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class JobExamplesController {
static allowedMethods = [immediate: 'POST', delayed: 'POST', recurring: 'POST', retry: 'POST']
static responseFormats = ['json']
JobExamplesService jobExamplesService
Object immediate(Long id) {
respond([jobId: jobExamplesService.enqueueImmediately(id)], status: 202)
}
Object delayed(Long id) {
respond([jobId: jobExamplesService.enqueueForLater(id)], status: 202)
}
Object recurring(Long id) {
respond([recurringJobId: jobExamplesService.registerRecurringDelivery(id)], status: 202)
}
Object retry(Long id) {
respond([jobId: jobExamplesService.enqueueRetryDemo(id)], status: 202)
}
}
package example.grails
class UrlMappings {
static mappings = {
"/deliveries"(controller: 'delivery', action: 'create', method: 'POST')
"/deliveries/$id"(controller: 'delivery', action: 'show', method: 'GET')
"/jobs/immediate/$id"(controller: 'jobExamples', action: 'immediate', method: 'POST')
"/jobs/delayed/$id"(controller: 'jobExamples', action: 'delayed', method: 'POST')
"/jobs/recurring/$id"(controller: 'jobExamples', action: 'recurring', method: 'POST')
"/jobs/retry/$id"(controller: 'jobExamples', action: 'retry', method: 'POST')
}
}
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.
7 Run and Inspect Jobs
Start the completed application:
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:
curl -i -X POST http://localhost:8080/deliveries -H "Accept: application/json" -H "Content-Type: application/x-www-form-urlencoded" -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:
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 Production Operations and OSS Boundaries.
8 Test the Integration
The sample uses both focused unit tests and a live integration test. First, the request test protects the deserialization-friendly no-argument constructor and handler mapping:
package example.grails.jobrunr
import spock.lang.Specification
class JobRequestSpec extends Specification {
void 'process request maps to its Spring handler and supports deserialization'() {
when:
ProcessDeliveryJobRequest request = new ProcessDeliveryJobRequest(42L)
ProcessDeliveryJobRequest emptyRequest = new ProcessDeliveryJobRequest()
then:
request.deliveryId == 42L
emptyRequest.deliveryId == null
request.jobRequestHandler == DeliveryJobRequestHandler
}
void 'retry request maps to its dedicated handler'() {
expect:
new RetryDeliveryJobRequest(7L).jobRequestHandler == RetryDeliveryJobRequestHandler
}
}
The listener test checks that the production wiring keeps the decisive AFTER_COMMIT phase and submits the expected request:
package example.grails
import org.jobrunr.scheduling.JobRequestScheduler
import spock.lang.Specification
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
class DeliveryJobEnqueuerSpec extends Specification {
void 'listener runs only after the delivery transaction commits'() {
when:
TransactionalEventListener annotation = DeliveryJobEnqueuer
.getMethod('enqueue', DeliveryCreatedEvent)
.getAnnotation(TransactionalEventListener)
then:
annotation != null
annotation.phase() == TransactionPhase.AFTER_COMMIT
!annotation.fallbackExecution()
}
void 'listener maps a committed delivery event to a JobRequest'() {
given:
JobRequestScheduler scheduler = Mock()
DeliveryJobEnqueuer enqueuer = new DeliveryJobEnqueuer(jobRequestScheduler: scheduler)
when:
enqueuer.enqueue(new DeliveryCreatedEvent(17L))
then:
1 * scheduler.enqueue({ request -> request.deliveryId == 17L })
}
}
The storage configuration test verifies that the configured table prefix is used and that skip-create leaves an empty data source without JobRunr tables:
package example.grails.jobrunr
import javax.sql.DataSource
import org.jobrunr.JobRunrException
import org.jobrunr.spring.autoconfigure.JobRunrProperties
import org.springframework.jdbc.datasource.DriverManagerDataSource
import spock.lang.Specification
class JobRunrStorageConfigSpec extends Specification {
void 'storage configuration applies the JobRunr database properties'() {
given:
DataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:jobrunr-storage-config-${System.nanoTime()};DB_CLOSE_DELAY=-1",
'sa',
''
)
JobRunrProperties properties = new JobRunrProperties()
properties.database.tablePrefix = 'GUIDE_'
properties.database.skipCreate = false
when:
new JobRunrStorageConfig().storageProvider(dataSource, properties)
then:
dataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, 'GUIDE%', null).next()
}
when:
DataSource skipCreateDataSource = new DriverManagerDataSource(
"jdbc:h2:mem:jobrunr-storage-config-skip-${System.nanoTime()};DB_CLOSE_DELAY=-1",
'sa',
''
)
properties.database.skipCreate = true
properties.database.tablePrefix = 'SKIP_'
new JobRunrStorageConfig().storageProvider(skipCreateDataSource, properties)
then:
thrown(JobRunrException)
!skipCreateDataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, 'SKIP%', null).next()
}
}
}
The HTTP integration test discovers the controller action and URL mapping, then sends a real request to the embedded server:
package example.grails
import grails.testing.mixin.integration.Integration
import grails.core.GrailsApplication
import grails.core.GrailsControllerClass
import grails.web.mapping.UrlMappingInfo
import grails.web.mapping.UrlMappingsHolder
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpMethod
import spock.lang.Specification
@Integration
class DeliveryHttpApiSpec extends Specification {
@Autowired
GrailsApplication grailsApplication
@Autowired
UrlMappingsHolder urlMappingsHolder
@Value('${local.server.port}')
int port
void 'POST deliveries routes to the documented create action'() {
given:
GrailsControllerClass deliveryController = grailsApplication.getArtefact('Controller', DeliveryController.name)
UrlMappingInfo[] deliveryMappings = urlMappingsHolder.matchAll('/deliveries', HttpMethod.POST)
when:
HttpURLConnection connection = new URL("http://localhost:${port}/deliveries").openConnection()
connection.requestMethod = 'POST'
connection.doOutput = true
connection.setRequestProperty('Accept', 'application/json')
connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded')
connection.outputStream.withCloseable { output ->
output.write('reference=delivery-http-route'.bytes)
}
then:
deliveryController.actions.contains('create')
deliveryMappings.any { it.controllerName == 'delivery' && it.actionName == 'create' }
connection.responseCode == 201
cleanup:
connection.disconnect()
}
void 'POST deliveries without a reference returns 422 and does not persist a delivery'() {
given:
int deliveryCount = Delivery.withNewSession { Delivery.count() }
when:
HttpURLConnection connection = new URL("http://localhost:${port}/deliveries").openConnection()
connection.requestMethod = 'POST'
connection.doOutput = true
connection.setRequestProperty('Accept', 'application/json')
connection.setRequestProperty('Content-Type', 'application/x-www-form-urlencoded')
connection.outputStream.withCloseable { output ->
output.write(''.bytes)
}
then:
connection.responseCode == 422
Delivery.withNewSession { Delivery.count() } == deliveryCount
cleanup:
connection?.disconnect()
}
}
The sample also includes unit tests for the scheduler endpoints and service. They are not required to follow the earlier chapters, but they keep the JobExamplesController and JobExamplesService behavior in lockstep with the companion app:
package example.grails
import grails.testing.web.controllers.ControllerUnitTest
import groovy.json.JsonSlurper
import spock.lang.Specification
class JobExamplesControllerSpec extends Specification implements ControllerUnitTest<JobExamplesController> {
void 'immediate job endpoint returns an accepted response with a string job id'() {
given:
controller.jobExamplesService = Stub(JobExamplesService) {
enqueueImmediately(7L) >> 'b5ab0831-a3af-4554-9b7a-5571137e9aaa'
}
request.method = 'POST'
request.addHeader('Accept', 'application/json')
when:
controller.immediate(7L)
then:
response.status == 202
new JsonSlurper().parseText(response.text).jobId == 'b5ab0831-a3af-4554-9b7a-5571137e9aaa'
}
void 'recurring job endpoint requires a delivery id and returns its stable identifier'() {
given:
controller.jobExamplesService = Stub(JobExamplesService) {
registerRecurringDelivery(19L) >> 'delivery-19'
}
request.method = 'POST'
request.addHeader('Accept', 'application/json')
when:
controller.recurring(19L)
then:
response.status == 202
new JsonSlurper().parseText(response.text).recurringJobId == 'delivery-19'
}
}
package example.grails
import example.grails.jobrunr.ProcessDeliveryJobRequest
import org.jobrunr.scheduling.JobRequestScheduler
import spock.lang.Specification
class JobExamplesServiceSpec extends Specification {
void 'recurring delivery uses the delivery id for its stable JobRunr identifier and request'() {
given:
JobRequestScheduler scheduler = Mock()
JobExamplesService service = new JobExamplesService(jobRequestScheduler: scheduler)
when:
String recurringJobId = service.registerRecurringDelivery(19L)
then:
recurringJobId == 'delivery-19'
1 * scheduler.scheduleRecurrently(
'delivery-19',
'0 3 * * *',
{ ProcessDeliveryJobRequest request -> request.deliveryId == 19L }
) >> 'delivery-19'
}
}
Finally, the integration test starts JobRunr and GORM together. It proves that a live JobRequest runs to completion and that JOBRUNR_JOBS exists only in the dedicated JobRunr data source:
package example.grails
import grails.testing.mixin.integration.Integration
import javax.sql.DataSource
import org.jobrunr.storage.StorageProvider
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.transaction.event.TransactionalEventListenerFactory
import spock.lang.Specification
@Integration
class JobRunrIntegrationSpec extends Specification {
@Autowired
DeliveryService deliveryService
@Autowired
StorageProvider storageProvider
@Autowired
TransactionalEventListenerFactory transactionalEventListenerFactory
@Autowired
@Qualifier('dataSource')
DataSource applicationDataSource
@Autowired
@Qualifier('dataSource_jobrunr')
DataSource jobrunrDataSource
void 'JobRequest completes a GORM delivery while JobRunr owns separate tables'() {
when:
int existingJobs = storageProvider.jobStats.total
Delivery delivery = deliveryService.create("delivery-${System.nanoTime()}")
then:
eventually { storageProvider.jobStats.total == existingJobs + 1 }
eventually { loadDelivery(delivery.id)?.status == 'SUCCEEDED' }
loadDelivery(delivery.id).progress == 100
loadDelivery(delivery.id).completedAt != null
storageProvider != null
transactionalEventListenerFactory != null
tableExists(jobrunrDataSource, 'JOBRUNR_JOBS')
!tableExists(applicationDataSource, 'JOBRUNR_JOBS')
}
private static boolean eventually(Closure<Boolean> condition) {
for (int attempt = 0; attempt < 120; attempt++) {
if (condition.call()) {
return true
}
Thread.sleep(250)
}
false
}
private static boolean tableExists(DataSource dataSource, String tableName) {
dataSource.connection.withCloseable { connection ->
connection.metaData.getTables(null, null, tableName, null).next()
}
}
private static Delivery loadDelivery(Long id) {
Delivery.withNewTransaction {
Delivery.get(id)
}
}
}
Run the complete suite with:
cd complete
./gradlew test
9 Production Operations
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 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 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 JobRunr argument guidance explains this boundary.
Expose JobRunr logs, job counts, failure rates, queue latency, and worker capacity to your telemetry system. JobRunr’s 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.
10 Do You Need Help With Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.