This guide walks you through integrating JobRunr - a distributed background job processing library - with Apache Grails 8. By the end you will have a working demo app that showcases every major JobRunr OSS feature: fire-and-forget jobs, scheduled jobs, recurring jobs, retries, progress tracking, job filters, and the built-in dashboard.
The demo uses an e-commerce order processing theme with domain classes for orders, products, and audit logs.
- Java 25 (the demo's Gradle toolchain pins the language version to 25; any vendor's OpenJDK 25 build works, e.g. Temurin, Corretto, Zulu)
- No Grails CLI required - the project includes the Gradle wrapper
For Grails 7.1.0: pin
grailsVersion=7.1.0ingradle.properties, swapjobrunr-spring-boot-4-starterforjobrunr-spring-boot-3-starter(same version), and lower the toolchain to JDK 17. The rest of the demo works unchanged.
cd grails-jobrunr
./gradlew bootRun- Demo app: http://localhost:8080 — page with buttons to trigger each job type
- JobRunr Dashboard: http://localhost:8000/dashboard — monitor jobs, view progress, inspect retries
- Project Setup
- Bridging Grails and JobRunr
- Configuration
- Why Groovy Cannot Use JobRunr's Lambda API
- The JobRequest Pattern
- Fire-and-Forget Jobs
- Scheduled Jobs
- Recurring Jobs
- The @Job Annotation
- Job Progress and Dashboard Logging
- Automatic Retries
- Job Filters
- Bulk Enqueueing
- GORM in Background Jobs
- The Dashboard
- Production Considerations
- Pitfalls Reference
All third-party versions live in gradle.properties so the demo has a single source of truth:
grailsVersion=8.0.0-M1
jobrunrVersion=8.5.1
assetPipelineVersion=5.1.0-M4
directoryWatcherVersion=0.19.1Then build.gradle references them by name:
dependencies {
// Grails Web Starter
implementation 'org.apache.grails:grails-dependencies-starter-web'
// GORM Hibernate - you need BOTH artifacts
implementation 'org.apache.grails:grails-data-hibernate5' // Grails plugin
implementation 'org.apache.grails:grails-data-hibernate5-spring-boot' // Spring Boot auto-config
implementation 'org.apache.grails:grails-datasource'
// H2 for development
runtimeOnly 'com.h2database:h2'
// Asset pipeline (serves CSS/JS from grails-app/assets/)
runtimeOnly "cloud.wondrify:asset-pipeline-grails:${assetPipelineVersion}"
// macOS file watcher (prevents noisy ClassNotFoundException on startup).
// Pin matches the Grails 8.0.0-M1 BOM. On Grails 7.x, use 0.18.0 instead.
developmentOnly "io.methvin:directory-watcher:${directoryWatcherVersion}"
// JobRunr (Grails 8 ships Spring Boot 4, so use the SB4 starter;
// on Grails 7.x replace with jobrunr-spring-boot-3-starter at the same version)
implementation "org.jobrunr:jobrunr-spring-boot-4-starter:${jobrunrVersion}"
// Spring Boot 4 / Grails 8 no longer pulls jackson-databind transitively for our
// surface area; JobRunr's JacksonJsonMapper needs it. Version is BOM-managed.
implementation 'com.fasterxml.jackson.core:jackson-databind'
}Pitfall: Two Hibernate artifacts.
grails-data-hibernate5registers the Grails Hibernate plugin.grails-data-hibernate5-spring-bootprovides the Spring Boot auto-configuration. If you only include the-spring-bootone, GORM will not initialize and you'll get: "Either class [X] is not a domain class or GORM has not been initialized correctly."
Pitfall: JobRunr starter must match Spring Boot major version. The
jobrunr-spring-boot-3-starteris compiled against Spring Boot 3 / Spring Framework 6 APIs (declaredprovidedscope). Putting it on a Grails 8 (Spring Boot 4 / Spring Framework 7) classpath will fail at bean wiring or class load time. Use the matching starter for your Spring Boot major version.
Pitfall: macOS file watcher. Without
io.methvin:directory-watcher, startup logs a longNoClassDefFoundErrorstacktrace forMacOSXListeningWatchService. It's harmless but noisy.
Your Application.groovy needs two annotations beyond the default:
@Import([JobRunrStorageConfig, HibernateGormAutoConfiguration])
@ComponentScan('example.grails.jobrunr')
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}@Import(JobRunrStorageConfig)- loads the custom StorageProvider bean (see next section)@Import(HibernateGormAutoConfiguration)- Grails bypasses Spring Boot'sDataSourceAutoConfiguration, which GORM's Hibernate auto-config depends on. Without this explicit import, GORM won't initialize.@ComponentScan('example.grails.jobrunr')- ensures Spring discovers@Componentclasses insrc/main/groovy/. Grails does not component-scan that directory by default.
Do not put
@CompileStaticonApplication.groovy.GrailsAutoConfiguration's lifecycle hooks (doWithSpring,doWithApplicationContext,doWithDynamicMethods, ...) dispatch into Groovy closures. Static compilation breaks those hooks the moment you override one.
Grails configures its DataSource via dataSource: blocks in application.yml (not the spring.datasource.* keys Spring Boot's DataSourceAutoConfiguration looks for), and the bean is registered late, by HibernateGormAutoConfiguration calling beanFactory.registerSingleton('dataSource', datastore.getDataSource()) from inside its @Bean method.
JobRunr's JobRunrSqlStorageAutoConfiguration is annotated @AutoConfigureAfter(DataSourceAutoConfiguration.class) and @ConditionalOnBean(DataSource.class). Because Spring Boot's DataSourceAutoConfiguration finds no spring.datasource.* config and contributes nothing, JobRunr's storage auto-config evaluates its @ConditionalOnBean against a context where the GORM-managed DataSource has not been registered yet. The result varies by Spring Boot version and bean ordering: in this demo it is unreliable enough that we register the StorageProvider explicitly to remove the timing dependency.
There are three things this configuration class must handle:
@Configuration
class JobRunrStorageConfig {
@Bean
StorageProvider storageProvider(DataSource dataSource) {
StorageProvider provider = SqlStorageProviderFactory.using(
dataSource, null, DatabaseOptions.CREATE)
provider.setJobMapper(new JobMapper(new JacksonJsonMapper()))
return provider
}
@Bean
JobFilterRegistrar jobFilterRegistrar(
BackgroundJobServer backgroundJobServer, List<JobFilter> jobFilters) {
return new JobFilterRegistrar(backgroundJobServer, jobFilters)
}
static class JobFilterRegistrar {
JobFilterRegistrar(BackgroundJobServer server, List<JobFilter> filters) {
server.setJobFilters(filters)
}
}
}-
StorageProviderbean — JobRunr's auto-config can't create one without a Spring Boot-managed DataSource. We create it manually from the Grails-managed DataSource. -
setJobMapper(...)called eagerly — TheRecurringJobPostProcessor(which processes@Recurringannotations) runs during Spring bean initialization. If theJobMapperhasn't been set on theStorageProvideryet, you'll get aNullPointerExceptioninsideRecurringJobTable. -
JobFilterRegistrarbean — JobRunr 8.x auto-configuration does NOT injectJobFilterbeans into theBackgroundJobServer. Without this registrar, any customApplyStateFilterorElectStateFilteryou write will be silently ignored.
# JobRunr settings (in a separate YAML document)
---
jobrunr:
background-job-server:
enabled: true
poll-interval-in-seconds: 5
worker-count: 4
dashboard:
enabled: true
port: 8000
jobs:
default-number-of-retries: 10
database:
skip-create: falseenvironments:
development:
dataSource:
dbCreate: create-drop
url: jdbc:h2:mem:devDb;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
driverClassName: org.h2.Driver
username: sa
password: ''Pitfall:
DB_CLOSE_DELAY=-1is required. Without it, H2's in-memory database is destroyed when the last JDBC connection closes. JobRunr opens connections during startup to run migrations, then closes them. By the time your first job runs, the tables are gone: "Table JOBRUNR_RECURRING_JOBS not found (this database is empty)."
Pitfall:
Orderis a SQL reserved word. If you have a domain class calledOrder, Hibernate will generateDROP TABLE orderwhich fails. Add a table mapping:class Order { static mapping = { table 'customer_order' } }
JobRunr's primary Java API looks like this:
jobScheduler.enqueue(svc -> svc.processOrder(orderId));This does not work in Groovy. Even with Groovy 4's arrow syntax, closures compile to different bytecode than Java lambdas. JobRunr uses ASM to analyze lambda bytecode for serialization, and it rejects Groovy closures with:
IllegalArgumentException: Please provide a lambda expression
There is no workaround — Groovy closures fundamentally cannot be used with JobScheduler.enqueue(), schedule(), or scheduleRecurrently().
Use JobRunr's JobRequest pattern instead. It avoids lambdas entirely. (Kotlin users have a separate code path - JobRunr ships a KotlinJobDetailsFinder for Kotlin lambdas - so this section is specific to Groovy.)
Instead of JobScheduler, inject JobRequestScheduler:
@Autowired
JobRequestScheduler jobRequestScheduler
// Fire-and-forget
jobRequestScheduler.enqueue(new OrderJobRequest(orderId))
// Scheduled
jobRequestScheduler.schedule(Instant.now().plusSeconds(120), new SendConfirmationRequest(orderId))
// Recurring
jobRequestScheduler.scheduleRecurrently("cleanup-id", "0 3 * * *", new CleanupJobRequest('audit-logs'))Every job needs two things: a request (what to do) and a handler (how to do it).
A simple Groovy class that implements JobRequest. It must have a no-arg constructor for deserialization:
class OrderJobRequest implements JobRequest {
Long orderId
OrderJobRequest() {}
OrderJobRequest(Long orderId) { this.orderId = orderId }
@Override
Class<OrderProcessingService> getJobRequestHandler() {
return OrderProcessingService
}
}Your Grails service can implement JobRequestHandler directly — no extra handler class needed:
@Transactional
class OrderProcessingService implements JobRequestHandler<OrderJobRequest> {
@Override
@Job(name = "Process order", retries = 5, labels = ["order-processing"])
void run(OrderJobRequest request) throws Exception {
processOrder(request.orderId)
}
void processOrder(Long orderId) {
Order order = Order.get(orderId)
order.status = 'PROCESSING'
order.save(flush: true)
// ...
}
}For standalone handlers that aren't Grails services, annotate with @Component and @Transactional:
@Component
class ImportProductsJobRequestHandler implements JobRequestHandler<ImportProductsJobRequest> {
@Override
@Job(name = "Import products", retries = 3)
@Transactional
void run(ImportProductsJobRequest request) throws Exception {
// ...
}
}Pitfall: Put
@Jobon therun()method, not on the delegated business method. JobRunr records the job as a call torun(Request). Any@Jobannotation onprocessOrder()is never evaluated because JobRunr doesn't know about that internal delegation.Also avoid
%0in@Job(name = "...")onrun()methods —%0resolves to the request object'stoString(), not to a specific field. Use a fixed name instead.
jobRequestScheduler.enqueue(new OrderJobRequest(orderId))The job executes immediately in JobRunr's background thread pool.
Instant runAt = Instant.now().plusSeconds(120)
jobRequestScheduler.schedule(runAt, new SendConfirmationRequest(orderId))The job appears in the dashboard's "Scheduled" tab and executes at the specified time.
Use @Recurring on service methods. Grails services are lazy-initialized by default, so you must set static lazyInit = false or JobRunr won't discover the annotations at startup:
class ReportGenerationService {
static lazyInit = false
@Recurring(id = "daily-sales-report", cron = "0 2 * * *")
@Job(name = "Generate daily sales report")
void generateDailySalesReport() { /* ... */ }
@Recurring(id = "inventory-snapshot", interval = "PT6H")
@Job(name = "Generate inventory snapshot")
void generateInventorySnapshot() { /* ... */ }
}Pitfall:
static lazyInit = false. Without this, the service bean isn't created until first use. Since nothing calls it during startup, the@Recurringannotations are never scanned and no recurring jobs are registered.
Register recurring jobs in BootStrap.groovy using JobRequestScheduler:
@Autowired
JobRequestScheduler jobRequestScheduler
def init = { servletContext ->
jobRequestScheduler.scheduleRecurrently(
"nightly-audit-cleanup", "0 3 * * *",
new CleanupJobRequest('audit-logs')
)
}@Job(name = "Process order", retries = 5, labels = ["order-processing"])
void run(OrderJobRequest request) { /* ... */ }name— Display name in the dashboard.retries— Override the default retry count.labels— Tags for filtering in the dashboard.
Access the JobContext via ThreadLocalJobContext.getJobContext() inside a JobRequestHandler.run() method:
import org.jobrunr.server.runner.ThreadLocalJobContext
void run(ImportProductsJobRequest request) throws Exception {
JobContext context = ThreadLocalJobContext.jobContext
JobDashboardLogger jobLogger = context.logger()
JobDashboardProgressBar progressBar = context.progressBar(request.batchSize)
jobLogger.info("Starting import of ${request.batchSize} products")
progressBar.incrementSucceeded()
}Pitfall:
JobRequestHandler.jobContext()is@Deprecatedin 8.x. The default method still works (it delegates toThreadLocalJobContext.getJobContext()), but the official replacement isThreadLocalJobContext.getJobContext()called directly. SeeJobRequestHandler.java.
Pitfall: JobRunr 8.x API changes. Three things changed from earlier versions:
Get logger/progressBar from context - Use
context.logger()andcontext.progressBar(n). Do NOT callnew JobDashboardLogger(context)- the constructor now takes aJobobject, not aJobContext, and Groovy's constructor resolution will fail withMissingMethodException.
incrementSucceeded()notincreaseByOne()- The progress bar method was renamed in 8.x.
info(String)only -JobDashboardLogger.info()takes a singleString. It does NOT support SLF4J-styleinfo("text {}", arg)formatting. Use Groovy string interpolation:jobLogger.info("Processing ${product.name}").
JobRunr automatically retries failed jobs with exponential backoff. Configure defaults:
jobrunr:
jobs:
default-number-of-retries: 10Override per-job with @Job(retries = 3).
Implement ApplyStateFilter to hook into job state transitions:
@Component
class AuditJobFilter implements ApplyStateFilter {
@Override
void onStateApplied(Job job, JobState oldState, JobState newState) {
try {
AuditLog.withNewTransaction {
new AuditLog(
jobId: job.id?.toString(),
jobName: job.jobName,
oldState: oldState?.name?.toString() ?: 'NONE',
newState: newState.name.toString()
).save(flush: true, failOnError: true)
}
} catch (Exception e) {
log.warn("Failed to write audit log: {}", e.message)
}
}
}Pitfall: Use
withNewTransaction, notwithNewSession. Filters run outside the Grails request lifecycle with no active transaction.withNewSessionopens a Hibernate session but not a transaction, causing: "no transaction is in progress". Always usewithNewTransactionwhich provides both.
Pitfall: Filters must be registered manually in JobRunr 8.x. The auto-configuration does not inject
JobFilterbeans into theBackgroundJobServer. See theJobFilterRegistrarin Section 2.
Pitfall: GORM in filters trips JobRunr's 10 ms warning. A
withNewTransactionblock that writes a domain object typically takes 10 to 60 ms on first invocation. JobRunr OSS does emit a warning every time the filter exceeds the budget - observed verbatim during boot of this demo on Grails 8: "JobFilter of type 'example.grails.jobrunr.AuditJobFilter' has slow performance of 57ms (a Job Filter should run under 10ms) which negatively impacts the overall functioning of JobRunr. JobRunr Pro can run slow running Job Filters without a negative performance impact." Keep filter work minimal, push heavy work into a fire-and-forget child job, or accept the warning for low-throughput audit use cases.
Enqueue one job per item:
products.each { product ->
jobRequestScheduler.enqueue(new SyncProductRequest(product.id))
}JobRunr runs jobs in its own thread pool, outside the Grails request lifecycle. GORM needs an active Hibernate session and transaction.
| Context | Solution |
|---|---|
Grails service (in grails-app/services/) |
@Transactional on the class — Grails provides session + transaction automatically |
@Component handler (in src/main/groovy/) |
@Transactional on the run() method |
| Job filter | DomainClass.withNewTransaction { ... } |
Enabled at http://localhost:8000/dashboard with:
jobrunr:
dashboard:
enabled: true
port: 8000The dashboard shows:
- Jobs by state (Enqueued, Processing, Succeeded, Failed, Scheduled)
- Recurring Jobs with their schedules and next run times
- Servers — connected background job server instances
- Progress bars and logs for running jobs (from
context.logger()andcontext.progressBar())
This demo uses H2. For production, switch to a persistent database:
// build.gradle
runtimeOnly 'org.postgresql:postgresql'# application.yml
environments:
production:
dataSource:
dbCreate: none
url: jdbc:postgresql://localhost:5432/myapp
driverClassName: org.postgresql.Driver
username: myuser
password: mypassJobRunr supports PostgreSQL, MySQL/MariaDB, Oracle, SQL Server, and MongoDB.
The dashboard is unauthenticated by default and binds to all interfaces on port 8000. The single most common JobRunr production footgun is leaving it exposed. Either disable it:
jobrunr:
dashboard:
enabled: falseOr enable HTTP Basic Auth:
jobrunr:
dashboard:
enabled: true
port: 8000
username: ${JOBRUNR_DASHBOARD_USER}
password: ${JOBRUNR_DASHBOARD_PASSWORD}A reverse proxy (nginx, Caddy, an ingress controller) doing TLS termination + auth in front of port 8000 is the more robust pattern.
The demo's GSP forms do not enable CSRF tokens because the demo has no Spring Security on the classpath. Production apps should add the Spring Security Core plugin and use Spring Security's CSRF integration with <g:form>, or fall back to the legacy <g:form useToken="true"> + withForm controller pattern.
Prefer @GrailsCompileStatic over plain @CompileStatic for Grails artefacts (controllers and services) - it understands Grails-specific dynamic methods (params, flash, redirect, respond, GORM dynamic finders) that plain static compilation rejects.
This demo applies @GrailsCompileStatic to controllers and to the simpler services. Two services that use heavy GORM DSLs (createCriteria { projections { ... } } in ReportGenerationService, where { ... }.deleteAll() in DataCleanupService) are left without it - those DSLs do not survive static compilation. Verify in your build before adding the annotation; do not assume it is safe.
Avoid static compilation on domain classes: GORM injects save(), get(), finders, withTransaction, and so on at runtime via AST transforms, and the trade-offs are not worth the friction.
A quick-reference of every Grails-specific pitfall covered in this guide:
| Pitfall | Symptom | Fix |
|---|---|---|
Groovy closures used with JobScheduler |
IllegalArgumentException: Please provide a lambda expression |
Use JobRequestScheduler with JobRequest objects |
Missing grails-data-hibernate5 dependency |
GORM has not been initialized correctly |
Add both grails-data-hibernate5 AND grails-data-hibernate5-spring-boot |
Missing @Import(HibernateGormAutoConfiguration) |
GORM has not been initialized correctly |
Add to Application.groovy |
Missing @ComponentScan for src/main/groovy |
NoSuchBeanDefinitionException for @Component classes |
Add @ComponentScan('your.package') to Application.groovy |
No StorageProvider bean |
No qualifying bean of type StorageProvider |
Create one in a @Configuration class with @Import |
JobMapper not set on StorageProvider |
NullPointerException in RecurringJobTable |
Call provider.setJobMapper(...) when creating the StorageProvider bean |
H2 without DB_CLOSE_DELAY=-1 |
Table JOBRUNR_RECURRING_JOBS not found (this database is empty) |
Add DB_CLOSE_DELAY=-1 to the JDBC URL |
Domain class named Order |
DDL error on DROP TABLE order |
Add static mapping = { table 'customer_order' } |
@Recurring not discovered |
No recurring jobs in dashboard | Set static lazyInit = false on the service |
@Job(name = "...%0") on run() method |
Job name shows request object's toString() |
Use a fixed name without %0 on run() methods |
JobRequestHandler.jobContext() |
@Deprecated in 8.x |
Use ThreadLocalJobContext.getJobContext() |
new JobDashboardLogger(context) |
MissingMethodException - constructor takes Job not JobContext |
Use context.logger() instead |
progressBar.increaseByOne() |
MissingMethodException |
Renamed to progressBar.incrementSucceeded() in 8.x |
jobLogger.info("text {}", arg) |
MissingMethodException - only info(String) exists |
Use Groovy interpolation: jobLogger.info("text ${arg}") |
withNewSession in job filters |
no transaction is in progress |
Use withNewTransaction instead |
| Job filters silently ignored | Filter bean exists but onStateApplied never called |
Register filters on BackgroundJobServer via setJobFilters() |
| GORM writes inside a filter | OSS warns: "has slow performance of N ms (a Job Filter should run under 10ms)" | Keep filter work minimal, or offload heavy work to a child job (Pro has async filters) |
@CompileStatic on a domain class |
GORM dynamic methods (save, get, finders) fail to compile |
Use @GrailsCompileStatic on services/controllers instead; never on domains |
Missing static allowedMethods on a controller with state-changing actions |
GET /controller/save mutates data |
Declare static allowedMethods = [save: 'POST', ...] |
Order.list(max: 1).first() on an empty table |
NoSuchElementException from List.first() |
Use Order.first() (GORM dynamic finder, returns null) |
| Dashboard exposed on port 8000 with no auth | Anyone on the network can trigger jobs | Set jobrunr.dashboard.username / password, or front it with a reverse proxy |