Skip to content

Commit fa0d61a

Browse files
delchevclaude
andauthored
fix(jobs): trigger-now runs a job on the engine it declares (#6305) (#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>
1 parent 0289a90 commit fa0d61a

6 files changed

Lines changed: 180 additions & 32 deletions

File tree

‎components/engine/engine-java/CLAUDE.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,15 @@ instantiate client classes.
9494
- `EntityClassConsumer` (data-store-java) — `@Entity` → `JavaEntityManager` (Hibernate dynamic-map).
9595
- `ControllerClassConsumer` — `@Controller` → `ControllerRouter` + OpenAPI via
9696
`JavaControllerOpenApiPublisher`. (A `@Controller` must not also implement `JavaHandler`.)
97-
- `ScheduledClassConsumer` — jobs (see two styles below) → cron via a dedicated `ThreadPoolTaskScheduler`.
97+
- `ScheduledClassConsumer` — jobs (see two styles below) → a real `Job` row per tenant on the platform's
98+
**shared Quartz scheduler** (#6375), under the synthetic `RUNTIME_LOCATION_PREFIX` location so the job
99+
synchronizer does not reap it as a registry orphan. So a client-Java job is listed, enable/disable-able,
100+
trigger-now-able and job-logged in the Jobs perspective like any `.job`, and fires **once cluster-wide**
101+
— not once per JVM, as the private `ThreadPoolTaskScheduler` this replaced did. At fire time the jobs
102+
engine dispatches back through the `JavaJobExecutor` SPI (engine `java`); both that path and the manual
103+
trigger go through `JobHandlerRunner`, which is the ONLY place the engine→runner dispatch lives —
104+
trigger-now was written out separately once and stayed JavaScript-only, so triggering a client-Java job
105+
ran its class name as a JS path and 500'd (#6305).
98106
- `ListenerClassConsumer` — listeners → ActiveMQ; re-establishes the message's tenant context.
99107
- `WebsocketClassConsumer` + `JavaWebsocketRegistry` — websockets; `WebsocketProcessor`
100108
(`engine-websockets`) calls `JavaWebsocketRegistry.dispatch(...)` reflectively (keeps that module free

‎components/engine/engine-jobs/src/main/java/org/eclipse/dirigible/components/jobs/handler/JobExecutionService.java‎

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,13 @@
1414
import org.eclipse.dirigible.components.jobs.domain.JobLog;
1515
import org.eclipse.dirigible.components.jobs.service.JobLogService;
1616
import org.eclipse.dirigible.components.jobs.tenant.JobNameCreator;
17-
import org.eclipse.dirigible.graalium.core.DirigibleJavascriptCodeRunner;
1817
import org.quartz.JobDataMap;
1918
import org.quartz.JobExecutionContext;
2019
import org.quartz.JobExecutionException;
2120
import org.slf4j.Logger;
2221
import org.slf4j.LoggerFactory;
2322
import org.springframework.stereotype.Service;
2423

25-
import java.nio.file.Path;
2624
import java.util.Date;
2725

2826
@Service
@@ -35,14 +33,14 @@ public class JobExecutionService {
3533
private final JobLogService jobLogService;
3634
private final JobNameCreator jobNameCreator;
3735
private final DataSourcesManager dataSourcesManager;
38-
private final org.springframework.beans.factory.ObjectProvider<JavaJobExecutor> javaJobExecutor;
36+
private final JobHandlerRunner jobHandlerRunner;
3937

4038
JobExecutionService(JobLogService jobLogService, JobNameCreator jobNameCreator, DataSourcesManager dataSourcesManager,
41-
org.springframework.beans.factory.ObjectProvider<JavaJobExecutor> javaJobExecutor) {
39+
JobHandlerRunner jobHandlerRunner) {
4240
this.jobLogService = jobLogService;
4341
this.jobNameCreator = jobNameCreator;
4442
this.dataSourcesManager = dataSourcesManager;
45-
this.javaJobExecutor = javaJobExecutor;
43+
this.jobHandlerRunner = jobHandlerRunner;
4644
}
4745

4846
public void executeJob(JobExecutionContext context) throws JobExecutionException {
@@ -70,21 +68,10 @@ private void executeJobInternal(JobExecutionContext context) throws JobExecution
7068

7169
if (triggered != null) {
7270
context.put("handler", handler);
73-
if (java) {
74-
// Client-Java scheduled job: dispatch to the Java engine's executor (the client
75-
// bean resolved + invoked there). Same JobLog wrapping as the JS path below, so
76-
// it is equally visible/monitored in the Jobs perspective.
77-
JavaJobExecutor executor = javaJobExecutor.getIfAvailable();
78-
if (executor == null) {
79-
throw new IllegalStateException("No JavaJobExecutor is available to run the Java job [" + handler + "]");
80-
}
81-
executor.execute(handler);
82-
} else {
83-
Path handlerPath = Path.of(handler);
84-
try (DirigibleJavascriptCodeRunner runner = new DirigibleJavascriptCodeRunner()) {
85-
runner.run(handlerPath);
86-
}
87-
}
71+
// A client-Java job dispatches to the Java engine's executor, a JS one to the code
72+
// runner - inside the same JobLog wrapping either way, so both are equally
73+
// visible/monitored in the Jobs perspective.
74+
jobHandlerRunner.run(handler, engine);
8875
}
8976

9077
registeredFinished(name, handler, triggered);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.jobs.handler;
11+
12+
import java.nio.file.Path;
13+
14+
import org.eclipse.dirigible.graalium.core.DirigibleJavascriptCodeRunner;
15+
import org.springframework.beans.factory.ObjectProvider;
16+
import org.springframework.stereotype.Component;
17+
18+
/**
19+
* Runs a job's handler on the engine the job declares: {@value JavaJobExecutor#ENGINE_JAVA} routes
20+
* to the client-Java executor, anything else to the JavaScript runner (the handler is then a
21+
* repository path).
22+
*
23+
* <p>
24+
* Shared by both ways a job can start - the scheduled fire ({@link JobExecutionService}) and the
25+
* manual trigger-now ({@code JobService.trigger}) - so a job runs the same way however it was
26+
* started. The two dispatches used to be written out separately and drifted: trigger-now stayed
27+
* JavaScript-only, so triggering a client-Java job from the Jobs perspective tried to run its class
28+
* name as a JavaScript file and failed (dirigible #6305).
29+
*/
30+
@Component
31+
public class JobHandlerRunner {
32+
33+
private final ObjectProvider<JavaJobExecutor> javaJobExecutor;
34+
35+
JobHandlerRunner(ObjectProvider<JavaJobExecutor> javaJobExecutor) {
36+
this.javaJobExecutor = javaJobExecutor;
37+
}
38+
39+
/**
40+
* Run the handler.
41+
*
42+
* @param handler the job's handler - a client-Java FQN (optionally {@code #method}) for the Java
43+
* engine, a repository path to a JavaScript module otherwise
44+
* @param engine the job's engine, or {@code null} for the default JavaScript one
45+
* @throws Exception when the job body throws, so the caller can log the run as failed
46+
*/
47+
public void run(String handler, String engine) throws Exception {
48+
if (JavaJobExecutor.ENGINE_JAVA.equals(engine)) {
49+
JavaJobExecutor executor = javaJobExecutor.getIfAvailable();
50+
if (executor == null) {
51+
throw new IllegalStateException("No JavaJobExecutor is available to run the Java job [" + handler + "]");
52+
}
53+
executor.execute(handler);
54+
return;
55+
}
56+
try (DirigibleJavascriptCodeRunner runner = new DirigibleJavascriptCodeRunner()) {
57+
runner.run(Path.of(handler));
58+
}
59+
}
60+
}

‎components/engine/engine-jobs/src/main/java/org/eclipse/dirigible/components/jobs/service/JobService.java‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,15 @@
1010
package org.eclipse.dirigible.components.jobs.service;
1111

1212
import static java.text.MessageFormat.format;
13-
import java.nio.file.Path;
1413
import java.util.HashMap;
1514
import java.util.Map;
1615
import org.eclipse.dirigible.commons.config.Configuration;
1716
import org.eclipse.dirigible.components.base.artefact.BaseArtefactService;
1817
import org.eclipse.dirigible.components.jobs.domain.Job;
1918
import org.eclipse.dirigible.components.jobs.email.JobEmailProcessor;
19+
import org.eclipse.dirigible.components.jobs.handler.JobHandlerRunner;
2020
import org.eclipse.dirigible.components.jobs.manager.JobsManager;
2121
import org.eclipse.dirigible.components.jobs.repository.JobRepository;
22-
import org.eclipse.dirigible.graalium.core.DirigibleJavascriptCodeRunner;
2322
import org.springframework.stereotype.Service;
2423
import org.springframework.transaction.annotation.Transactional;
2524

@@ -36,17 +35,23 @@ public class JobService extends BaseArtefactService<Job, Long> {
3635
/** The jobs manager. */
3736
private final JobsManager jobsManager;
3837

38+
/** Runs a job's handler on the engine it declares - shared with the scheduled fire. */
39+
private final JobHandlerRunner jobHandlerRunner;
40+
3941
/**
4042
* Instantiates a new job service.
4143
*
4244
* @param repository the repository
4345
* @param jobEmailProcessor the job email processor
4446
* @param jobsManager the jobs manager
47+
* @param jobHandlerRunner the job handler runner
4548
*/
46-
public JobService(JobRepository repository, JobEmailProcessor jobEmailProcessor, JobsManager jobsManager) {
49+
public JobService(JobRepository repository, JobEmailProcessor jobEmailProcessor, JobsManager jobsManager,
50+
JobHandlerRunner jobHandlerRunner) {
4751
super(repository);
4852
this.jobEmailProcessor = jobEmailProcessor;
4953
this.jobsManager = jobsManager;
54+
this.jobHandlerRunner = jobHandlerRunner;
5055
}
5156

5257
/**
@@ -140,14 +145,9 @@ public boolean trigger(String name, Map<String, String> parametersMap) throws Ex
140145
Configuration.set(entry.getKey(), entry.getValue());
141146
}
142147

143-
String handler = job.getHandler();
144-
Path handlerPath = Path.of(handler);
145-
146-
try (DirigibleJavascriptCodeRunner runner = new DirigibleJavascriptCodeRunner()) {
147-
runner.run(handlerPath);
148-
} catch (Exception e) {
149-
throw new Exception(e);
150-
}
148+
// Run it on the engine the job declares - the same dispatch the scheduled fire uses. A
149+
// client-Java job's handler is a class name, not a JavaScript path (dirigible #6305).
150+
jobHandlerRunner.run(job.getHandler(), job.getEngine());
151151
} finally {
152152
for (Map.Entry<String, String> entry : memento.entrySet()) {
153153
Configuration.set(entry.getKey(), entry.getValue());
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.jobs.handler;
11+
12+
import static org.junit.jupiter.api.Assertions.assertThrows;
13+
import static org.mockito.Mockito.mock;
14+
import static org.mockito.Mockito.verify;
15+
import static org.mockito.Mockito.verifyNoInteractions;
16+
import static org.mockito.Mockito.when;
17+
18+
import org.junit.jupiter.api.Test;
19+
import org.springframework.beans.factory.ObjectProvider;
20+
21+
/**
22+
* Dispatching a job's handler to the engine it declares. The Java branch is what the manual
23+
* trigger-now path was missing (#6305); the JavaScript branch runs a Graal code runner and is
24+
* covered end-to-end by the job integration tests instead.
25+
*/
26+
class JobHandlerRunnerTest {
27+
28+
@SuppressWarnings("unchecked")
29+
private static ObjectProvider<JavaJobExecutor> provider(JavaJobExecutor executor) {
30+
ObjectProvider<JavaJobExecutor> provider = mock(ObjectProvider.class);
31+
when(provider.getIfAvailable()).thenReturn(executor);
32+
return provider;
33+
}
34+
35+
@Test
36+
void aJavaJobIsRunByTheJavaExecutor() throws Exception {
37+
JavaJobExecutor executor = mock(JavaJobExecutor.class);
38+
39+
new JobHandlerRunner(provider(executor)).run("app.jobs.CleanupJob", JavaJobExecutor.ENGINE_JAVA);
40+
41+
verify(executor).execute("app.jobs.CleanupJob");
42+
}
43+
44+
@Test
45+
void aJavaJobWithoutAnExecutorFailsLoudly() {
46+
JobHandlerRunner runner = new JobHandlerRunner(provider(null));
47+
48+
// Rather than silently doing nothing - the caller logs the run as failed, so the Jobs
49+
// perspective shows it instead of reporting a run that never happened.
50+
assertThrows(IllegalStateException.class, () -> runner.run("app.jobs.CleanupJob", JavaJobExecutor.ENGINE_JAVA));
51+
}
52+
53+
@Test
54+
void aJavaScriptJobNeverReachesTheJavaExecutor() {
55+
JavaJobExecutor executor = mock(JavaJobExecutor.class);
56+
JobHandlerRunner runner = new JobHandlerRunner(provider(executor));
57+
58+
// No engine (a plain .job artefact) means the JavaScript runner - which needs a repository, so
59+
// it throws here. What this asserts is that the Java executor was not consulted.
60+
assertThrows(Exception.class, () -> runner.run("project/job.mjs", null));
61+
62+
verifyNoInteractions(executor);
63+
}
64+
}

‎tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/sample/JavaJobDecoratorSampleProjectIT.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.util.concurrent.TimeUnit;
1717

1818
import org.eclipse.dirigible.tests.framework.logging.LogsAsserter;
19+
import io.restassured.http.ContentType;
1920
import org.eclipse.dirigible.tests.framework.util.SynchronizationUtil;
2021
import org.junit.jupiter.api.BeforeEach;
2122

@@ -65,6 +66,34 @@ protected void verifyProject() {
6566
.then()
6667
.statusCode(200)
6768
.body("findAll { it.engine == 'java' }.size()", greaterThanOrEqualTo(2)));
69+
70+
verifyTriggerNowRunsTheClientJavaJob();
71+
}
72+
73+
/**
74+
* Trigger-now (the Jobs perspective's play button) must run a client-Java job on the Java engine.
75+
* The manual path used to be JavaScript-only, so it tried to run the job's CLASS NAME as a
76+
* repository path to a JS module and answered 500 (dirigible #6305).
77+
*
78+
* <p>
79+
* The status is the whole assertion: the Java dispatch either resolves the client bean and invokes
80+
* it, or throws (an unknown bean, a job body that fails) - and either way the endpoint surfaces
81+
* that as a 500. It cannot answer 200 without having run the job.
82+
*/
83+
private void verifyTriggerNowRunsTheClientJavaJob() {
84+
String name = restAssuredExecutor.executeWithResult(() -> given().when()
85+
.get("/services/jobs")
86+
.then()
87+
.statusCode(200)
88+
.extract()
89+
.path("find { it.engine == 'java' }.name"));
90+
91+
restAssuredExecutor.execute(() -> given().contentType(ContentType.JSON)
92+
.body("[]")
93+
.when()
94+
.post("/services/jobs/trigger/" + name)
95+
.then()
96+
.statusCode(200));
6897
}
6998

7099
}

0 commit comments

Comments
 (0)