u/JobRunrHQ

Why `BackgroundJob.enqueue(() -> ...)` does not compile to what you think it does in Groovy, and what to do about it
▲ 2 r/groovy

Why `BackgroundJob.enqueue(() -> ...)` does not compile to what you think it does in Groovy, and what to do about it

Groovy folks, this one is a bytecode story that came out of writing a Grails + JobRunr integration guide. I think it is interesting even if you never touch background jobs, because it is a clean example of where Groovy closures and Java SAM lambdas diverge.

The setup

JobRunr is an open-source background job library for Java. Its headline API takes a lambda:

BackgroundJob.enqueue(() -> myService.processOrder(orderId));

The trick under the hood: JobRunr uses ASM to walk the lambda's bytecode, find the method invocation inside it, capture the argument values, and persist that as a serialisable job description. When a worker picks the job up later, it reconstructs the call.

This relies on the lambda compiling to a specific bytecode shape, the Java SAM lambda shape: an invokedynamic bound to LambdaMetafactory, producing a tiny synthetic class that implements the functional interface.

The Groovy problem

Groovy closures look syntactically similar:

BackgroundJob.enqueue { myService.processOrder(orderId) }
// or
BackgroundJob.enqueue(() -> myService.processOrder(orderId))

But Groovy compiles both forms to groovy.lang.Closure subclasses, not to a SAM-implementing synthetic. Even when you use the arrow syntax that Groovy 3+ supports, the result is still a Closure, not an invokedynamic-produced SAM. (Yes, there is some SAM-coercion at call sites for certain Java APIs, but it produces a different bytecode shape than javac does for the same source.)

JobRunr's ASM walker hits this, fails to recognise the shape, and throws IllegalArgumentException: Please provide a lambda expression. At runtime, not compile time, which is what makes it confusing the first time.

There is no language-level fix

You cannot make Groovy produce a javac-style SAM lambda from a closure. @CompileStatic does not help here. The closure-to-SAM coercion that exists in some Groovy code paths happens at the boundary into Java code, after the bytecode JobRunr is trying to analyse.

The pragmatic fix

JobRunr ships a second API specifically for languages where its lambda walker does not work: the JobRequest + JobRequestHandler pair.

class OrderJobRequest implements JobRequest {
    Long orderId
    OrderJobRequest() {}
    OrderJobRequest(Long orderId) { this.orderId = orderId }
    @Override Class<OrderProcessingService> getJobRequestHandler() { OrderProcessingService }
}

class OrderProcessingService implements JobRequestHandler<OrderJobRequest> {
    @Override
    void run(OrderJobRequest request) {
        // your code
    }
}

jobRequestScheduler.enqueue(new OrderJobRequest(orderId))

The request is a small serialisable value object. The handler is a regular Spring bean (or a Grails service). JobRunr serialises the request, picks it up on any worker, dispatches it back to the handler. No bytecode walking required.

Takeaway

If you are integrating any Java library from Groovy that uses ASM or similar bytecode analysis on a lambda (Mockito's argThat, some assertion libraries, some lazy-evaluation tricks), keep this in mind.

If you want the full Grails-flavoured walkthrough (GORM DataSource wiring, @Recurring, progress bars, retries, all the production gotchas), the guide is here: https://www.jobrunr.io/en/guides/jvm-frameworks/grails/

Demo repo with everything wired up: https://github.com/jobrunr/example-grails

u/JobRunrHQ — 22 hours ago
▲ 3 r/grails

New guide: integrating JobRunr (background jobs + dashboard) with Grails 8

We just published a guide on getting JobRunr running on Grails 8, and figured this is the right place to share it. For anyone who has not run into it: JobRunr is an open-source background job library for Java, similar in spirit to Hangfire on .NET. One dependency, jobs stored in your existing database, built-in dashboard, no extra infrastructure.

The Grails integration is interesting because, on paper, "Grails runs on Spring Boot, JobRunr has a Spring Boot starter, done." In practice there are two real gotchas worth knowing about even if you do not end up using JobRunr.

1. GORM owns the DataSource

Grails configures its DataSource through the dataSource: block in application.yml, not via the spring.datasource.* keys Spring Boot's DataSourceAutoConfiguration looks at. GORM registers the DataSource late (via beanFactory.registerSingleton(...) inside HibernateGormAutoConfiguration), not as a normal @Bean. Combined with Spring Boot 4's stricter conditional evaluation, JobRunr's auto-configured StorageProvider (which depends on @ConditionalOnBean(DataSource.class)) does not always wire itself up.

The fix is a 5-line @Configuration class that registers the StorageProvider yourself from the GORM-managed DataSource. Reliable, explicit, no surprises.

2. Groovy closures are not Java SAM lambdas

JobRunr's headline API is BackgroundJob.enqueue(() -> myService.process(id)). It uses ASM to reconstruct the method call from the lambda's bytecode. Groovy closures compile to a different bytecode shape, so this throws IllegalArgumentException: Please provide a lambda expression at runtime. There is no language-level workaround.

JobRunr ships a second API for exactly this: the JobRequest + JobRequestHandler pair. The request is a serialisable value object that names the work, the handler is the Spring-managed bean (or a regular Grails service with @Transactional from grails.gorm.transactions). Works fine from Groovy and is actually a cleaner pattern for anything non-trivial.

What the guide covers

  • Adding the right starter (Spring Boot 4 starter for Grails 8, Spring Boot 3 starter for Grails 7.x)
  • Wiring the StorageProvider, including the eager setJobMapper(...) call needed for @Recurring post-processing
  • application.yml setup with the H2 DB_CLOSE_DELAY=-1 pitfall (otherwise migrations run and then the tables vanish before your first job)
  • Fire-and-forget order processing as the worked example
  • Scheduled, recurring (both @Recurring annotation and programmatic from BootStrap.groovy), retries with exponential backoff, and a progress-bar example
  • A static lazyInit = false gotcha for services with @Recurring methods
  • Production notes (persistent DB, dashboard auth, schema migration control)

The full runnable demo is at https://github.com/jobrunr/jobrunr/example-grails. Six end-to-end patterns plus a reference table of every Grails-specific pitfall.

Guide: https://www.jobrunr.io/en/guides/jvm-frameworks/grails/

Happy to answer questions. Curious also if anyone is running JobRunr on Grails in production already, and what storage backend you ended up with.

reddit.com
u/JobRunrHQ — 23 hours ago

How we turned a 40+ minute startup query into 5 seconds on a 10-schema, 80k-table Postgres setup

Story behind a fix that just shipped in JobRunr 8.6.0 that other folks doing JDBC-side table validation might find useful.

A user reported that on their Postgres setup (10 schemas, ~8000 tables each, so roughly 80k tables total) just validating which JobRunr tables existed during application startup was taking over 40 minutes.

The offending code was straightforward:

ResultSet tables = conn.getMetaData().getTables(catalog, null, "%", null);

That "%" pattern pulls metadata for every table in every schema in the catalog. On a small Postgres database it's instant but on a database with tens of thousands of tables, it's a pg_class / pg_namespace scan can take a while...

The fix: filter by name pattern at the metadata-query level instead. We let DatabaseMetaData tell us how identifiers are stored (upper / lower / mixed case) and pass a narrowed pattern:

DatabaseMetaData md = conn.getMetaData();
String pattern = "%";
if (md.storesMixedCaseIdentifiers()) pattern = "%";
else if (md.storesUpperCaseIdentifiers()) pattern = "%JOBRUNR%";
else if (md.storesLowerCaseIdentifiers()) pattern = "%jobrunr%";
ResultSet tables = md.getTables(catalog, null, pattern, null);

On the same 80k-table Postgres setup, validation went from 40+ minutes to under 5 seconds.

The identifier-casing dance matters because Postgres folds unquoted identifiers to lowercase, Oracle / DB2 fold to uppercase, and mixed-case databases like MS SQL keep them as written. A blanket %JOBRUNR% pattern would miss tables on Postgres, and %jobrunr% would miss them on Oracle.

For anyone hitting similar slowness on getMetaData().getTables() in their own apps, this is the lever to pull.

PR: https://github.com/jobrunr/jobrunr/pull/1539
Original issue with the user's reproduction: https://github.com/jobrunr/jobrunr/issues/1538

(JobRunr is an open-source background job library for Java if you haven't heard of it. This post isn't really about JobRunr, just a Postgres / JDBC startup-perf pattern that's worth sharing.)

reddit.com
u/JobRunrHQ — 5 days ago
▲ 13 r/quarkus

JobRunr 8.6.0 ships with official Quarkus 3.33 LTS support

For anyone running JobRunr on Quarkus: 8.6.0 just shipped with official support for the new 3.33 LTS line. You can upgrade Quarkus and JobRunr together without compatibility surprises.

Beyond the Quarkus integration update, a few other things in 8.6.0 worth flagging:

  • JDK 26 compatibility (works with the strict --illegal-final-field-mutation=deny flag, so the JVM upgrade path is clear)
  • SQL table validation on startup: a user reported it taking 40+ minutes on a DB with 10 schemas × 8000 tables, we fixed it down to ~5 seconds by filtering getTables for %jobrunr% instead of pulling every table from every schema
  • Recurring jobs throughput back to historical levels (single MAX query instead of ORDER BY + LIMIT)
  • withDetailswithJobLambda rename on the Fluent API (old name deprecated, still works)
  • Whitespace-preserving job logs in the dashboard

JobRunr Pro 8.6.0 also adds OpenID PKCE, External Job timeouts, and a TrimExceptionFilter for redacting / normalizing exceptions before they hit storage.

For anyone unfamiliar: JobRunr is an open-source background job library for Java (Quarkus, Spring Boot, Micronaut) with a built-in dashboard, 8 supported databases out of the box, carbon-aware scheduling, and a Pro edition with stuff like batches, queues, and external jobs.

Release Blogpost with code-examples: https://www.jobrunr.io/en/blog/jobrunr-v8.6.0/
GitHub: https://github.com/jobrunr/jobrunr/releases/tag/v8.6.0
Quarkus extension: https://search.maven.org/artifact/org.jobrunr/quarkus-jobrunr

jobrunr.io
u/JobRunrHQ — 5 days ago
▲ 17 r/Kotlin+1 crossposts

JobRunr 8.6.0: now starts on ApplicationReadyEvent, JDK 26 compatible, faster SQL validation

Quick heads-up for the Spring Boot crowd: JobRunr 8.6.0 just shipped and the Spring Boot starter (both 3.x and 4.x) now boots the Background Job Server and Dashboard on ApplicationReadyEvent instead of SmartInitializingSingleton.afterSingletonsInstantiated(). That means JobRunr only starts polling once your application context is fully initialized, which avoids a whole class of subtle startup races where a job ran before its dependencies were ready.

Also in this release:

  • JDK 26 compatibility (works with --illegal-final-field-mutation=deny)
  • Quarkus 3.33 LTS support (yes, also relevant if you run a hybrid stack)
  • 40+ minute → 5 second startup on databases with thousands of tables
  • Recurring job lookup uses a single MAX query, throughput back to historical levels
  • withDetailswithJobLambda rename for the Fluent API (old name deprecated, still works)
  • Job logs in the dashboard preserve whitespace now

Release blogpost with code-examples: https://www.jobrunr.io/en/blog/jobrunr-v8.6.0/

github.com
u/JobRunrHQ — 5 days ago