Skip to content

feat(jobs): client-Java @Scheduled/JobHandler jobs on the shared Quartz scheduler — visible in the Jobs perspective + cluster-safe - #6375

Merged
delchev merged 2 commits into
masterfrom
feat/java-jobs-quartz-visible
Jul 22, 2026
Merged

feat(jobs): client-Java @Scheduled/JobHandler jobs on the shared Quartz scheduler — visible in the Jobs perspective + cluster-safe#6375
delchev merged 2 commits into
masterfrom
feat/java-jobs-quartz-visible

Conversation

@delchev

@delchev delchev commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Client-Java scheduled jobs — a @Component implementing JobHandler (cron()/run()) or a @Scheduled-annotated method — were scheduled by ScheduledClassConsumer on a private in-JVM Spring ThreadPoolTaskScheduler. They executed, but:

  • they were invisible in the Jobs perspective (/services/jobs) — no row, no run log;
  • they were not monitored — no JobLog triggered/finished/failed entries;
  • in a cluster they fired once per node, not once cluster-wide (a private scheduler per JVM), so e.g. a recurring-invoice or cleanup job would double-run.

This diverged from JS .job / scheduled.ts jobs, which register through the Quartz jobs engine and get all of the above.

Fix

Register each client-Java job as a real Job definition (engine java, handler = the client FQN, optionally #method) via JobService + JobsManager on the shared clustered Quartz scheduler, per tenant (mirroring the JS job synchronizer). At fire time the jobs engine dispatches back through a new JavaJobExecutor SPI (implemented in engine-java) to run the client bean, wrapped in the same JobLog as every engine.

Result: client-Java jobs now appear in the Jobs perspective with a run log and fire once cluster-wide.

  • engine-jobs: JavaJobExecutor SPI; a java branch in JobExecutionService (keeps jobs → javascript layering — the impl is looked up via ObjectProvider); JobSynchronizer skips runtime-registered rows (RUNTIME_LOCATION_PREFIX) so they are not reaped as registry orphans.
  • engine-java: depends on engine-jobs; JavaJobExecutorImpl resolves + invokes the client bean (ComponentContainer.instanceOfClassName); ScheduledClassConsumer registers/unregisters Job rows per tenant instead of using a private scheduler.

Test

JavaJobDecoratorSampleProjectIT (self-interface + method-level sample) now additionally asserts the jobs appear in GET /services/jobs with engine java and survive a forced synchronization pass. Green locally: Tests run: 1, Failures: 0.

Notes / follow-ups

  • The SDK javadoc previously said "Quartz cron expression / mirrors org.quartz.Job" — that described only the cron string; the jobs were not on Quartz. Now they are.
  • Cron parser: JobsManager uses Quartz CronScheduleBuilder (7-field Quartz), whereas the old private path used Spring CronTrigger (6-field). Expressions already used the Quartz ? form in samples; worth a line in the SDK docs.

delchev and others added 2 commits July 22, 2026 13:39
…Quartz scheduler - visible + monitored in the Jobs perspective, cluster-safe

Previously ScheduledClassConsumer scheduled client-Java jobs on a private
in-JVM Spring ThreadPoolTaskScheduler: they executed but were invisible to
the Jobs perspective/job log and fired once PER NODE in a cluster - unlike JS
.job/scheduled.ts jobs, which register through the Quartz jobs engine.

Now each JobHandler bean / @scheduled method is registered as a real Job
definition (engine 'java', handler = FQN [+ '#method']) persisted via
JobService and scheduled via JobsManager on the shared clustered scheduler.
At cron time the jobs engine dispatches back through the new JavaJobExecutor
SPI (implemented in engine-java) to run the client bean, wrapped in the same
JobLog triggered/finished/failed as every other engine. Consequences: the
job shows in the Jobs perspective with a run log, and fires once cluster-wide.

- engine-jobs: JavaJobExecutor SPI + a 'java' branch in JobExecutionService;
  JobSynchronizer skips runtime-registered rows (RUNTIME_LOCATION_PREFIX) so
  they are not reaped as registry orphans.
- engine-java: depends on engine-jobs; JavaJobExecutorImpl resolves+invokes
  the client bean; ScheduledClassConsumer registers/unregisters Job rows
  instead of using a private scheduler; ComponentContainer.instanceOfClassName.
- IT: JavaJobDecoratorSampleProjectIT also asserts the jobs appear in
  /services/jobs (engine 'java') and survive a synchronization pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sert around throwing findByName

The registration runs off any tenant thread (class load), so scheduling a
'defined'-group job (which derives a tenant-prefixed name) needs an explicit
tenant context - register/unregister per tenant exactly as the JS .job
synchronizer does, so the job body runs tenant-scoped at fire time. Also
findByName throws (not null) when absent - handle it as a fresh row.

Verified: JavaJobDecoratorSampleProjectIT green - jobs execute, appear in
/services/jobs with engine 'java', and survive a synchronization pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Register one job (a JobHandler class or a single @Scheduled method) as a Job on the shared
* scheduler.
*/
private void register(List<String> sink, String fqn, String handler, String expression) {
@delchev
delchev merged commit 0d01163 into master Jul 22, 2026
10 checks passed
@delchev
delchev deleted the feat/java-jobs-quartz-visible branch July 22, 2026 12:19
delchev added a commit that referenced this pull request Aug 9, 2026
…6625)

#6375 made client-Java `@Scheduled` / `JobHandler` jobs first-class `Job`
definitions on the shared Quartz scheduler, so they are listed, monitored
and manageable in the Jobs perspective like any `.job`. One half of that
was still missing: the Jobs perspective's Trigger action.

`JobService.trigger` — the manual path behind `POST /services/jobs/trigger`
— ran the handler through the JavaScript code runner unconditionally,
ignoring the job's `engine`. A client-Java job's handler is a class name,
not a repository path to a JS module, so triggering one answered 500 while
the same job ran fine on its cron.

The dispatch now lives in exactly one place, `JobHandlerRunner`, used by
both ways a job can start: the scheduled fire (`JobExecutionService`) and
the manual trigger. That is the actual defect — the two dispatches were
written out separately and drifted.

The engine-java guide's job bullet was also still describing the private
`ThreadPoolTaskScheduler` that #6375 replaced; it now records the shared
Quartz registration and this single dispatch point.

Verified against the client-Java job sample: `JavaJobDecoratorSampleProjectIT`
triggers a listed `engine: java` job over REST and fails on the pre-fix code
with "Expected status code <200> but was <500>", passing with the fix.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants