This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch UNOMI-979-scheduler-lock-lease
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/UNOMI-979-scheduler-lock-lease 
by this push:
     new 4d0ba8be5 UNOMI-979: Rebase task counters on the store before a 
terminal transition
4d0ba8be5 is described below

commit 4d0ba8be5715a7fcf9da36f504ec468a708ae607
Author: Serge Huber <[email protected]>
AuthorDate: Mon Aug 17 13:11:11 2026 +0200

    UNOMI-979: Rebase task counters on the store before a terminal transition
    
    CI reported SchedulerServiceImplTest.testMetricsAndHistory failing with 
"Should
    have 2 successful executions ==> expected: <2> but was: <1>": a periodic 
task
    recorded one success after two successful executions.
    
    canCommitTerminalTransition() loads the authoritative document by id and 
carries
    only the OCC tokens onto the executing task instance. The counters and 
execution
    history stay as the dispatched copy had them - and that copy comes from
    findEnabledScheduledOrWaitingTasks(), a search query that lags the store by 
up to
    the index refresh interval. The compare-and-set in persistTerminalState() 
then
    protects only the document version, not those values, so incrementing a 
stale
    base and CAS-writing it succeeds and silently discards the newer count. Two
    successful executions dispatched from the same lagged view therefore both 
write
    successCount=1.
    
    Accumulators are now rebased on the fresh read before the terminal handler
    increments them: successCount, failureCount, and the append-only execution
    history (taken from the store when it is ahead, so other statusDetails keys 
such
    as checkpoint and crash markers survive). Status, lock fields and 
scheduling are
    untouched - those belong to the execution's own outcome.
    
    This is a data-integrity fix independent of the lock-lease change: success 
and
    failure counts, and the execution history a UI or operator reads, were
    under-reported whenever a dispatch raced the refresh interval. It affects
    Elasticsearch and OpenSearch equally, both having real refresh lag.
    
    testTerminalCompletionRebasesCountersOnStoreValues pins it and is
    mutation-validated: with the rebase removed it fails "expected: <2> but was:
    <1>", the exact CI symptom, deterministically.
    
    Also: testOneShotRetryBehavior's retry-delay assertion now reports the full 
gap
    sequence, execution count and persistence mode on failure instead of a bare
    boolean. It is deliberately not relaxed - an execution landing sooner than 
the
    retry delay is a real contract violation. The suspected mechanism is the 
same
    staleness family on the dispatch side (prepareForExecution() checks due-ness
    against the instance it is handed, so a lagged copy still carrying an 
already
    past nextScheduledExecution passes the check), but that is unproven and 
fixing
    it means changing the dispatch path, so the next occurrence is made decisive
    rather than silenced.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../impl/scheduler/TaskExecutionManager.java       | 50 ++++++++++++++++++
 .../impl/scheduler/SchedulerServiceImplTest.java   | 32 ++++++++++--
 .../impl/scheduler/TaskExecutionManagerTest.java   | 60 ++++++++++++++++++++++
 3 files changed, 137 insertions(+), 5 deletions(-)

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 cde83c97f..98b98a2e3 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
@@ -23,6 +23,8 @@ import org.slf4j.LoggerFactory;
 
 import java.util.ArrayList;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.*;
@@ -668,9 +670,57 @@ public class TaskExecutionManager {
 
         // Carry OCC tokens from the fresh load so persistTerminalState can 
CAS.
         TaskLockManager.copyOccMetadata(latest, task);
+        rebaseAccumulatorsFromStore(task, latest);
         return true;
     }
 
