|
1 | 1 | package org.openmetadata.service.apps.scheduler; |
2 | 2 |
|
| 3 | +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
3 | 4 | import static org.junit.jupiter.api.Assertions.assertEquals; |
4 | 5 | import static org.junit.jupiter.api.Assertions.assertNotEquals; |
5 | 6 | import static org.junit.jupiter.api.Assertions.assertNotNull; |
|
19 | 20 | import java.util.List; |
20 | 21 | import java.util.Map; |
21 | 22 | import java.util.UUID; |
| 23 | +import java.util.concurrent.atomic.AtomicBoolean; |
22 | 24 | import org.junit.jupiter.api.BeforeEach; |
23 | 25 | import org.junit.jupiter.api.Test; |
24 | 26 | import org.junit.jupiter.api.extension.ExtendWith; |
|
34 | 36 | import org.quartz.JobDetail; |
35 | 37 | import org.quartz.JobExecutionContext; |
36 | 38 | import org.quartz.JobKey; |
| 39 | +import org.quartz.ObjectAlreadyExistsException; |
37 | 40 | import org.quartz.Scheduler; |
| 41 | +import org.quartz.SchedulerException; |
38 | 42 | import org.quartz.Trigger; |
| 43 | +import org.quartz.TriggerKey; |
39 | 44 | import sun.misc.Unsafe; |
40 | 45 |
|
41 | 46 | @ExtendWith(MockitoExtension.class) |
@@ -287,4 +292,137 @@ void testConcurrentApp_twoParallelJobs_differentIdentities() throws Exception { |
287 | 292 | assertTrue(identity1.contains(workflowId1)); |
288 | 293 | assertTrue(identity2.contains(workflowId2)); |
289 | 294 | } |
| 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 | + } |
290 | 428 | } |
0 commit comments