From 691ac2bac3a09d9409b06d14ec254df826a103cf Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 07:25:32 -0400 Subject: [PATCH 1/7] Add Grails 8 JobRunr guide Assisted-by: opencode:gpt-5.6-sol --- conf/guides.yml | 48 +++++++++++++ .../v8/guide/dependencySetup.adoc | 33 +++++++++ .../v8/guide/durableEnqueue.adoc | 31 +++++++++ .../v8/guide/gettingStarted.adoc | 13 ++++ .../v8/guide/helpWithGrails.adoc | 1 + guides/grails-jobrunr/v8/guide/howto.adoc | 10 +++ guides/grails-jobrunr/v8/guide/jobApis.adoc | 41 +++++++++++ .../grails-jobrunr/v8/guide/jobRequests.adoc | 28 ++++++++ .../grails-jobrunr/v8/guide/operations.adoc | 25 +++++++ .../grails-jobrunr/v8/guide/requirements.adoc | 8 +++ .../v8/guide/runningTheApp.adoc | 29 ++++++++ .../v8/guide/storageConfiguration.adoc | 29 ++++++++ guides/grails-jobrunr/v8/guide/testing.adoc | 47 +++++++++++++ .../v8/snippets/complete/build.gradle | 38 ++++++++++ .../v8/snippets/complete/gradle.properties | 4 ++ .../complete/grails-app/conf/application.yml | 64 +++++++++++++++++ .../grails-app/conf/spring/resources.groovy | 5 ++ .../grails/JobExamplesController.groovy | 27 ++++++++ .../example/grails/UrlMappings.groovy | 12 ++++ .../init/example/grails/Application.groovy | 16 +++++ .../example/grails/DeliveryService.groovy | 23 +++++++ .../example/grails/JobExamplesService.groovy | 34 +++++++++ .../grails/DeliveryCreatedEvent.groovy | 12 ++++ .../example/grails/DeliveryJobEnqueuer.groovy | 21 ++++++ .../jobrunr/DeliveryJobRequestHandler.groovy | 47 +++++++++++++ .../jobrunr/JobRunrStorageConfig.groovy | 29 ++++++++ .../jobrunr/ProcessDeliveryJobRequest.groovy | 21 ++++++ .../jobrunr/RetryDeliveryJobRequest.groovy | 21 ++++++ .../RetryDeliveryJobRequestHandler.groovy | 16 +++++ .../example/grails/DeliveryHttpApiSpec.groovy | 69 +++++++++++++++++++ .../grails/DeliveryJobEnqueuerSpec.groovy | 32 +++++++++ .../grails/JobRunrIntegrationSpec.groovy | 67 ++++++++++++++++++ .../grails/jobrunr/JobRequestSpec.groovy | 21 ++++++ .../jobrunr/JobRunrStorageConfigSpec.groovy | 45 ++++++++++++ .../v8/snippets/initial/build.gradle | 31 +++++++++ .../v8/snippets/initial/gradle.properties | 3 + 36 files changed, 1001 insertions(+) create mode 100644 guides/grails-jobrunr/v8/guide/dependencySetup.adoc create mode 100644 guides/grails-jobrunr/v8/guide/durableEnqueue.adoc create mode 100644 guides/grails-jobrunr/v8/guide/gettingStarted.adoc create mode 100644 guides/grails-jobrunr/v8/guide/helpWithGrails.adoc create mode 100644 guides/grails-jobrunr/v8/guide/howto.adoc create mode 100644 guides/grails-jobrunr/v8/guide/jobApis.adoc create mode 100644 guides/grails-jobrunr/v8/guide/jobRequests.adoc create mode 100644 guides/grails-jobrunr/v8/guide/operations.adoc create mode 100644 guides/grails-jobrunr/v8/guide/requirements.adoc create mode 100644 guides/grails-jobrunr/v8/guide/runningTheApp.adoc create mode 100644 guides/grails-jobrunr/v8/guide/storageConfiguration.adoc create mode 100644 guides/grails-jobrunr/v8/guide/testing.adoc create mode 100644 guides/grails-jobrunr/v8/snippets/complete/build.gradle create mode 100644 guides/grails-jobrunr/v8/snippets/complete/gradle.properties create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/application.yml create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/spring/resources.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/JobExamplesController.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/UrlMappings.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/init/example/grails/Application.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/DeliveryService.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/JobExamplesService.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryCreatedEvent.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy create mode 100644 guides/grails-jobrunr/v8/snippets/initial/build.gradle create mode 100644 guides/grails-jobrunr/v8/snippets/initial/gradle.properties diff --git a/conf/guides.yml b/conf/guides.yml index 94d905d3d51..5bb87d45db3 100644 --- a/conf/guides.yml +++ b/conf/guides.yml @@ -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' diff --git a/guides/grails-jobrunr/v8/guide/dependencySetup.adoc b/guides/grails-jobrunr/v8/guide/dependencySetup.adoc new file mode 100644 index 00000000000..b8511948bba --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/dependencySetup.adoc @@ -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[] +---- diff --git a/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc b/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc new file mode 100644 index 00000000000..0ff3e184b2a --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc @@ -0,0 +1,31 @@ +The job must not run for a delivery that rolls back. 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. diff --git a/guides/grails-jobrunr/v8/guide/gettingStarted.adoc b/guides/grails-jobrunr/v8/guide/gettingStarted.adoc new file mode 100644 index 00000000000..975737db49c --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/gettingStarted.adoc @@ -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. diff --git a/guides/grails-jobrunr/v8/guide/helpWithGrails.adoc b/guides/grails-jobrunr/v8/guide/helpWithGrails.adoc new file mode 100644 index 00000000000..e062f614b1a --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/helpWithGrails.adoc @@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[] diff --git a/guides/grails-jobrunr/v8/guide/howto.adoc b/guides/grails-jobrunr/v8/guide/howto.adoc new file mode 100644 index 00000000000..24f457cf13b --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/howto.adoc @@ -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. diff --git a/guides/grails-jobrunr/v8/guide/jobApis.adoc b/guides/grails-jobrunr/v8/guide/jobApis.adoc new file mode 100644 index 00000000000..ff4f9ff9339 --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/jobApis.adoc @@ -0,0 +1,41 @@ +`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[] +---- + +The controller and URL mappings expose these 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. diff --git a/guides/grails-jobrunr/v8/guide/jobRequests.adoc b/guides/grails-jobrunr/v8/guide/jobRequests.adoc new file mode 100644 index 00000000000..138b012e219 --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/jobRequests.adoc @@ -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. diff --git a/guides/grails-jobrunr/v8/guide/operations.adoc b/guides/grails-jobrunr/v8/guide/operations.adoc new file mode 100644 index 00000000000..4e16a4803ad --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/operations.adoc @@ -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. diff --git a/guides/grails-jobrunr/v8/guide/requirements.adoc b/guides/grails-jobrunr/v8/guide/requirements.adoc new file mode 100644 index 00000000000..d1345dccece --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/requirements.adoc @@ -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. diff --git a/guides/grails-jobrunr/v8/guide/runningTheApp.adoc b/guides/grails-jobrunr/v8/guide/runningTheApp.adoc new file mode 100644 index 00000000000..57af4c65898 --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/runningTheApp.adoc @@ -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]. diff --git a/guides/grails-jobrunr/v8/guide/storageConfiguration.adoc b/guides/grails-jobrunr/v8/guide/storageConfiguration.adoc new file mode 100644 index 00000000000..e793d0f4737 --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/storageConfiguration.adoc @@ -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[] +---- diff --git a/guides/grails-jobrunr/v8/guide/testing.adoc b/guides/grails-jobrunr/v8/guide/testing.adoc new file mode 100644 index 00000000000..064c9109791 --- /dev/null +++ b/guides/grails-jobrunr/v8/guide/testing.adoc @@ -0,0 +1,47 @@ +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: + +[source,groovy] +.src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy +---- +include::../snippets/complete/src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy[] +---- + +The listener test checks that the production wiring keeps the decisive `AFTER_COMMIT` phase and submits the expected request: + +[source,groovy] +.src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy +---- +include::../snippets/complete/src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy[] +---- + +The storage configuration test verifies that the configured table prefix is used and that `skip-create` leaves an empty data source without JobRunr tables: + +[source,groovy] +.src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy +---- +include::../snippets/complete/src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy[] +---- + +The HTTP integration test discovers the controller action and URL mapping, then sends a real request to the embedded server: + +[source,groovy] +.src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy +---- +include::../snippets/complete/src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy[] +---- + +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: + +[source,groovy] +.src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy +---- +include::../snippets/complete/src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy[] +---- + +Run the complete suite with: + +[source,bash] +---- +cd complete +./gradlew test +---- diff --git a/guides/grails-jobrunr/v8/snippets/complete/build.gradle b/guides/grails-jobrunr/v8/snippets/complete/build.gradle new file mode 100644 index 00000000000..9966e1f1e2e --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/build.gradle @@ -0,0 +1,38 @@ +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 +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/gradle.properties b/guides/grails-jobrunr/v8/snippets/complete/gradle.properties new file mode 100644 index 00000000000..7e8e599c9dd --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/gradle.properties @@ -0,0 +1,4 @@ +grailsVersion=8.0.0-M5 +jobrunrVersion=8.8.1 +org.gradle.caching=true +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1g diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/application.yml b/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/application.yml new file mode 100644 index 00000000000..a1bb67e8673 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/application.yml @@ -0,0 +1,64 @@ +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 diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/spring/resources.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/spring/resources.groovy new file mode 100644 index 00000000000..8d24ca18301 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/conf/spring/resources.groovy @@ -0,0 +1,5 @@ +import org.springframework.transaction.event.TransactionalEventListenerFactory + +beans = { + transactionalEventListenerFactory(TransactionalEventListenerFactory) +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/JobExamplesController.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/JobExamplesController.groovy new file mode 100644 index 00000000000..869d55f1fe5 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/JobExamplesController.groovy @@ -0,0 +1,27 @@ +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) + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/UrlMappings.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/UrlMappings.groovy new file mode 100644 index 00000000000..ef93666d143 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/UrlMappings.groovy @@ -0,0 +1,12 @@ +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') + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/init/example/grails/Application.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/init/example/grails/Application.groovy new file mode 100644 index 00000000000..d4386a8ac1e --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/init/example/grails/Application.groovy @@ -0,0 +1,16 @@ +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) + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/DeliveryService.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/DeliveryService.groovy new file mode 100644 index 00000000000..1a77dd7bb20 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/DeliveryService.groovy @@ -0,0 +1,23 @@ +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 + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/JobExamplesService.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/JobExamplesService.groovy new file mode 100644 index 00000000000..04cee4740b2 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/services/example/grails/JobExamplesService.groovy @@ -0,0 +1,34 @@ +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() + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryCreatedEvent.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryCreatedEvent.groovy new file mode 100644 index 00000000000..cbb46795098 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryCreatedEvent.groovy @@ -0,0 +1,12 @@ +package example.grails + +import groovy.transform.CompileStatic + +@CompileStatic +class DeliveryCreatedEvent { + final Long deliveryId + + DeliveryCreatedEvent(Long deliveryId) { + this.deliveryId = deliveryId + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy new file mode 100644 index 00000000000..49c6932d594 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/DeliveryJobEnqueuer.groovy @@ -0,0 +1,21 @@ +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)) + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy new file mode 100644 index 00000000000..d6dd68e06ea --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/DeliveryJobRequestHandler.groovy @@ -0,0 +1,47 @@ +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 { + @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 + } + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy new file mode 100644 index 00000000000..31453548bd4 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/JobRunrStorageConfig.groovy @@ -0,0 +1,29 @@ +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 + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy new file mode 100644 index 00000000000..0f40d924ec9 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/ProcessDeliveryJobRequest.groovy @@ -0,0 +1,21 @@ +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 getJobRequestHandler() { + DeliveryJobRequestHandler + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy new file mode 100644 index 00000000000..fa4dd32f139 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequest.groovy @@ -0,0 +1,21 @@ +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 getJobRequestHandler() { + RetryDeliveryJobRequestHandler + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy new file mode 100644 index 00000000000..6509e648e4c --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy @@ -0,0 +1,16 @@ +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 { + @Override + @Job(name = 'Retry delivery example', retries = 2, labels = ['delivery', 'retry']) + void run(RetryDeliveryJobRequest request) { + throw new IllegalStateException("Retry example for delivery ${request.deliveryId}") + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy new file mode 100644 index 00000000000..fa9c97aa522 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryHttpApiSpec.groovy @@ -0,0 +1,69 @@ +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() + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy new file mode 100644 index 00000000000..ccc72d68c48 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/DeliveryJobEnqueuerSpec.groovy @@ -0,0 +1,32 @@ +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 }) + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy new file mode 100644 index 00000000000..83098193aee --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/JobRunrIntegrationSpec.groovy @@ -0,0 +1,67 @@ +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 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) + } + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy new file mode 100644 index 00000000000..921d50b45d1 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRequestSpec.groovy @@ -0,0 +1,21 @@ +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 + } +} diff --git a/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy new file mode 100644 index 00000000000..0da0d316de2 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/src/test/groovy/example/grails/jobrunr/JobRunrStorageConfigSpec.groovy @@ -0,0 +1,45 @@ +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() + } + } +} diff --git a/guides/grails-jobrunr/v8/snippets/initial/build.gradle b/guides/grails-jobrunr/v8/snippets/initial/build.gradle new file mode 100644 index 00000000000..eed896bedb3 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/initial/build.gradle @@ -0,0 +1,31 @@ +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() +} diff --git a/guides/grails-jobrunr/v8/snippets/initial/gradle.properties b/guides/grails-jobrunr/v8/snippets/initial/gradle.properties new file mode 100644 index 00000000000..3532b99e899 --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/initial/gradle.properties @@ -0,0 +1,3 @@ +grailsVersion=8.0.0-M5 +org.gradle.caching=true +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1g From 53c08c0b7669f75bbdda24b1674d0dcf1715827e Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 07:51:54 -0400 Subject: [PATCH 2/7] Fix guide download and clone controls Assisted-by: opencode:gpt-5.6-sol --- guides/resources/style/guideItem.html | 32 +++++++++++++++------------ guides/resources/style/layout.html | 6 ++--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/guides/resources/style/guideItem.html b/guides/resources/style/guideItem.html index 7510570766e..37ee4307b57 100644 --- a/guides/resources/style/guideItem.html +++ b/guides/resources/style/guideItem.html @@ -4,10 +4,12 @@ ${sectionNumber} ${title.encodeAsHtml()} + + diff --git a/guides/resources/style/layout.html b/guides/resources/style/layout.html index 6f184eed8b6..024e1173aa5 100644 --- a/guides/resources/style/layout.html +++ b/guides/resources/style/layout.html @@ -18,13 +18,13 @@
  • - -
    - + <% if(toc){ %>

    Table of Contents

    From e5009f3e25bddf0a5886828c59221a5b1896aa88 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 07:57:02 -0400 Subject: [PATCH 3/7] Improve guide renderer accessibility and responsiveness Assisted-by: opencode:gpt-5.6-sol --- guides/resources/css/guide.css | 161 ++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 2 deletions(-) diff --git a/guides/resources/css/guide.css b/guides/resources/css/guide.css index 7471d71ca4e..8a34e5232e0 100644 --- a/guides/resources/css/guide.css +++ b/guides/resources/css/guide.css @@ -68,6 +68,23 @@ textarea { vertical-align: middle; } +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +#main pre:focus-visible, +#navigation a:focus-visible { + outline: 3px solid #0969da; + outline-offset: 2px; +} + +#navigation #nav-summary > a:focus-visible { + background: #1f2328; + color: #ffffff; + margin-left: 4px; +} + .input-group { display: table; margin-top: 12px; @@ -325,19 +342,153 @@ body.guide article.guide aside button.btn-large + ul.githublinks + .input-group color: #ffffff; } +#footer { + box-sizing: border-box; +} + .local-title { font-size: 1.4em; font-weight: bold; } +/* ---- Standalone chapter layout -------------------------------------------- */ +body.body { + text-align: left; +} + +#colset { + table-layout: fixed; +} + +#colset #col1 { + min-width: 0; +} + +#main, +#main .listingblock, +#main .listingblock .content { + min-width: 0; + max-width: 100%; +} + +#main h1, +#main h2 { + color: #d97706; +} + +@media screen { + #main pre { + box-sizing: border-box; + contain: paint; + max-width: 100%; + overflow-x: auto; + white-space: pre; + } + + #main .tableblock, + #main .imageblock, + #main .videoblock, + #main .listingblock, + #main .literalblock { + overflow-x: auto; + } +} + +#main .tableblock, +#main .imageblock, +#main .videoblock, +#main .listingblock, +#main .literalblock { + max-width: 100%; +} + +#main img, +#main object, +#main svg { + height: auto; + max-width: 100%; +} + +.chapter-navigation { + display: flex; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.chapter-navigation .toc-item { + float: none; +} + +.chapter-navigation .next-right { + margin-left: auto; + text-align: right; +} + +#nav-summary-childs { + display: none; + box-sizing: border-box; + max-width: calc(100vw - 32px); +} + +#nav-summary:hover #nav-summary-childs, +#nav-summary:focus-within #nav-summary-childs { + display: block; +} + +.downloadButton { + display: inline-block; + color: #24292f !important; + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + padding: 7px 12px; + text-decoration: none; +} + +.downloadButton:hover { + background: #f6f8fa; +} + /* ---- Responsive: hide aside on small screens ----------------------------- */ -@media only screen and (max-device-width: 480px) and (orientation: portrait) { +@media only screen and (max-width: 768px) { + body.body { + text-align: left; + } + + .chapter-navigation { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .chapter-navigation .next-right { + margin-left: 0; + text-align: left; + } + + .contribute-btn { + clear: both; + float: none; + margin: 8px 0 16px; + position: static; + top: auto; + } + + #colset, + #colset tbody, + #colset tr, + #colset #col1 { + display: block; + width: 100%; + } #colset #col1 { margin: 0; padding: 0; } #main { - width: 98%; + box-sizing: border-box; + width: auto; margin: 0; border: 0; } @@ -345,3 +496,9 @@ body.guide article.guide aside button.btn-large + ul.githublinks + .input-group display: none; } } + +@media only screen and (min-width: 481px) and (max-width: 900px) { + body.guide article.guide > header { + padding-right: 72px; + } +} From 10fe9eb76dc9c78711e9b4c26596dbd1f75897d0 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 08:23:15 -0400 Subject: [PATCH 4/7] Preserve code blocks in printed guides Assisted-by: opencode:gpt-5.6-sol --- guides/resources/css/guide.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/guides/resources/css/guide.css b/guides/resources/css/guide.css index 8a34e5232e0..0d62fade68e 100644 --- a/guides/resources/css/guide.css +++ b/guides/resources/css/guide.css @@ -394,6 +394,22 @@ body.body { } } +@media print { + body.body .listingblock pre, + body.body .listingblock pre code, + body.body .literalblock pre, + body.body .literalblock pre code, + body.guide .listingblock pre, + body.guide .listingblock pre code, + body.guide .literalblock pre, + body.guide .literalblock pre code { + contain: none; + overflow-x: visible !important; + overflow-wrap: anywhere; + white-space: pre-wrap; + } +} + #main .tableblock, #main .imageblock, #main .videoblock, From 38fd5a5d148ae5822eee20dd8a1ef2c56caedff0 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 08:59:51 -0400 Subject: [PATCH 5/7] Document the JobRunr delivery model Assisted-by: opencode:gpt-5.6-sol --- guides/grails-jobrunr/v8/guide/durableEnqueue.adoc | 10 +++++++++- .../domain/example/grails/Delivery.groovy | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/domain/example/grails/Delivery.groovy diff --git a/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc b/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc index 0ff3e184b2a..c0c0b91887e 100644 --- a/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc +++ b/guides/grails-jobrunr/v8/guide/durableEnqueue.adoc @@ -1,4 +1,12 @@ -The job must not run for a delivery that rolls back. The service first persists the delivery and publishes a small event within its GORM transaction: +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 diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/domain/example/grails/Delivery.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/domain/example/grails/Delivery.groovy new file mode 100644 index 00000000000..fe32c43278f --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/domain/example/grails/Delivery.groovy @@ -0,0 +1,14 @@ +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 + } +} From 3f3c26f158cff6a95430d4652edfe537fe6e8450 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 09:04:30 -0400 Subject: [PATCH 6/7] Document the JobRunr delivery API Assisted-by: opencode:gpt-5.6-sol --- guides/grails-jobrunr/v8/guide/jobApis.adoc | 10 ++++++- .../example/grails/DeliveryController.groovy | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/DeliveryController.groovy diff --git a/guides/grails-jobrunr/v8/guide/jobApis.adoc b/guides/grails-jobrunr/v8/guide/jobApis.adoc index ff4f9ff9339..463b5ee340d 100644 --- a/guides/grails-jobrunr/v8/guide/jobApis.adoc +++ b/guides/grails-jobrunr/v8/guide/jobApis.adoc @@ -24,7 +24,15 @@ include::../snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDelive include::../snippets/complete/src/main/groovy/example/grails/jobrunr/RetryDeliveryJobRequestHandler.groovy[] ---- -The controller and URL mappings expose these examples as `202 Accepted` operations: +`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 diff --git a/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/DeliveryController.groovy b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/DeliveryController.groovy new file mode 100644 index 00000000000..ce62994297e --- /dev/null +++ b/guides/grails-jobrunr/v8/snippets/complete/grails-app/controllers/example/grails/DeliveryController.groovy @@ -0,0 +1,29 @@ +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) + } +} From d618f42460b9497982db30c3c66c2277747df7b8 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Wed, 12 Aug 2026 09:47:59 -0400 Subject: [PATCH 7/7] Prevent TOC focus layout shift Assisted-by: opencode:gpt-5.6-sol --- guides/resources/css/guide.css | 1 - 1 file changed, 1 deletion(-) diff --git a/guides/resources/css/guide.css b/guides/resources/css/guide.css index 0d62fade68e..e76a9d7eef5 100644 --- a/guides/resources/css/guide.css +++ b/guides/resources/css/guide.css @@ -82,7 +82,6 @@ textarea:focus-visible, #navigation #nav-summary > a:focus-visible { background: #1f2328; color: #ffffff; - margin-left: 4px; } .input-group {