+    /**
+     * Rebases the running task's accumulating fields on the authoritative 
store document before a
+     * terminal handler increments them.
+     * <p>
+     * The task instance a wrapper carries comes from the dispatch path, whose 
discovery query
+     * ({@code findEnabledScheduledOrWaitingTasks}) is search-based and 
therefore lags the store by
+     * up to the index refresh interval. Its {@code successCount}, {@code 
failureCount} and
+     * execution history can predate writes that have already landed. The 
compare-and-set in
+     * {@link #persistTerminalState} protects only the document 
<em>version</em>, not these values:
+     * incrementing a stale base and then CAS-writing it succeeds and silently 
loses the newer
+     * count. Observed as a periodic task reporting one success after two 
successful executions.
+     * <p>
+     * Only accumulators are taken from the store. Status, lock fields and 
scheduling are the
+     * terminal handler's business and are set from the execution's own 
outcome.
+     *
+     * @param task   the executing task instance about to be mutated by a 
terminal handler
+     * @param latest the authoritative document, freshly loaded by id
+     */
+    private static void rebaseAccumulatorsFromStore(ScheduledTask task, 
ScheduledTask latest) {
+        task.setSuccessCount(latest.getSuccessCount());
+        task.setFailureCount(latest.getFailureCount());
+
+        // Execution history is append-only, so the longer list is the more 
current one. Other
+        // statusDetails keys stay as the execution left them (checkpoint 
markers, crash details).
+        Map<String, Object> latestDetails = latest.getStatusDetails();
+        if (latestDetails == null) {
+            return;
+        }
+        Object latestHistory = latestDetails.get("executionHistory");
+        if (!(latestHistory instanceof List)) {
+            return;
+        }
+        Map<String, Object> details = task.getStatusDetails();
+        if (details == null) {
+            details = new HashMap<>();
+            task.setStatusDetails(details);
+        } else if (!(details instanceof HashMap)) {
+            details = new HashMap<>(details);
+            task.setStatusDetails(details);
+        }
+        Object ourHistory = details.get("executionHistory");
+        int ourSize = ourHistory instanceof List ? ((List<?>) 
ourHistory).size() : 0;
+        if (((List<?>) latestHistory).size() > ourSize) {
+            details.put("executionHistory", new ArrayList<>((List<?>) 
latestHistory));
+        }
+    }
+
     /**
      * Persists a terminal task state. Persistent tasks use compare-and-set so 
a late
      * complete/fail cannot clobber CANCELLED or a peer's RUNNING document. 
Lock fields are
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 5f9e8ee38..1be4eea88 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
@@ -1050,12 +1050,34 @@ public class SchedulerServiceImplTest {
             executionLatch.await(TEST_TIMEOUT, TimeUnit.MILLISECONDS),
             "Task should complete all executions");
 
-        // Verify retry delays
+        // Verify retry delays. Deliberately NOT relaxed: an execution landing 
sooner than the
+        // retry delay means a retry attempt was dispatched early, which is a 
real contract
+        // violation worth failing on. Suspected mechanism if this fires on CI 
and not locally:
+        // prepareForExecution() checks due-ness against the task instance it 
was handed, and the
+        // checker discovers tasks with a search query that lags the store, so 
a stale copy still
+        // carrying the pre-retry (already past) nextScheduledExecution passes 
the due check and
+        // executes immediately. Unproven - hence the diagnostics below rather 
than a weakened
+        // assertion, so the next occurrence is decisive instead of just a 
boolean.
         for (int i = 1; i < executionTimes.size(); i++) {
-            long delay = executionTimes.get(i) - executionTimes.get(i-1);
-            assertTrue(
-                delay >= TEST_RETRY_DELAY,
-                "Retry delay should be at least " + TEST_RETRY_DELAY + "ms");
+            long delay = executionTimes.get(i) - executionTimes.get(i - 1);
+            if (delay < TEST_RETRY_DELAY) {
+                StringBuilder detail = new StringBuilder();
+                detail.append("Retry delay should be at least 
").append(TEST_RETRY_DELAY)
+                    .append("ms but execution #").append(i + 1).append(" came 
").append(delay)
+                    .append("ms after #").append(i)
+                    .append(". persistent=").append(persistent)
+                    .append(", executions=").append(executionTimes.size())
+                    .append(" (expected ").append(TEST_MAX_RETRIES + 
1).append("), gaps=[");
+                for (int j = 1; j < executionTimes.size(); j++) {
+                    if (j > 1) {
+                        detail.append(", ");
+                    }
+                    detail.append(executionTimes.get(j) - executionTimes.get(j 
- 1)).append("ms");
+                }
+                detail.append("]. More executions than expected points at a 
duplicate dispatch; "
+                    + "the right count with a short gap points at an early 
retry schedule.");
+                fail(detail.toString());
+            }
         }
 
         // Wait for the task to transition from RUNNING to COMPLETED state
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManagerTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManagerTest.java
index 9c045bf45..f81b9c6e4 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManagerTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskExecutionManagerTest.java
@@ -28,8 +28,12 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -91,6 +95,62 @@ public class TaskExecutionManagerTest {
         executionManager.shutdown();
     }
 
+    /**
+     * A terminal handler must increment counters from the STORE's values, not 
from the possibly
+     * stale copy the wrapper is carrying.
+     * <p>
+     * The dispatch path discovers tasks with a search query, which lags the 
store by up to the
+     * index refresh interval, so the executing instance can hold counters 
that predate writes
+     * already committed. {@code persistTerminalState}'s compare-and-set 
protects only the document
+     * version, so incrementing a stale base then CAS-writing it succeeds and 
silently loses the
+     * newer count. Observed in CI as a periodic task reporting one success 
after two successful
+     * executions ({@code SchedulerServiceImplTest.testMetricsAndHistory}).
+     */
+    @Test
+    public void testTerminalCompletionRebasesCountersOnStoreValues() throws 
Exception {
+        CountDownLatch done = new CountDownLatch(1);
+        TaskExecutor executor = new TaskExecutor() {
+            @Override public String getTaskType() { return "stale-counters"; }
+            @Override public void execute(ScheduledTask task, 
TaskStatusCallback callback) {
+                callback.complete();
+                done.countDown();
+            }
+        };
+
+        // What the wrapper carries: a search-lagged view that has not seen 
the first success.
+        ScheduledTask stale = TaskTestFixtures.baseTask("stale-counters");
+        stale.setOneShot(false);
+        stale.setPeriod(60_000);
+        stale.setSuccessCount(0);
+        stale.setFailureCount(0);
+
+        // What the store actually holds: one success already recorded, with 
its history entry.
+        ScheduledTask store = TaskTestFixtures.baseTask("stale-counters");
+        store.setItemId(stale.getItemId());
+        store.setStatus(ScheduledTask.TaskStatus.RUNNING);
+        store.setExecutingNodeId(NODE);
+        store.setSuccessCount(1);
+        Map<String, Object> storeDetails = new HashMap<>();
+        List<Map<String, Object>> storeHistory = new ArrayList<>();
+        storeHistory.add(Collections.singletonMap("status", "SUCCESS"));
+        storeDetails.put("executionHistory", storeHistory);
+        store.setStatusDetails(storeDetails);
+        when(schedulerService.getTask(eq(stale.getItemId()), 
eq(true))).thenReturn(store);
+
+        executionManager.executeTask(stale, executor);
+        assertTrue(done.await(5, TimeUnit.SECONDS));
+        awaitStatus(stale, ScheduledTask.TaskStatus.SCHEDULED, 5000);
+
+        assertEquals(2, stale.getSuccessCount(),
+            "the second success must count from the store's value (1), not the 
stale copy's (0)");
+
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> history =
+            (List<Map<String, Object>>) 
stale.getStatusDetails().get("executionHistory");
+        assertEquals(2, history.size(),
+            "history must extend the store's entries rather than restart from 
the stale copy's");
+    }
+
     @Test
     public void testPrepareForExecutionRejectsDisabledAndWrongStatus() {
         ScheduledTask disabled = TaskTestFixtures.baseTask("p");

Reply via email to