Skip to content

Commit 9ce0855

Browse files
mohityadav766claude
andcommitted
fix(reindex): clear stale on-demand job before triggering reindex
A leftover on-demand job from a previous run that did not complete was preventing further reindex runs. Start the scheduler in the CLI path and clear any existing on-demand job before triggering. The clear skips the delete when the on-demand job is in getCurrentlyExecutingJobs(), so a genuinely running reindex is never cleared. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d40252 commit 9ce0855

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,32 @@ public void triggerOnDemandApplication(App application, Map<String, Object> conf
336336
}
337337
}
338338

339+
/**
340+
* Clears a persisted on-demand job/trigger for the given app from the Quartz store so a fresh
341+
* on-demand trigger can be scheduled without hitting "Job is already running". Used by the CLI
342+
* reindex commands to remove a leftover from a previous run that died before completing. A job
343+
* that is currently executing is left untouched, so a genuinely running run is never cleared.
344+
*/
345+
public void deleteOnDemandJob(App app) {
346+
JobKey onDemandJobKey =
347+
new JobKey(String.format("%s-%s", app.getName(), ON_DEMAND_JOB), APPS_JOB_GROUP);
348+
try {
349+
for (JobExecutionContext context : scheduler.getCurrentlyExecutingJobs()) {
350+
if (context.getJobDetail().getKey().equals(onDemandJobKey)) {
351+
LOG.info(
352+
"On-demand job for app {} is currently executing; leaving it in place.",
353+
app.getName());
354+
return;
355+
}
356+
}
357+
scheduler.deleteJob(onDemandJobKey);
358+
scheduler.unscheduleJob(
359+
new TriggerKey(String.format("%s-%s", app.getName(), ON_DEMAND_JOB), APPS_TRIGGER_GROUP));
360+
} catch (SchedulerException ex) {
361+
LOG.warn("Could not clear existing on-demand job for app {}", app.getName(), ex);
362+
}
363+
}
364+
339365
public void stopApplicationRun(App application) {
340366
try {
341367
JobDetail jobDetailScheduled =

openmetadata-service/src/main/java/org/openmetadata/service/util/OpenMetadataOperations.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,7 @@ public Integer reIndex(
13881388
TypeRepository typeRepository = (TypeRepository) Entity.getEntityRepository(Entity.TYPE);
13891389
TypeRegistry.instance().initialize(typeRepository);
13901390
AppScheduler.initialize(config, collectionDAO, searchRepository);
1391+
AppScheduler.getInstance().start();
13911392

13921393
// Prepare search repository for reindexing (e.g., initialize vector services)
13931394
searchRepository.prepareForReindex();
@@ -1908,8 +1909,10 @@ private int executeSearchReindexApp(
19081909
LOG.info(" - Request compression benefits (JSON payloads will be gzip compressed)");
19091910
}
19101911

1911-
// Trigger Application
1912+
// Trigger Application. Clear any on-demand job left behind by a previous run that died
1913+
// before completing so this run is not rejected with "Job is already running".
19121914
long currentTime = System.currentTimeMillis();
1915+
AppScheduler.getInstance().deleteOnDemandJob(app);
19131916
AppScheduler.getInstance().triggerOnDemandApplication(app, JsonUtils.getMap(config));
19141917

19151918
int result = waitAndReturnReindexingAppStatus(app, currentTime, progressMonitor);
@@ -1966,6 +1969,7 @@ public Integer reIndexDI(
19661969
CollectionRegistry.getInstance().loadSeedData(jdbi, config, null, null, null, true);
19671970
ApplicationHandler.initialize(config);
19681971
AppScheduler.initialize(config, collectionDAO, searchRepository);
1972+
AppScheduler.getInstance().start();
19691973
return executeDataInsightsReindexApp(
19701974
batchSize, recreateIndexes, getBackfillConfiguration(startDate, endDate));
19711975
} catch (Exception e) {
@@ -2000,8 +2004,10 @@ private int executeDataInsightsReindexApp(
20002004
.withRecreateDataAssetsIndex(recreateIndexes)
20012005
.withBackfillConfiguration(backfillConfiguration);
20022006

2003-
// Trigger Application
2007+
// Trigger Application. Clear any on-demand job left behind by a previous run that died
2008+
// before completing so this run is not rejected with "Job is already running".
20042009
long currentTime = System.currentTimeMillis();
2010+
AppScheduler.getInstance().deleteOnDemandJob(app);
20052011
AppScheduler.getInstance().triggerOnDemandApplication(app, JsonUtils.getMap(config));
20062012
return waitAndReturnReindexingAppStatus(app, currentTime);
20072013
}

openmetadata-service/src/test/java/org/openmetadata/service/apps/scheduler/AppSchedulerTest.java

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.openmetadata.service.apps.scheduler;
22

3+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
34
import static org.junit.jupiter.api.Assertions.assertEquals;
45
import static org.junit.jupiter.api.Assertions.assertNotEquals;
56
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -19,6 +20,7 @@
1920
import java.util.List;
2021
import java.util.Map;
2122
import java.util.UUID;
23+
import java.util.concurrent.atomic.AtomicBoolean;
2224
import org.junit.jupiter.api.BeforeEach;
2325
import org.junit.jupiter.api.Test;
2426
import org.junit.jupiter.api.extension.ExtendWith;
@@ -34,8 +36,11 @@
3436
import org.quartz.JobDetail;
3537
import org.quartz.JobExecutionContext;
3638
import org.quartz.JobKey;
39+
import org.quartz.ObjectAlreadyExistsException;
3740
import org.quartz.Scheduler;
41+
import org.quartz.SchedulerException;
3842
import org.quartz.Trigger;
43+
import org.quartz.TriggerKey;
3944
import sun.misc.Unsafe;
4045

4146
@ExtendWith(MockitoExtension.class)
@@ -287,4 +292,137 @@ void testConcurrentApp_twoParallelJobs_differentIdentities() throws Exception {
287292
assertTrue(identity1.contains(workflowId1));
288293
assertTrue(identity2.contains(workflowId2));
289294
}
295+
296+
// --- Tests for the reindex/ops path: clear a stale on-demand job, then trigger ---
297+
298+
private App nonConcurrentApp(String name) {
299+
return new App()
300+
.withId(UUID.randomUUID())
301+
.withName(name)
302+
.withFullyQualifiedName(name)
303+
.withClassName("org.openmetadata.service.resources.apps.TestApp")
304+
.withAllowConcurrentExecution(false)
305+
.withRuntime(new ScheduledExecutionContext().withEnabled(true));
306+
}
307+
308+
@Test
309+
void testDeleteOnDemandJob_removesOnDemandJobAndTrigger() throws Exception {
310+
AppScheduler appScheduler = createSchedulerWithMock();
311+
App app = nonConcurrentApp("SearchIndexingApplication");
312+
313+
appScheduler.deleteOnDemandJob(app);
314+
315+
String onDemandIdentity =
316+
String.format("SearchIndexingApplication-%s", AppScheduler.ON_DEMAND_JOB);
317+
verify(mockScheduler).deleteJob(new JobKey(onDemandIdentity, AppScheduler.APPS_JOB_GROUP));
318+
verify(mockScheduler)
319+
.unscheduleJob(new TriggerKey(onDemandIdentity, AppScheduler.APPS_TRIGGER_GROUP));
320+
}
321+
322+
@Test
323+
void testDeleteOnDemandJob_skipsWhenJobCurrentlyExecuting() throws Exception {
324+
AppScheduler appScheduler = createSchedulerWithMock();
325+
App app = nonConcurrentApp("SearchIndexingApplication");
326+
String onDemandIdentity =
327+
String.format("SearchIndexingApplication-%s", AppScheduler.ON_DEMAND_JOB);
328+
JobKey onDemandKey = new JobKey(onDemandIdentity, AppScheduler.APPS_JOB_GROUP);
329+
330+
// The on-demand job is genuinely executing right now.
331+
JobDetail running =
332+
JobBuilder.newJob(Job.class)
333+
.withIdentity(onDemandIdentity, AppScheduler.APPS_JOB_GROUP)
334+
.build();
335+
JobExecutionContext execContext = mock(JobExecutionContext.class);
336+
when(execContext.getJobDetail()).thenReturn(running);
337+
when(mockScheduler.getCurrentlyExecutingJobs()).thenReturn(List.of(execContext));
338+
339+
appScheduler.deleteOnDemandJob(app);
340+
341+
// A running job must never be cleared.
342+
verify(mockScheduler, never()).deleteJob(onDemandKey);
343+
verify(mockScheduler, never()).unscheduleJob(any(TriggerKey.class));
344+
}
345+
346+
@Test
347+
void testDeleteOnDemandJob_isBestEffortOnSchedulerException() throws Exception {
348+
AppScheduler appScheduler = createSchedulerWithMock();
349+
App app = nonConcurrentApp("SearchIndexingApplication");
350+
when(mockScheduler.deleteJob(any(JobKey.class)))
351+
.thenThrow(new SchedulerException("store unavailable"));
352+
353+
// Clearing a leftover is best-effort; a failure here must not break the reindex flow.
354+
assertDoesNotThrow(() -> appScheduler.deleteOnDemandJob(app));
355+
}
356+
357+
@Test
358+
void testStaleOnDemandJob_blocksTriggerWithoutClear() throws Exception {
359+
AppScheduler appScheduler = createSchedulerWithMock();
360+
App app = nonConcurrentApp("SearchIndexingApplication");
361+
String onDemandIdentity =
362+
String.format("SearchIndexingApplication-%s", AppScheduler.ON_DEMAND_JOB);
363+
JobKey onDemandKey = new JobKey(onDemandIdentity, AppScheduler.APPS_JOB_GROUP);
364+
365+
// A leftover on-demand job persisted by a previous run that died (not currently running).
366+
JobDetail stale =
367+
JobBuilder.newJob(Job.class)
368+
.withIdentity(onDemandIdentity, AppScheduler.APPS_JOB_GROUP)
369+
.build();
370+
when(mockScheduler.getJobDetail(
371+
new JobKey("SearchIndexingApplication", AppScheduler.APPS_JOB_GROUP)))
372+
.thenReturn(null);
373+
when(mockScheduler.getJobDetail(onDemandKey)).thenReturn(stale);
374+
when(mockScheduler.getCurrentlyExecutingJobs()).thenReturn(Collections.emptyList());
375+
// Quartz rejects scheduling a duplicate key while the stale job is still persisted.
376+
when(mockScheduler.scheduleJob(any(JobDetail.class), any(Trigger.class)))
377+
.thenThrow(new ObjectAlreadyExistsException("duplicate job key"));
378+
379+
assertThrows(
380+
UnhandledServerException.class,
381+
() -> appScheduler.triggerOnDemandApplication(app, new HashMap<>()));
382+
}
383+
384+
@Test
385+
void testReindexPath_clearStaleThenTriggerSucceeds() throws Exception {
386+
AppScheduler appScheduler = createSchedulerWithMock();
387+
App app = nonConcurrentApp("SearchIndexingApplication");
388+
String onDemandIdentity =
389+
String.format("SearchIndexingApplication-%s", AppScheduler.ON_DEMAND_JOB);
390+
JobKey onDemandKey = new JobKey(onDemandIdentity, AppScheduler.APPS_JOB_GROUP);
391+
392+
// Stale leftover present until deleteOnDemandJob removes it, mirroring the JDBC job store.
393+
AtomicBoolean stalePresent = new AtomicBoolean(true);
394+
JobDetail stale =
395+
JobBuilder.newJob(Job.class)
396+
.withIdentity(onDemandIdentity, AppScheduler.APPS_JOB_GROUP)
397+
.build();
398+
when(mockScheduler.getJobDetail(
399+
new JobKey("SearchIndexingApplication", AppScheduler.APPS_JOB_GROUP)))
400+
.thenReturn(null);
401+
when(mockScheduler.getJobDetail(onDemandKey))
402+
.thenAnswer(inv -> stalePresent.get() ? stale : null);
403+
when(mockScheduler.getCurrentlyExecutingJobs()).thenReturn(Collections.emptyList());
404+
when(mockScheduler.deleteJob(onDemandKey))
405+
.thenAnswer(
406+
inv -> {
407+
stalePresent.set(false);
408+
return true;
409+
});
410+
when(mockScheduler.scheduleJob(any(JobDetail.class), any(Trigger.class)))
411+
.thenAnswer(
412+
inv -> {
413+
if (stalePresent.get()) {
414+
throw new ObjectAlreadyExistsException("duplicate job key");
415+
}
416+
return null;
417+
});
418+
419+
// The reindex CLI path: clear the leftover, then trigger.
420+
appScheduler.deleteOnDemandJob(app);
421+
appScheduler.triggerOnDemandApplication(app, new HashMap<>());
422+
423+
verify(mockScheduler).deleteJob(onDemandKey);
424+
ArgumentCaptor<JobDetail> jobCaptor = ArgumentCaptor.forClass(JobDetail.class);
425+
verify(mockScheduler).scheduleJob(jobCaptor.capture(), any(Trigger.class));
426+
assertEquals(onDemandIdentity, jobCaptor.getValue().getKey().getName());
427+
}
290428
}

0 commit comments

Comments
 (0)