This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 5f8f4dc30 UNOMI-939/940: Scheduler dispatch and cache refresh race
fixes (#792)
5f8f4dc30 is described below
commit 5f8f4dc300fe72c82775a3d2448acbf121952729
Author: Serge Huber <[email protected]>
AuthorDate: Wed Jul 1 06:09:20 2026 +0200
UNOMI-939/940: Scheduler dispatch and cache refresh race fixes (#792)
Merge scheduler cache improvements
---
.../cache/AbstractMultiTypeCachingService.java | 59 +++++++++++-
.../cache/AbstractMultiTypeCachingServiceTest.java | 90 ++++++++++++++++++
.../impl/scheduler/SchedulerServiceImpl.java | 8 +-
.../impl/scheduler/TaskExecutionManager.java | 84 +++++++++++------
.../impl/scheduler/TaskRecoveryManager.java | 9 ++
.../services/impl/scheduler/TaskStateManager.java | 5 +-
.../services/impl/events/EventServiceImplTest.java | 7 +-
.../impl/scheduler/SchedulerServiceImplTest.java | 104 +++++++++++++++++++++
.../tracing/impl/DefaultTracerServiceTest.java | 4 +-
9 files changed, 327 insertions(+), 43 deletions(-)
diff --git
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
index 499c7aa7d..4891fc347 100644
---
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
+++
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
@@ -43,6 +43,9 @@ import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -68,6 +71,25 @@ public abstract class AbstractMultiTypeCachingService
extends AbstractContextAwa
protected TenantService tenantService;
protected AuditService auditService;
+ /**
+ * Per-item-type locks ensuring saveItem()/removeItem() (direct writes)
and refreshTypeCache()'s
+ * persistence-read-then-cache-write cycle can never interleave for the
same item type. Without this,
+ * refreshTypeCache() could read a snapshot from persistence just before a
concurrent direct write
+ * commits, then overwrite the cache with that stale snapshot after the
direct write's own cache.put()
+ * already applied the fresh value. Direct writes only need to exclude a
concurrent refresh of the
+ * same type, not each other, so they take the read lock (shared,
concurrent) while refreshTypeCache()
+ * takes the write lock (exclusive) for its whole read-then-cache cycle,
including the "remove items no
+ * longer in persistence" step, which reads from the same persistence
snapshot.
+ *
+ * Cardinality is bounded by the number of distinct item types a service
registers (getTypeConfigs()),
+ * typically one to a handful per service, so this map needs no eviction.
+ */
+ private final Map<String, ReadWriteLock> typeRefreshLocks = new
ConcurrentHashMap<>();
+
+ private ReadWriteLock typeRefreshLock(String itemType) {
+ return typeRefreshLocks.computeIfAbsent(itemType, k -> new
ReentrantReadWriteLock());
+ }
+
/**
* Map tracking which plugin/bundle contributed which items.
* Key is the bundle ID, value is the list of items contributed by that
bundle.
@@ -261,12 +283,25 @@ public abstract class AbstractMultiTypeCachingService
extends AbstractContextAwa
scheduledRefreshTasks.clear();
}
- @SuppressWarnings("unchecked")
protected <T extends Serializable> void
refreshTypeCache(CacheableTypeConfig<T> config) {
if (!config.isRequiresRefresh()) {
return;
}
+ // Excludes any concurrent saveItem()/removeItem() for this item type
for the duration of this
+ // refresh, so its persistence read can never be overwritten by - or
overwrite - a direct write.
+ // See typeRefreshLocks for the full rationale.
+ Lock refreshLock = typeRefreshLock(config.getItemType()).writeLock();
+ refreshLock.lock();
+ try {
+ refreshTypeCacheLocked(config);
+ } finally {
+ refreshLock.unlock();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private <T extends Serializable> void
refreshTypeCacheLocked(CacheableTypeConfig<T> config) {
// Only create the global state maps if we need them
Map<String, Map<String, T>> oldGlobalState = null;
Map<String, Map<String, T>> newGlobalState = null;
@@ -858,8 +893,15 @@ public abstract class AbstractMultiTypeCachingService
extends AbstractContextAwa
}
}
- persistenceService.save(item);
- cacheService.put(itemType, itemId, currentTenant, item);
+ // Excludes a concurrent refreshTypeCache() for this item type: see
typeRefreshLocks.
+ Lock writeGuard = typeRefreshLock(itemType).readLock();
+ writeGuard.lock();
+ try {
+ persistenceService.save(item);
+ cacheService.put(itemType, itemId, currentTenant, item);
+ } finally {
+ writeGuard.unlock();
+ }
}
/**
@@ -872,7 +914,14 @@ public abstract class AbstractMultiTypeCachingService
extends AbstractContextAwa
*/
protected <T extends Item & Serializable> void removeItem(String id,
Class<T> itemClass, String itemType) {
String currentTenant =
contextManager.getCurrentContext().getTenantId();
- persistenceService.remove(id, itemClass);
- cacheService.remove(itemType, id, currentTenant, itemClass);
+ // Excludes a concurrent refreshTypeCache() for this item type: see
typeRefreshLocks.
+ Lock writeGuard = typeRefreshLock(itemType).readLock();
+ writeGuard.lock();
+ try {
+ persistenceService.remove(id, itemClass);
+ cacheService.remove(itemType, id, currentTenant, itemClass);
+ } finally {
+ writeGuard.unlock();
+ }
}
}
diff --git
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
index 73a1684a2..57923a771 100644
---
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
+++
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.services.common.cache;
+import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.Item;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.cache.CacheableTypeConfig;
@@ -32,6 +33,9 @@ import org.mockito.junit.MockitoJUnitRunner;
import java.io.Serializable;
import java.util.*;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import static org.junit.Assert.*;
@@ -110,6 +114,18 @@ public class AbstractMultiTypeCachingServiceTest {
}
}
+ // Minimal Item subclass: saveItem()/removeItem() require <T extends Item
& Serializable>,
+ // which the plain-Serializable TestSerializable above does not satisfy.
+ // Public (not just the ITEM_TYPE field): Item's reflective ITEM_TYPE
lookup needs the
+ // declaring class itself to be accessible, not just the field's own
modifiers.
+ public static class TestItem extends Item {
+ public static final String ITEM_TYPE = TEST_ITEM_TYPE;
+
+ public TestItem(String itemId) {
+ super(itemId);
+ }
+ }
+
private static class TestCachingServiceImpl extends
AbstractMultiTypeCachingService {
private final Set<CacheableTypeConfig<?>> typeConfigs = new
HashSet<>();
@@ -136,6 +152,11 @@ public class AbstractMultiTypeCachingServiceTest {
return typeConfigs;
}
+ // Exposes the protected saveItem() for the concurrency test below.
+ void callSaveItem(TestItem item) {
+ saveItem(item, Item::getItemId, TEST_ITEM_TYPE);
+ }
+
// Helper method to set a config as persistable for testing
void makeConfigPersistable() {
try {
@@ -194,6 +215,9 @@ public class AbstractMultiTypeCachingServiceTest {
runnable.run();
return null;
}).when(contextManager).executeAsSystem(any(Runnable.class));
+
+ // saveItem() reads the current tenant from
contextManager.getCurrentContext().getTenantId().
+ when(contextManager.getCurrentContext()).thenReturn(new
ExecutionContext(TEST_TENANT, null, null));
}
@Test
@@ -377,4 +401,70 @@ public class AbstractMultiTypeCachingServiceTest {
// Verify items were removed from system tenant
verify(cacheService).remove(eq(TEST_ITEM_TYPE), eq("item3"),
eq(SYSTEM_TENANT), eq(TestSerializable.class));
}
+
+ /**
+ * Validates the ReadWriteLock fix in AbstractMultiTypeCachingService:
saveItem() must block while
+ * a refreshTypeCache() for the same item type holds the write lock, so a
refresh's persistence read
+ * can never be followed by it overwriting a cache entry that a concurrent
direct write just applied.
+ * Without the fix, saveItem() and refreshTypeCache() could interleave
freely and the two race
+ * scenarios this test would otherwise need to get lucky on
(read-before-write, write-after-read)
+ * would only manifest occasionally - here we force the interleaving
deterministically instead.
+ */
+ @Test
+ public void testSaveItemExcludedByConcurrentRefresh() throws Exception {
+ when(cacheService.getTenantCache(eq(TEST_TENANT),
eq(TestSerializable.class))).thenReturn(new HashMap<>());
+ when(cacheService.getTenantCache(eq(SYSTEM_TENANT),
eq(TestSerializable.class))).thenReturn(new HashMap<>());
+
doReturn(Collections.singleton(TEST_TENANT)).when(testCachingService).getTenants();
+
+ CacheableTypeConfig<TestSerializable> config = null;
+ for (CacheableTypeConfig<?> typeConfig :
testCachingService.getTypeConfigs()) {
+ if (typeConfig.getType().equals(TestSerializable.class)) {
+ @SuppressWarnings("unchecked")
+ CacheableTypeConfig<TestSerializable> typedConfig =
(CacheableTypeConfig<TestSerializable>) typeConfig;
+ config = typedConfig;
+ break;
+ }
+ }
+ assertNotNull("Should find config for TestSerializable", config);
+ final CacheableTypeConfig<TestSerializable> finalConfig = config;
+
+ CountDownLatch refreshHoldingLock = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ AtomicBoolean refreshStillRunningWhenSaveReturned = new
AtomicBoolean(true);
+
+ // Stand-in for the persistence read inside refreshTypeCache(): signal
that the refresh has
+ // entered its critical section (and therefore holds the write lock),
then block until the
+ // test releases it.
+ doAnswer(invocation -> {
+ refreshHoldingLock.countDown();
+ assertTrue("Test setup: refresh should be released within timeout",
+ releaseRefresh.await(5, TimeUnit.SECONDS));
+ return Collections.emptyList();
+ }).when(testCachingService).loadItemsForTenant(eq(TEST_TENANT),
eq(finalConfig));
+
+ Thread refreshThread = new Thread(() ->
testCachingService.refreshTypeCache(finalConfig));
+ refreshThread.start();
+
+ assertTrue("Refresh should reach loadItemsForTenant while holding the
write lock",
+ refreshHoldingLock.await(5, TimeUnit.SECONDS));
+
+ Thread saveThread = new Thread(() -> {
+ testCachingService.callSaveItem(new TestItem("item1"));
+ refreshStillRunningWhenSaveReturned.set(refreshThread.isAlive());
+ });
+ saveThread.start();
+
+ // saveItem()'s read lock should be blocked behind the refresh's write
lock: give it a moment,
+ // then confirm it has NOT returned yet.
+ saveThread.join(500);
+ assertTrue("saveItem() should still be blocked while refresh holds the
write lock", saveThread.isAlive());
+
+ releaseRefresh.countDown();
+ refreshThread.join(5000);
+ saveThread.join(5000);
+
+ assertFalse("saveThread should have finished", saveThread.isAlive());
+ assertFalse("saveItem() must not proceed until the concurrent refresh
released its write lock",
+ refreshStillRunningWhenSaveReturned.get());
+ }
}
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
index 74f3cbe71..646eb3561 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImpl.java
@@ -1172,10 +1172,14 @@ public class SchedulerServiceImpl implements
SchedulerService {
}
ScheduledTask task = getTask(taskId);
if (task != null) {
- // Only cancel if in a cancellable state
+ // Only cancel if in a cancellable state. COMPLETED is included
because a periodic task
+ // can be observed in that transient state by a caller racing
handleTaskCompletion()
+ // (status briefly goes RUNNING -> COMPLETED -> SCHEDULED);
without it, a cancelTask()
+ // call landing in that window would silently no-op and leave the
task enabled.
if (task.getStatus() == ScheduledTask.TaskStatus.SCHEDULED ||
task.getStatus() == ScheduledTask.TaskStatus.WAITING ||
- task.getStatus() == ScheduledTask.TaskStatus.RUNNING) {
+ task.getStatus() == ScheduledTask.TaskStatus.RUNNING ||
+ task.getStatus() == ScheduledTask.TaskStatus.COMPLETED) {
task.setEnabled(false);
stateManager.updateTaskState(task,
ScheduledTask.TaskStatus.CANCELLED, null, nodeId);
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManager.java
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManager.java
index f787ac563..206e2eb00 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManager.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManager.java
@@ -176,13 +176,28 @@ public class TaskExecutionManager {
// Ensure the executing set exists even under concurrent clears
during shutdown
Set<String> executingSet =
executingTasksByType.computeIfAbsent(taskType, k ->
ConcurrentHashMap.newKeySet());
+ // Atomically claim this task before dispatching. The
persisted/cached status check above
+ // is only updated once prepareForExecution() actually runs inside
the async taskWrapper below,
+ // so a second poll tick landing in that gap could otherwise see a
stale non-RUNNING status and
+ // dispatch the same task again. Set.add() is atomic, so only one
caller can win this race.
+ if (!executingSet.add(task.getItemId())) {
+ LOGGER.debug("Node {} : Task {} is already dispatched for
execution, skipping duplicate dispatch", nodeId, task.getItemId());
+ return;
+ }
+
TaskExecutor.TaskStatusCallback statusCallback =
createStatusCallback(task);
Runnable taskWrapper = createTaskWrapper(task, executor,
statusCallback);
// Execute task immediately using the scheduler
- ScheduledFuture<?> future = scheduler.schedule(taskWrapper, 0,
TimeUnit.MILLISECONDS);
- scheduledTasks.put(task.getItemId(), future);
- executingSet.add(task.getItemId());
+ try {
+ ScheduledFuture<?> future = scheduler.schedule(taskWrapper, 0,
TimeUnit.MILLISECONDS);
+ scheduledTasks.put(task.getItemId(), future);
+ } catch (Exception e) {
+ // Scheduling failed (e.g. rejected during shutdown): release
the claim so a later
+ // attempt isn't permanently blocked, since the wrapper that
would normally do so never ran.
+ executingSet.remove(task.getItemId());
+ throw e;
+ }
} catch (Exception e) {
LOGGER.error("Node "+nodeId+", Error executing task: " +
task.getItemId(), e);
handleTaskError(task, e.getMessage(), System.currentTimeMillis());
@@ -281,35 +296,34 @@ public class TaskExecutionManager {
return;
}
- // Check shutdown again before preparing for execution
- if (schedulerService != null && schedulerService.isShutdownNow()) {
- LOGGER.debug("Node {} : Skipping task {} execution as
scheduler is shutting down", nodeId, taskId);
- return;
- }
-
- // Prepare task for execution (both persistent and in-memory)
- if (!prepareForExecution(task)) {
- return;
- }
-
- // Final shutdown check before executing
- if (schedulerService != null && schedulerService.isShutdownNow()) {
- LOGGER.debug("Node {} : Skipping task {} execution as
scheduler is shutting down", nodeId, taskId);
- return;
- }
-
+ // From here on, executeTask() has already atomically claimed
taskId in executingTasksByType
+ // before scheduling this wrapper. Every exit path below -
including the early returns for
+ // shutdown and prepareForExecution() failing (e.g. lock already
held by the caller, as
+ // happens when
TaskRecoveryManager.attemptTaskResumption()/attemptTaskRestart() acquire the
+ // lock themselves before calling executeTask()) - must release
that claim in the outer
+ // finally below, or the task is permanently blocked from ever
being dispatched again.
+ boolean executingNodeIdSet = false;
try {
- // Get or create the executing tasks set
- Set<String> executingTasks =
executingTasksByType.computeIfAbsent(taskType,
- k -> ConcurrentHashMap.newKeySet());
+ // Check shutdown again before preparing for execution
+ if (schedulerService != null &&
schedulerService.isShutdownNow()) {
+ LOGGER.debug("Node {} : Skipping task {} execution as
scheduler is shutting down", nodeId, taskId);
+ return;
+ }
+
+ // Prepare task for execution (both persistent and in-memory)
+ if (!prepareForExecution(task)) {
+ return;
+ }
- // Only add to executing set if not already there
- if (taskId != null) {
- executingTasks.add(taskId);
+ // Final shutdown check before executing
+ if (schedulerService != null &&
schedulerService.isShutdownNow()) {
+ LOGGER.debug("Node {} : Skipping task {} execution as
scheduler is shutting down", nodeId, taskId);
+ return;
}
// Set the executing node ID
task.setExecutingNodeId(nodeId);
+ executingNodeIdSet = true;
schedulerService.saveTask(task);
long startTime = System.currentTimeMillis();
@@ -329,11 +343,15 @@ public class TaskExecutionManager {
LOGGER.error("Unexpected error while executing task: " +
taskId, e);
statusCallback.fail("Unexpected error: " + e.getMessage());
} finally {
- // Clear executing node ID
- task.setExecutingNodeId(null);
- schedulerService.saveTask(task);
+ // Only clear/save executingNodeId if we actually set it
above; otherwise we never
+ // touched the task and a redundant save here could race a
concurrent legitimate holder.
+ if (executingNodeIdSet) {
+ task.setExecutingNodeId(null);
+ schedulerService.saveTask(task);
+ }
- // Remove task from executing set
+ // Always release the dispatch claim taken by executeTask(),
regardless of which path
+ // above was taken.
try {
Set<String> executingTasks =
executingTasksByType.get(taskType);
if (executingTasks != null && taskId != null) {
@@ -424,7 +442,11 @@ public class TaskExecutionManager {
executeTask(task, executor);
}
};
- long retryDelay =
task.getNextScheduledExecution().getTime() - System.currentTimeMillis();
+ // Use the configured retry delay directly rather than
re-deriving it from
+ // nextScheduledExecution: that target was computed
before the state/history/metrics
+ // bookkeeping above ran, so subtracting "now" here
would silently erode the delay
+ // by however long that bookkeeping took (worse under
slower/contended runners).
+ long retryDelay = task.getRetryDelay();
scheduler.schedule(retryTask, retryDelay,
TimeUnit.MILLISECONDS);
LOGGER.debug("Scheduled retry #{} for task {} in {}
ms",
task.getFailureCount(), task.getItemId(),
retryDelay);
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskRecoveryManager.java
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskRecoveryManager.java
index 03691ba62..56c771317 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskRecoveryManager.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskRecoveryManager.java
@@ -134,6 +134,15 @@ public class TaskRecoveryManager {
return;
}
+ // Re-check shutdown right before writing: preDestroy() marks RUNNING
tasks owned by this
+ // node as crashed with a more specific "Interrupted by scheduler
shutdown" cause, and that
+ // write must win over the generic "Node failure detected" cause below
if both race.
+ if (shutdownNow) {
+ LOGGER.debug("Node {} Skipping recovery of task {} : {} as
scheduler is shutting down",
+ nodeId, task.getTaskType(), task.getItemId());
+ return;
+ }
+
// First mark as crashed and release lock
String previousOwner = task.getLockOwner();
if (task.getStatus() != ScheduledTask.TaskStatus.CRASHED) {
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskStateManager.java
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskStateManager.java
index cce41b29b..1e909bd90 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskStateManager.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskStateManager.java
@@ -39,7 +39,10 @@ public class TaskStateManager {
EXECUTE(TaskStatus.RUNNING, EnumSet.of(TaskStatus.SCHEDULED,
TaskStatus.CRASHED, TaskStatus.WAITING)),
COMPLETE(TaskStatus.COMPLETED, EnumSet.of(TaskStatus.RUNNING)),
FAIL(TaskStatus.FAILED, EnumSet.of(TaskStatus.RUNNING)),
- CANCEL(TaskStatus.CANCELLED, EnumSet.of(TaskStatus.RUNNING,
TaskStatus.SCHEDULED, TaskStatus.WAITING)),
+ // COMPLETED is included because a periodic task can be observed in
that transient state
+ // (briefly, between finishing one run and being rescheduled for the
next) by a concurrent
+ // cancelTask() call; cancellation should still apply rather than
silently no-op.
+ CANCEL(TaskStatus.CANCELLED, EnumSet.of(TaskStatus.RUNNING,
TaskStatus.SCHEDULED, TaskStatus.WAITING, TaskStatus.COMPLETED)),
CRASH(TaskStatus.CRASHED, EnumSet.of(TaskStatus.RUNNING,
TaskStatus.SCHEDULED)),
WAIT(TaskStatus.WAITING, EnumSet.of(TaskStatus.SCHEDULED,
TaskStatus.RUNNING));
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
index 109d5ef97..a5092fd22 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/events/EventServiceImplTest.java
@@ -417,10 +417,13 @@ public class EventServiceImplTest {
condition.setParameter("propertyValue", "test");
query.setCondition(condition);
- // Test - retry until event is available (handles refresh delay)
+ // Test - retry until event is available (handles refresh delay).
+ // Full-text indexing can take longer than the default 2000ms budget
on loaded CI runners,
+ // so use a more generous timeout for this specific query.
PartialList<Event> result = TestHelper.retryQueryUntilAvailable(
() -> eventService.search(query),
- 1
+ 1,
+ 10000L
);
// Verify
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
index 593867e2a..74bb68f12 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
@@ -622,6 +622,56 @@ public class SchedulerServiceImplTest {
assertTrue(recovered.get(), "Task should be recovered and executed");
}
+ @Test
+ @Tag("ClusterTests")
+ public void testNoDuplicateDispatchUnderSlowExecution() throws Exception {
+ // Validates TaskExecutionManager.executeTask(): the dispatch claim it
takes in
+ // executingTasksByType before scheduling a task wrapper must hold for
the entire run, or a
+ // poll tick landing while the first run is still in flight (status
not yet flipped to RUNNING,
+ // or - as observed before this fix - never released because
prepareForExecution() returned
+ // false on an early-return path) could dispatch the same task a
second time. The execution
+ // below deliberately runs past several poll ticks
(TASK_CHECK_INTERVAL = 1000ms) to exercise
+ // that window.
+ AtomicInteger concurrentExecutions = new AtomicInteger(0);
+ AtomicInteger maxConcurrentExecutions = new AtomicInteger(0);
+ AtomicInteger totalExecutions = new AtomicInteger(0);
+ CountDownLatch completionLatch = new CountDownLatch(1);
+
+ TaskExecutor executor = new TaskExecutor() {
+ @Override
+ public String getTaskType() {
+ return "slow-dispatch-test";
+ }
+
+ @Override
+ public void execute(ScheduledTask task, TaskStatusCallback
callback) {
+ int concurrent = concurrentExecutions.incrementAndGet();
+ maxConcurrentExecutions.updateAndGet(max -> Math.max(max,
concurrent));
+ totalExecutions.incrementAndGet();
+ try {
+ Thread.sleep(2500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ concurrentExecutions.decrementAndGet();
+ }
+ callback.complete();
+ completionLatch.countDown();
+ }
+ };
+
+ schedulerService.registerTaskExecutor(executor);
+ schedulerService.newTask("slow-dispatch-test")
+ .disallowParallelExecution()
+ .schedule();
+
+ assertTrue(completionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task
should complete");
+
+ assertEquals(1, maxConcurrentExecutions.get(), "Task must never run
concurrently with itself");
+ assertEquals(1, totalExecutions.get(),
+ "Task must execute exactly once despite a slow run spanning
multiple poll ticks");
+ }
+
// Metrics and history tests
@Test
@Tag("MetricsTests")
@@ -1223,6 +1273,60 @@ public class SchedulerServiceImplTest {
assertFalse(completed.get(), "Task should not complete after
cancellation");
}
+ /**
+ * Validates the fix broadening the CANCEL transition to accept COMPLETED.
A periodic task only
+ * observes COMPLETED transiently (RUNNING -> COMPLETED ->
SCHEDULED), but a one-shot task stays
+ * COMPLETED indefinitely once it finishes, giving a fully deterministic
way to exercise
+ * cancelTask() against a task that is genuinely COMPLETED rather than
racing the transient window.
+ * Before the fix, cancelTaskInternal() only handled
SCHEDULED/WAITING/RUNNING and silently left a
+ * COMPLETED task enabled.
+ */
+ @Test
+ @Tag("StateTests")
+ public void testCancelTaskWhileCompleted() throws Exception {
+ CountDownLatch executionLatch = new CountDownLatch(1);
+
+ TaskExecutor executor = new TaskExecutor() {
+ @Override
+ public String getTaskType() {
+ return "cancel-completed-test";
+ }
+
+ @Override
+ public void execute(ScheduledTask task, TaskStatusCallback
callback) {
+ callback.complete();
+ executionLatch.countDown();
+ }
+ };
+
+ schedulerService.registerTaskExecutor(executor);
+
+ ScheduledTask task = schedulerService.newTask("cancel-completed-test")
+ .asOneShot()
+ .schedule();
+
+ assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task
should execute");
+
+ TestHelper.retryUntil(
+ () -> schedulerService.getTask(task.getItemId()),
+ t -> t != null && t.getStatus() ==
ScheduledTask.TaskStatus.COMPLETED
+ );
+
+ schedulerService.cancelTask(task.getItemId());
+
+ TestHelper.retryUntil(
+ () -> schedulerService.getTask(task.getItemId()),
+ t -> t != null && t.getStatus() ==
ScheduledTask.TaskStatus.CANCELLED
+ );
+
+ ScheduledTask cancelledTask =
schedulerService.getTask(task.getItemId());
+ assertEquals(
+ ScheduledTask.TaskStatus.CANCELLED,
+ cancelledTask.getStatus(),
+ "cancelTask() on a COMPLETED task must actually cancel it, not
silently no-op");
+ assertFalse(cancelledTask.isEnabled(), "Cancelled task should be
disabled");
+ }
+
/**
* Tests task querying and filtering capabilities.
*/
diff --git
a/tracing/tracing-impl/src/test/java/org/apache/unomi/tracing/impl/DefaultTracerServiceTest.java
b/tracing/tracing-impl/src/test/java/org/apache/unomi/tracing/impl/DefaultTracerServiceTest.java
index 87577ae7a..15771f9b7 100644
---
a/tracing/tracing-impl/src/test/java/org/apache/unomi/tracing/impl/DefaultTracerServiceTest.java
+++
b/tracing/tracing-impl/src/test/java/org/apache/unomi/tracing/impl/DefaultTracerServiceTest.java
@@ -86,9 +86,9 @@ public class DefaultTracerServiceTest {
RequestTracer tracer = tracerService.getCurrentTracer();
// Start a root operation
- tracer.startOperation("test", "Root operation", null);
long startTime = System.currentTimeMillis();
-
+ tracer.startOperation("test", "Root operation", null);
+
// Add some traces
tracer.trace("Test message", null);
tracer.trace("Test with context", "context-data");