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 0ff5e659e UNOMI-979: Judge scheduler lock expiry by the owner's 
recorded lease, deflake scheduler tests (#855)
0ff5e659e is described below

commit 0ff5e659e88f9f9ba0845502d690819f97717189
Author: Serge Huber <[email protected]>
AuthorDate: Tue Aug 18 14:18:58 2026 +0200

    UNOMI-979: Judge scheduler lock expiry by the owner's recorded lease, 
deflake scheduler tests (#855)
    
    Merge UNOMI-979 unit test fix
---
 .github/workflows/unomi-ci-build-tests.yml         |  83 +++++-
 .../org/apache/unomi/api/tasks/ScheduledTask.java  |  36 ++-
 build.sh                                           |  22 +-
 itests/lib/it-run-memory.sh                        | 184 +++++++++++-
 itests/sample-it-memory.sh                         |   9 +-
 .../META-INF/cxs/mappings/scheduledTask.json       |   3 +
 .../META-INF/cxs/mappings/scheduledTask.json       |   3 +
 .../impl/scheduler/SchedulerServiceImpl.java       |   3 +
 .../impl/scheduler/TaskExecutionManager.java       |  62 +++++
 .../services/impl/scheduler/TaskLockManager.java   |  29 +-
 .../impl/scheduler/TaskRecoveryManager.java        |   2 +
 .../services/impl/scheduler/TaskStateManager.java  |   2 +
 .../ScheduledTaskLeaseSerializationTest.java       | 124 +++++++++
 .../scheduler/SchedulerDiagnosticsExtension.java   | 271 ++++++++++++++++++
 .../scheduler/SchedulerServiceClusterRaceTest.java | 145 ++++++++++
 .../impl/scheduler/SchedulerServiceImplTest.java   | 310 ++++++++++++++++-----
 .../impl/scheduler/TaskExecutionManagerTest.java   | 111 +++++++-
 .../impl/scheduler/TaskLockManagerTest.java        | 183 ++++++++++++
 18 files changed, 1475 insertions(+), 107 deletions(-)

diff --git a/.github/workflows/unomi-ci-build-tests.yml 
b/.github/workflows/unomi-ci-build-tests.yml
index 076ab448e..07f2f5858 100644
--- a/.github/workflows/unomi-ci-build-tests.yml
+++ b/.github/workflows/unomi-ci-build-tests.yml
@@ -21,7 +21,9 @@ jobs:
   unit-tests:
     name: Execute unit tests
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    # Slightly above the previous 15: retried failures (rerunFailingTestsCount 
below) add time
+    # on a red build, and a timeout is a much worse signal than a clean 
failure.
+    timeout-minutes: 20
     steps:
     - uses: actions/checkout@v5
     - name: Set up JDK 17
@@ -36,12 +38,79 @@ jobs:
         sudo apt-get install -y graphviz
         dot -V
     - name: Build and Unit tests
+      env:
+        # Retry a failing test twice before calling the build red. Several 
suites (notably the
+        # scheduler ones) are timing-sensitive and this runner has 2 vCPU, so 
a single unlucky
+        # scheduling hiccup should not fail a whole build. A test that only 
passes on retry is
+        # NOT silently forgiven: Surefire records it as a flake, and the step 
below surfaces
+        # every one in the job summary so the flake rate stays visible instead 
of becoming
+        # invisible green.
+        MAVEN_EXTRA_OPTS: -Dsurefire.rerunFailingTestsCount=2
       run: ./build.sh --ci
     # Keep only third-party dependencies in the post-job Maven cache: Unomi's 
own
     # snapshots are rebuilt every run and would only bloat the cache / risk 
staleness
     - name: Clean Unomi artifacts from Maven cache
       if: always()
       run: rm -rf ~/.m2/repository/org/apache/unomi
+    # A flake is a test that failed and then passed on retry. The build is 
green, so without
+    # this the signal is lost entirely — which is how the scheduler suites 
stayed unreliable
+    # for as long as they did.
+    - name: Detect flaky tests
+      id: flakes
+      if: always()
+      run: |
+        python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+        import glob, os, xml.etree.ElementTree as ET
+        flaky = []
+        for path in glob.glob('**/target/surefire-reports/TEST-*.xml', 
recursive=True):
+            try:
+                root = ET.parse(path).getroot()
+            except ET.ParseError:
+                continue
+            for case in root.iter('testcase'):
+                reruns = case.findall('flakyFailure') + 
case.findall('flakyError')
+                if reruns:
+                    msg = (reruns[0].get('message') or 
'').strip().replace('\n', ' ')
+                    flaky.append((case.get('classname', '?'), case.get('name', 
'?'),
+                                  len(reruns), msg[:160]))
+        if flaky:
+            print('### :warning: Flaky tests detected\n')
+            print('These failed and then passed on retry. The build is green, 
but each one is')
+            print('a real intermittent failure worth investigating.\n')
+            print('| Test | Retries | First failure |')
+            print('| --- | --- | --- |')
+            for cls, name, n, msg in sorted(flaky):
+                print(f'| `{cls}.{name}` | {n} | {msg or "—"} |')
+        else:
+            print('### No flaky tests detected\n')
+        with open(os.environ['GITHUB_OUTPUT'], 'a') as out:
+            out.write(f'found={"true" if flaky else "false"}\n')
+            out.write(f'count={len(flaky)}\n')
+        PY
+    # Uploaded when the build failed OR when something only passed on retry: 
those are exactly
+    # the runs where the reports (and the scheduler diagnostics dumped into 
them) are worth
+    # keeping. Skipped on a clean green run so this does not accumulate on 
every push.
+    - name: Archive unit test reports
+      uses: actions/upload-artifact@v6
+      if: always() && (job.status == 'failure' || steps.flakes.outputs.found 
== 'true')
+      with:
+        name: unit-test-reports-jdk17-${{ github.run_number }}
+        path: |
+          **/target/surefire-reports/**
+        if-no-files-found: ignore
+        retention-days: 14
+    # Always publish so a later "re-run failed jobs" pass updates the check to 
green, matching
+    # the integration-test job's behaviour.
+    - name: Publish Test Report
+      uses: mikepenz/action-junit-report@v3
+      if: always()
+      continue-on-error: true
+      with:
+        report_paths: '**/target/surefire-reports/TEST-*.xml'
+        check_name: 'JUnit Test Report (unit tests)'
+        update_check: true
+        fail_on_failure: false
+        require_tests: false
 
   integration-tests:
     name: Execute integration tests
@@ -74,11 +143,19 @@ jobs:
           MAVEN_EXTRA_OPTS: >-
             -Dopensearch.port=${{ matrix.port }}
             -Delasticsearch.port=${{ matrix.port }}
+        # This job is gated on `needs: unit-tests`, so the unit suite and the 
Javadoc/checkstyle
+        # validation have already passed on this exact commit. Re-running 
either here is pure
+        # duplication before the integration tests this job exists for, and 
the legs run
+        # sequentially (max-parallel: 1), so it costs twice over.
+        #   --skip-unit-tests activates the skip-unit-tests profile, which 
sets surefire's skip
+        #     only: failsafe, and therefore the ITs, still run.
+        #   --no-javadoc drops the two extra full-reactor invocations --ci adds
+        #     (javadoc:javadoc and javadoc-tags-warn checkstyle:check).
         run: |
           if [ "${{ matrix.search-engine }}" = "opensearch" ]; then
-            ./build.sh --ci --integration-tests --use-opensearch
+            ./build.sh --ci --integration-tests --skip-unit-tests --no-javadoc 
--use-opensearch
           else
-            ./build.sh --ci --integration-tests
+            ./build.sh --ci --integration-tests --skip-unit-tests --no-javadoc
           fi
       # Keep only third-party dependencies in the post-job Maven cache: 
Unomi's own
       # snapshots are rebuilt every run and would only bloat the cache / risk 
staleness
diff --git a/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java 
b/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java
index c5d698e77..5a2f52ace 100644
--- a/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java
+++ b/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java
@@ -16,6 +16,7 @@
  */
 package org.apache.unomi.api.tasks;
 
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import org.apache.unomi.api.Item;
 
 import java.io.Serializable;
@@ -40,6 +41,11 @@ import java.util.HashSet;
  * @see org.apache.unomi.api.services.SchedulerService
  * @see TaskExecutor
  */
+// Tolerate unknown properties so a node running THIS version can still 
deserialize task
+// documents written by a NEWER version that has added fields (rolling upgrade 
window).
+// Without this, Jackson's default rejects the first unrecognized field and 
the older node
+// loses access to all scheduler state until it is upgraded.
+@JsonIgnoreProperties(ignoreUnknown = true)
 public class ScheduledTask extends Item implements Serializable {
 
     /**
@@ -86,6 +92,7 @@ public class ScheduledTask extends Item implements 
Serializable {
     private boolean enabled;
     private String lockOwner;
     private Date lockDate;
+    private long lockLeaseMillis;
     private boolean oneShot;
     private boolean allowParallelExecution;
     private TaskStatus status;
@@ -343,13 +350,40 @@ public class ScheduledTask extends Item implements 
Serializable {
 
     /**
      * Sets the date when the current lock was acquired.
-     * 
+     *
      * @param lockDate the lock acquisition date
      */
     public void setLockDate(Date lockDate) {
         this.lockDate = lockDate;
     }
 
+    /**
+     * Duration in milliseconds for which the current lock is valid, as 
declared by the node that
+     * acquired or last renewed it.
+     * <p>
+     * A lock's lifetime is a lease granted by its <em>owner</em>: the owner 
renews it on a cadence
+     * derived from its own configured lock timeout, so only the owner's 
timeout describes when a
+     * missing renewal actually means the owner is dead. Observers must judge 
expiry against this
+     * recorded lease, never against their own configured timeout — a node 
configured with a shorter
+     * timeout than the owner's renewal cadence would otherwise "recover" a 
lock whose owner is alive
+     * and mid-execution, and the task would run twice.
+     *
+     * @return the lease duration in milliseconds, or {@code 0} when the lock 
predates lease
+     *         recording (legacy documents) and the observer's own timeout is 
the only guide
+     */
+    public long getLockLeaseMillis() {
+        return lockLeaseMillis;
+    }
+
+    /**
+     * Sets the lease duration granted with the current lock.
+     *
+     * @param lockLeaseMillis the lease duration in milliseconds, {@code 0} 
when unlocked or unknown
+     */
+    public void setLockLeaseMillis(long lockLeaseMillis) {
+        this.lockLeaseMillis = lockLeaseMillis;
+    }
+
     /**
      * Determines whether this task should execute only once.
      * Tasks with period=0 are automatically marked as one-shot tasks.
diff --git a/build.sh b/build.sh
index c74fd5eb8..408768e32 100755
--- a/build.sh
+++ b/build.sh
@@ -279,8 +279,11 @@ RESOLVER_DEBUG=false
 KEEP_CONTAINER=false
 IT_SEARCH_ENGINE_LOGS=false
 IT_MEMORY_SAMPLER=true
-IT_MEMORY_INTERVAL=30
+# 10s, matching itests/sample-it-memory.sh: at 30s a single sample spans 
several ITs, so a
+# stall cannot be attributed to the test that caused it.
+IT_MEMORY_INTERVAL=10
 JAVADOC=false
+NO_JAVADOC=false
 LOG_FILE=""
 LOG_FILE_ONLY=false
 
@@ -327,8 +330,9 @@ EOF
         echo -e "  ${CYAN}--keep-container${NC}           Keep search engine 
container running after tests (for post-failure inspection)"
         echo -e "  ${CYAN}--search-engine-logs${NC}       Stream search engine 
Docker logs to the Maven console during integration tests"
         echo -e "  ${CYAN}--no-memory-sampler${NC}        Disable JVM/system 
memory sampling during integration tests"
-        echo -e "  ${CYAN}--memory-interval SEC${NC}    Memory sample interval 
in seconds (default: 30)"
+        echo -e "  ${CYAN}--memory-interval SEC${NC}    Memory sample interval 
in seconds (default: 10)"
         echo -e "  ${CYAN}--javadoc${NC}                  Build and validate 
Javadoc after install (doclint errors fail; public/protected tag gaps warn)"
+        echo -e "  ${CYAN}--no-javadoc${NC}               Skip 
Javadoc/checkstyle validation (overrides --ci; use when another job already ran 
it)"
         echo -e "  ${CYAN}--ci${NC}                       CI mode: no Karaf, 
non-interactive, includes Javadoc"
         echo -e "  ${CYAN}--log-file PATH${NC}            Tee all output to 
PATH (console + file)"
         echo -e "  ${CYAN}--log-file-only${NC}            With --log-file: 
write to file only, suppress console"
@@ -371,8 +375,9 @@ EOF
         echo "  --keep-container          Keep search engine container running 
after tests (for post-failure inspection)"
         echo "  --search-engine-logs      Stream search engine Docker logs to 
the Maven console during integration tests"
         echo "  --no-memory-sampler       Disable JVM/system memory sampling 
during integration tests"
-        echo "  --memory-interval SEC     Memory sample interval in seconds 
(default: 30)"
+        echo "  --memory-interval SEC     Memory sample interval in seconds 
(default: 10)"
         echo "  --javadoc                 Build and validate Javadoc after 
install (doclint errors fail; public/protected tag gaps warn)"
+        echo "  --no-javadoc              Skip Javadoc/checkstyle validation 
(overrides --ci; use when another job already ran it)"
         echo "  --ci                      CI mode: no Karaf, non-interactive, 
includes Javadoc"
         echo "  --log-file PATH           Tee all output to PATH (console + 
file)"
         echo "  --log-file-only           With --log-file: write to file only, 
suppress console"
@@ -549,6 +554,11 @@ while [ "$1" != "" ]; do
         --javadoc)
             JAVADOC=true
             ;;
+        --no-javadoc)
+            # Explicit veto, applied after argument parsing so it wins 
regardless of whether it
+            # appears before or after --ci (which turns Javadoc on).
+            NO_JAVADOC=true
+            ;;
         --log-file)
             shift
             LOG_FILE="$1"
@@ -1167,6 +1177,12 @@ echo "Estimated time: 3-5 minutes for build, 50-60 
minutes with integration test
 start_timer
 
 # Build phases with enhanced output
+# Apply the --no-javadoc veto now that all arguments are parsed, so it wins 
over --ci
+# regardless of flag order.
+if [ "$NO_JAVADOC" = true ]; then
+    JAVADOC=false
+fi
+
 [ "$JAVADOC" = true ] && total_steps=4 || total_steps=2
 current_step=0
 
diff --git a/itests/lib/it-run-memory.sh b/itests/lib/it-run-memory.sh
index 1c8f43466..8c61cdd5b 100644
--- a/itests/lib/it-run-memory.sh
+++ b/itests/lib/it-run-memory.sh
@@ -27,7 +27,7 @@ IT_MEMORY_SAMPLER_LOG="memory-sampler.log"
 IT_MEMORY_SAMPLER_CACHE="memory-sampler.cache"
 IT_MEMORY_SWAP_PRESSURE_MB=2048
 
-IT_MEMORY_TSV_HEADER=$'timestamp_utc\tkaraf_pid\tkaraf_heap_used_mb\tkaraf_heap_max_mb\tkaraf_gct_s\tes_heap_used_mb\tes_heap_max_mb\tdocker_rss_mb\tsystem_mem_available_mb\tsystem_swap_used_mb\tsystem_load_1m'
+IT_MEMORY_TSV_HEADER=$'timestamp_utc\tkaraf_pid\tkaraf_heap_used_mb\tkaraf_heap_max_mb\tkaraf_gct_s\tes_heap_used_mb\tes_heap_max_mb\tdocker_rss_mb\tsystem_mem_available_mb\tsystem_swap_used_mb\tsystem_load_1m\tkaraf_cpu_pct\tkaraf_io_read_mb_s\tkaraf_io_write_mb_s\tsearch_cpu_pct\tsearch_io_read_mb_s\tsearch_io_write_mb_s'
 
 _IT_MEMORY_OS=""
 
@@ -350,7 +350,12 @@ it_memory_parse_docker_mem_to_mb() {
 }
 
 it_memory_find_karaf_pid() {
-    pgrep -f 'org.apache.karaf.main.Main' 2>/dev/null | head -1
+    # `|| true`: pgrep exits non-zero when nothing matches, and with `set -euo 
pipefail` that
+    # aborted the whole sample. The sampler starts before Karaf does, so every 
sample taken
+    # during startup was discarded -- exactly the window where the search 
engine is booting and
+    # its resource use is most interesting. No match now yields an empty pid, 
which the callers
+    # and the summarizer already treat as "no Karaf yet" (guarded by `if ($2+0 
> 0)`).
+    pgrep -f 'org.apache.karaf.main.Main' 2>/dev/null | head -1 || true
 }
 
 it_memory_karaf_max_mb_cached() {
@@ -446,21 +451,139 @@ it_memory_search_engine_stats() {
     echo -e "$(it_memory_mb_from_bytes 
"${used_bytes:-0}")\t$(it_memory_mb_from_bytes "${max_bytes:-0}")"
 }
 
-it_memory_docker_rss_mb() {
+# --- CPU and disk I/O sampling 
-------------------------------------------------
+#
+# Added to answer "is the run CPU-bound, I/O-bound, or waiting?". The memory 
columns alone
+# could not distinguish a busy run from an idle one blocked on a remote call, 
which is exactly
+# the question raised by the Elasticsearch/OpenSearch IT duration gap: system 
load was near
+# idle on the slower engine, so the extra time was spent waiting rather than 
computing.
+#
+# CPU is measured as a TRUE INTERVAL PERCENTAGE, not ps(1)'s %cpu -- that is 
an average over the
+# whole process lifetime, so a JVM that was busy at startup reads as busy 
forever and the number
+# is useless for spotting a stall. On Linux -- which is what CI runs, and the 
only place these
+# numbers are compared across runs -- we delta /proc/<pid>/stat between 
samples for a true
+# interval figure. macOS has no procfs, so it falls back to ps(1)'s lifetime 
average: good enough
+# to see that a process is alive and roughly how hard it has worked, but NOT 
comparable with a
+# Linux sample and not to be read as "CPU right now". Everything here is 
best-effort: a missing
+# file, a dead pid or an absent docker CLI yields 0 and never fails a run.
+
+# Stores "value timestamp" pairs so the next sample can compute a delta.
+_it_memory_counter_cache() {
+    local target_dir="$1" key="$2"
+    echo "$target_dir/.it-memory-counter-$key"
+}
+
+# Echoes the per-second rate between this reading and the previous one, or 0 
on the first call.
+_it_memory_rate_per_sec() {
+    local target_dir="$1" key="$2" value="$3"
+    local cache prev_value prev_ts now delta_v delta_t
+    cache="$(_it_memory_counter_cache "$target_dir" "$key")"
+    now="$(date +%s)"
+
+    if [ -r "$cache" ]; then
+        read -r prev_value prev_ts < "$cache" 2>/dev/null || true
+    fi
+    printf '%s %s\n' "$value" "$now" > "$cache" 2>/dev/null || true
+
+    if [ -z "${prev_value:-}" ] || [ -z "${prev_ts:-}" ]; then
+        echo "0"
+        return
+    fi
+    delta_t=$((now - prev_ts))
+    [ "$delta_t" -le 0 ] && { echo "0"; return; }
+    delta_v="$(awk -v a="$value" -v b="$prev_value" 'BEGIN { d = a - b; print 
(d > 0 ? d : 0) }')"
+    awk -v d="$delta_v" -v t="$delta_t" 'BEGIN { printf "%.2f", d / t }'
+}
+
+# Interval CPU% for a pid. >100 is legitimate on multi-core (sum across 
threads).
+it_memory_process_cpu_pct() {
+    local target_dir="$1" pid="${2:-}"
+    local ticks hz cpu_s rate
+
+    if [ -z "$pid" ] || [ "$pid" = "0" ] || ! kill -0 "$pid" 2>/dev/null; then
+        echo "0"
+        return
+    fi
+
+    if it_memory_is_linux && [ -r "/proc/$pid/stat" ]; then
+        # Fields 14 (utime) and 15 (stime), in clock ticks. comm (field 2) is 
parenthesised and
+        # may itself contain spaces AND parentheses, so split after the LAST 
')' rather than the
+        # first: a process named e.g. "java (worker)" otherwise shifts every 
subsequent index.
+        ticks="$(awk '{
+                        i = length($0)
+                        while (i > 0 && substr($0, i, 1) != ")") i--
+                        n = split(substr($0, i + 2), f, " ")
+                        if (n >= 13) print f[12] + f[13]; else print 0
+                      }' "/proc/$pid/stat" 2>/dev/null)"
+        [ -z "$ticks" ] && { echo "0"; return; }
+        hz="$(getconf CLK_TCK 2>/dev/null || echo 100)"
+        cpu_s="$(awk -v t="$ticks" -v hz="$hz" 'BEGIN { printf "%.4f", t / hz 
}')"
+        rate="$(_it_memory_rate_per_sec "$target_dir" "cpu-$pid" "$cpu_s")"
+        awk -v r="$rate" 'BEGIN { printf "%.1f", r * 100 }'
+        return
+    fi
+
+    # macOS / no procfs: lifetime average, better than nothing for a local run.
+    ps -o %cpu= -p "$pid" 2>/dev/null | tr -d ' ' | awk 'NF { printf "%.1f", 
$1; found = 1 } END { if (!found) print 0 }'
+}
+
+# Interval disk read/write in MB/s for a pid (Linux only; /proc/<pid>/io).
+it_memory_process_io_mb_s() {
+    local target_dir="$1" pid="${2:-}"
+    local read_bytes write_bytes read_rate write_rate
+
+    if [ -z "$pid" ] || [ "$pid" = "0" ] || ! it_memory_is_linux || [ ! -r 
"/proc/$pid/io" ]; then
+        echo -e "0\t0"
+        return
+    fi
+
+    read_bytes="$(awk '/^read_bytes:/ { print $2 }' "/proc/$pid/io" 
2>/dev/null)"
+    write_bytes="$(awk '/^write_bytes:/ { print $2 }' "/proc/$pid/io" 
2>/dev/null)"
+    read_rate="$(_it_memory_rate_per_sec "$target_dir" "ior-$pid" 
"${read_bytes:-0}")"
+    write_rate="$(_it_memory_rate_per_sec "$target_dir" "iow-$pid" 
"${write_bytes:-0}")"
+    awk -v r="$read_rate" -v w="$write_rate" 'BEGIN { printf "%.2f\t%.2f", r / 
1048576, w / 1048576 }'
+}
+
+# One docker stats call per sample, returning RSS, CPU% and block I/O together.
+#
+# Deliberately a single invocation: `docker stats --no-stream` costs ~1s and 
briefly loads the
+# daemon, and the sampler now runs 3x more often (10s rather than 30s). Two 
calls per sample
+# would have meant six times the docker traffic of the original sampler, 
perturbing the very
+# run being measured and tripling the exposure to a hung daemon. CPUPerc is 
already an interval
+# measurement; BlockIO is cumulative and is deltaed here.
+#
+# Echoes: rss_mb \t cpu_pct \t io_read_mb_s \t io_write_mb_s
+it_memory_docker_sample() {
     local target_dir="$1"
-    local container rss
+    local container stats mem cpu blockio read_raw write_raw read_b write_b 
read_rate write_rate
 
     if ! command -v docker >/dev/null 2>&1; then
-        echo "0"
+        echo -e "0\t0\t0\t0"
         return
     fi
 
     container="$(it_memory_resolve_docker_container "$target_dir")"
-    rss="$(docker stats --no-stream --format '{{.MemUsage}}' "$container" 
2>/dev/null | head -1 | cut -d/ -f1 | tr -d ' ')"
+    stats="$(docker stats --no-stream --format 
'{{.MemUsage}}|{{.CPUPerc}}|{{.BlockIO}}' "$container" 2>/dev/null | head -1)"
+    if [ -z "$stats" ]; then
+        echo -e "0\t0\t0\t0"
+        return
+    fi
+
+    mem="$(echo "$stats" | cut -d'|' -f1 | cut -d/ -f1 | tr -d ' ')"
+    cpu="$(echo "$stats" | cut -d'|' -f2 | tr -d ' %')"
+    blockio="$(echo "$stats" | cut -d'|' -f3)"
+    read_raw="$(echo "$blockio" | cut -d/ -f1 | tr -d ' ')"
+    write_raw="$(echo "$blockio" | cut -d/ -f2 | tr -d ' ')"
+    read_b="$(it_memory_parse_docker_mem_to_mb "$read_raw")"
+    write_b="$(it_memory_parse_docker_mem_to_mb "$write_raw")"
+    read_rate="$(_it_memory_rate_per_sec "$target_dir" "dior" "${read_b:-0}")"
+    write_rate="$(_it_memory_rate_per_sec "$target_dir" "diow" 
"${write_b:-0}")"
 
-    it_memory_parse_docker_mem_to_mb "$rss"
+    printf '%s\t%.1f\t%.2f\t%.2f\n' \
+        "$(it_memory_parse_docker_mem_to_mb "$mem")" "${cpu:-0}" 
"${read_rate:-0}" "${write_rate:-0}"
 }
 
+
 it_memory_system_stats() {
     local mem_available swap_used load_1m
 
@@ -482,13 +605,22 @@ it_memory_sample_once() {
     es_line="$(it_memory_search_engine_stats "$port")"
     sys_line="$(it_memory_system_stats)"
 
-    printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
+    # One docker call per sample; split into the RSS column (8) and the CPU/IO 
columns (15-17).
+    local docker_line docker_rss docker_cpu_io
+    docker_line="$(it_memory_docker_sample "$target_dir")"
+    docker_rss="$(printf '%s' "$docker_line" | cut -f1)"
+    docker_cpu_io="$(printf '%s' "$docker_line" | cut -f2-4)"
+
+    printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
         "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
         "${karaf_pid:-0}" \
         "$karaf_line" \
         "$es_line" \
-        "$(it_memory_docker_rss_mb "$target_dir")" \
-        "$sys_line"
+        "${docker_rss:-0}" \
+        "$sys_line" \
+        "$(it_memory_process_cpu_pct "$target_dir" "$karaf_pid")" \
+        "$(it_memory_process_io_mb_s "$target_dir" "$karaf_pid")" \
+        "${docker_cpu_io:-$(printf '0\t0\t0')}"
 }
 
 it_memory_write_samples_header() {
@@ -508,7 +640,7 @@ it_memory_summarize_samples() {
 
     awk -F'\t' -v summary="$summary_file" -v 
swap_pressure_mb="$IT_MEMORY_SWAP_PRESSURE_MB" '
         NR == 1 { next }
-        NF < 11 { next }
+        NF < 11 { next }   # pre-CPU/IO samples still summarize
         {
             samples++
             if ($2+0 > 0) {
@@ -524,6 +656,18 @@ it_memory_summarize_samples() {
             if ($11+0 > peak_load) peak_load = $11+0
             if (samples == 1) first_swap = $10+0
             last_swap = $10+0
+            # CPU / IO columns are absent in samples written before they were 
added.
+            if (NF >= 17) {
+                cpu_samples++
+                karaf_cpu_sum += $12+0; if ($12+0 > peak_karaf_cpu) 
peak_karaf_cpu = $12+0
+                search_cpu_sum += $15+0; if ($15+0 > peak_search_cpu) 
peak_search_cpu = $15+0
+                io_sum += $13+0 + $14+0 + $16+0 + $17+0
+                if ($13+0 + $14+0 > peak_karaf_io) peak_karaf_io = $13+0 + 
$14+0
+                if ($16+0 + $17+0 > peak_search_io) peak_search_io = $16+0 + 
$17+0
+                # "Idle" = neither process using meaningful CPU: the signature 
of a run that is
+                # waiting on latency rather than doing work.
+                if ($12+0 < 10 && $15+0 < 10) idle_samples++
+            }
         }
         END {
             if (samples == 0) exit 1
@@ -541,6 +685,24 @@ it_memory_summarize_samples() {
             printf("memory.min.system.mem.available.mb=%d\n", min_mem_avail+0) 
>> summary
             printf("memory.peak.system.swap.used.mb=%d\n", peak_swap+0) >> 
summary
             printf("memory.peak.system.load.1m=%.2f\n", peak_load+0) >> summary
+            if (cpu_samples > 0) {
+                printf("cpu.samples.count=%d\n", cpu_samples) >> summary
+                printf("cpu.mean.karaf.pct=%.1f\n", karaf_cpu_sum / 
cpu_samples) >> summary
+                printf("cpu.peak.karaf.pct=%.1f\n", peak_karaf_cpu+0) >> 
summary
+                printf("cpu.mean.search.pct=%.1f\n", search_cpu_sum / 
cpu_samples) >> summary
+                printf("cpu.peak.search.pct=%.1f\n", peak_search_cpu+0) >> 
summary
+                printf("cpu.idle.samples.pct=%d\n", idle_samples * 100 / 
cpu_samples) >> summary
+                printf("io.peak.karaf.mb.s=%.2f\n", peak_karaf_io+0) >> summary
+                printf("io.peak.search.mb.s=%.2f\n", peak_search_io+0) >> 
summary
+                printf("io.mean.total.mb.s=%.2f\n", io_sum / cpu_samples) >> 
summary
+                # Mostly-idle CPU with negligible I/O means the run is 
latency-bound: time is
+                # going on waiting (polls, refresh intervals, timeouts), not 
on work.
+                if (idle_samples * 100 / cpu_samples >= 70 && io_sum / 
cpu_samples < 5) {
+                    printf("cpu.warning.mostly.idle=true\n") >> summary
+                } else {
+                    printf("cpu.warning.mostly.idle=false\n") >> summary
+                }
+            }
             printf("memory.karaf.headroom.pct=%d\n", karaf_headroom+0) >> 
summary
             printf("memory.search.headroom.pct=%d\n", es_headroom+0) >> summary
             if (swap_pressure) {
diff --git a/itests/sample-it-memory.sh b/itests/sample-it-memory.sh
index 19e0d5d5c..e0469bd02 100755
--- a/itests/sample-it-memory.sh
+++ b/itests/sample-it-memory.sh
@@ -39,7 +39,10 @@ source "$SCRIPT_DIR/lib/it-run.sh"
 source "$SCRIPT_DIR/lib/it-run-memory.sh"
 
 TARGET_DIR="$SCRIPT_DIR/target"
-INTERVAL=30
+# 10s, down from 30s: at 30s a sample covers several ITs at once, so a stall 
cannot be
+# attributed to the test that caused it. Each sample is a few cheap reads plus 
one
+# `docker stats --no-stream`, and a 50-minute run produces ~300 rows (a few 
tens of KB).
+INTERVAL=10
 SEARCH_PORT=""
 PRINT_ONLY=false
 COMMAND=""
@@ -58,7 +61,7 @@ Commands:
 
 Options:
   --target-dir DIR   IT target directory (default: itests/target)
-  --interval SEC     Sample interval in seconds for start (default: 30)
+  --interval SEC     Sample interval in seconds for start (default: 10)
   --port PORT        Search engine HTTP port override
   --print-only       With operator-note: print to stdout instead of writing 
file
   -h, --help         Show this help
@@ -78,7 +81,7 @@ parse_args() {
                 ;;
             --interval)
                 shift
-                INTERVAL="${1:-30}"
+                INTERVAL="${1:-10}"
                 ;;
             --port)
                 shift
diff --git 
a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
index f36fc297c..030305e8e 100644
--- 
a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
+++ 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
@@ -75,6 +75,9 @@
     "lockDate": {
       "type": "date"
     },
+    "lockLeaseMillis": {
+      "type": "long"
+    },
     "lastExecutionDate": {
       "type": "date"
     },
diff --git 
a/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
index 9c1541d96..a251eebf4 100644
--- 
a/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
+++ 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/scheduledTask.json
@@ -78,6 +78,9 @@
     "lockDate": {
       "type": "date"
     },
+    "lockLeaseMillis": {
+      "type": "long"
+    },
     "lastExecutionDate": {
       "type": "date"
     },
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 3c98963fc..5e84267da 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
@@ -493,6 +493,7 @@ public class SchedulerServiceImpl implements 
SchedulerService {
         if (newStatus == TaskStatus.COMPLETED || newStatus == 
TaskStatus.FAILED) {
             task.setLockOwner(null);
             task.setLockDate(null);
+            task.setLockLeaseMillis(0);
             task.setWaitingForTaskType(null);
             task.setCurrentStep(null);
             // Update last execution date for completed/failed tasks
@@ -511,6 +512,7 @@ public class SchedulerServiceImpl implements 
SchedulerService {
         } else if (newStatus == TaskStatus.WAITING) {
             task.setLockOwner(null);
             task.setLockDate(null);
+            task.setLockLeaseMillis(0);
         } else if (newStatus == TaskStatus.RUNNING) {
             // Update status details for running tasks
             Map<String, Object> details = task.getStatusDetails();
@@ -899,6 +901,7 @@ public class SchedulerServiceImpl implements 
SchedulerService {
                             // and PersistenceSchedulerProvider.preDestroy 
need not unlock RUNNING.
                             task.setLockOwner(null);
                             task.setLockDate(null);
+                            task.setLockLeaseMillis(0);
                             if (task.isPersistent() && persistenceProvider != 
null) {
                                 if (!persistenceProvider.saveTask(task)) {
                                     LOGGER.warn("Failed to persist CRASHED 
state for task {} during shutdown; "
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 8f3bd41a4..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.*;
@@ -502,6 +504,7 @@ public class TaskExecutionManager {
             task.setExecutingNodeId(null);
             task.setLockOwner(null);
             task.setLockDate(null);
+            task.setLockLeaseMillis(0);
             schedulerService.saveTask(task, true);
         } catch (Exception e) {
             LOGGER.warn("Failed to abort prepared task {} during shutdown: {}",
@@ -532,6 +535,16 @@ public class TaskExecutionManager {
             return;
         }
         long interval = Math.max(MIN_LOCK_RENEWAL_INTERVAL_MS, 
lockManager.getLockTimeout() / 3);
+        if (interval >= lockManager.getLockTimeout()) {
+            // The renewal floor exceeds the configured timeout, so this node 
cannot renew its own
+            // lease fast enough to keep it alive: peers may legitimately 
treat its live locks as
+            // expired between two renewals and recover mid-execution tasks. 
Surface the
+            // misconfiguration instead of leaving sporadic double executions 
to be diagnosed.
+            LOGGER.warn("Lock timeout {}ms is at or below the minimum renewal 
interval {}ms: "
+                    + "this node's live locks can expire between renewals and 
be recovered by peers. "
+                    + "Configure a lock timeout of at least {}ms.",
+                lockManager.getLockTimeout(), interval, 
MIN_LOCK_RENEWAL_INTERVAL_MS * 3);
+        }
         LockRenewalHandle handle = new LockRenewalHandle();
         activeLockRenewals.put(task.getItemId(), handle);
         try {
@@ -657,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
@@ -668,6 +729,7 @@ public class TaskExecutionManager {
     private boolean persistTerminalState(ScheduledTask task) {
         task.setLockOwner(null);
         task.setLockDate(null);
+        task.setLockLeaseMillis(0);
         if (!task.isPersistent()) {
             boolean saved = schedulerService.saveTask(task);
             if (!saved) {
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskLockManager.java
 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskLockManager.java
index 978d97fa6..fbe1517c0 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskLockManager.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/scheduler/TaskLockManager.java
@@ -161,6 +161,7 @@ public class TaskLockManager {
             // Just set lock info but don't enforce exclusivity
             task.setLockOwner(nodeId);
             task.setLockDate(new Date());
+            task.setLockLeaseMillis(lockTimeout);
             
metricsManager.updateMetric(TaskMetricsManager.METRIC_TASKS_LOCK_ACQUIRED);
             return true;
         }
@@ -194,8 +195,10 @@ public class TaskLockManager {
 
             latest.setLockOwner(nodeId);
             latest.setLockDate(new Date());
+            latest.setLockLeaseMillis(lockTimeout);
             task.setLockOwner(nodeId);
             task.setLockDate(latest.getLockDate());
+            task.setLockLeaseMillis(lockTimeout);
             
metricsManager.updateMetric(TaskMetricsManager.METRIC_TASKS_LOCK_ACQUIRED);
 
             // For non-persistent tasks, we just update the in-memory map
@@ -247,9 +250,12 @@ public class TaskLockManager {
         task.setSystemMetadata(SEQ_NO, latestTask.getSystemMetadata(SEQ_NO));
         task.setSystemMetadata(PRIMARY_TERM, 
latestTask.getSystemMetadata(PRIMARY_TERM));
 
-        // Step 6: Set lock information
+        // Step 6: Set lock information. The lease records THIS node's timeout 
with the lock:
+        // renewal cadence is derived from the owner's timeout, so only the 
owner's timeout says
+        // when a missing renewal means the owner is dead (see 
isLockExpired()).
         task.setLockOwner(nodeId);
         task.setLockDate(new Date());
+        task.setLockLeaseMillis(lockTimeout);
 
         LOGGER.debug("LOCK-DIAG [{}] node {} : attempting CAS write - 
if_seq_no={}, if_primary_term={}, "
                 + "writing lockOwner={}",
@@ -391,6 +397,7 @@ public class TaskLockManager {
             if (latestOwner == null) {
                 task.setLockOwner(null);
                 task.setLockDate(null);
+                task.setLockLeaseMillis(0);
                 LOGGER.debug("LOCK-DIAG [{}] node {} : releaseLock() no-op, 
store already unlocked",
                     task.getItemId(), nodeId);
                 return true;
@@ -406,8 +413,10 @@ public class TaskLockManager {
 
             toSave.setLockOwner(null);
             toSave.setLockDate(null);
+            toSave.setLockLeaseMillis(0);
             task.setLockOwner(null);
             task.setLockDate(null);
+            task.setLockLeaseMillis(0);
 
             // Compare-and-set on the freshly loaded seq_no/primary_term, not 
a blind overwrite:
             // a peer may win a legitimate CAS-based lock acquisition in the 
window between our
@@ -474,6 +483,7 @@ public class TaskLockManager {
             }
 
             latest.setLockDate(new Date());
+            latest.setLockLeaseMillis(lockTimeout);
 
             // Compare-and-set on the fresh store view: if a peer stole the 
lock between the
             // read above and this write, renewal fails closed instead of 
resurrecting our lock.
@@ -486,6 +496,7 @@ public class TaskLockManager {
             // the executing thread's later compare-and-set writes are checked 
against the
             // store's current version, not the pre-renewal one.
             task.setLockDate(latest.getLockDate());
+            task.setLockLeaseMillis(latest.getLockLeaseMillis());
             copyOccMetadata(latest, task);
             LOGGER.debug("LOCK-DIAG [{}] node {} : renewLock() succeeded, new 
lockDate={}",
                 task.getItemId(), nodeId, latest.getLockDate());
@@ -537,12 +548,22 @@ public class TaskLockManager {
             return true;
         }
 
+        // Judge expiry against the lease the OWNER recorded with the lock, 
not this node's own
+        // configured timeout. The owner renews on a cadence derived from its 
own timeout
+        // (lockTimeout/3, see TaskExecutionManager#startLockRenewal), so a 
node configured with a
+        // shorter timeout than the owner's renewal cadence would otherwise 
declare a live,
+        // renewed lock dead in the gap between two renewals and "recover" a 
task that is still
+        // executing — observed as double execution under divergent per-node 
configuration.
+        // Locks written before lease recording carry no lease (0); only for 
those does this
+        // node's own timeout remain the best available guess.
+        long lease = task.getLockLeaseMillis() > 0 ? task.getLockLeaseMillis() 
: lockTimeout;
         long now = System.currentTimeMillis();
         long lockAge = now - task.getLockDate().getTime();
-        boolean expired = lockAge > lockTimeout;
+        boolean expired = lockAge > lease;
         LOGGER.debug("LOCK-DIAG isLockExpired() : task={}, lockDate={} ({}), 
now={}, lockAge={}ms, "
-                + "lockTimeout={}ms -> expired={}",
-            task.getItemId(), task.getLockDate(), 
task.getLockDate().getTime(), now, lockAge, lockTimeout, expired);
+                + "lease={}ms (recorded={}ms, own timeout={}ms) -> expired={}",
+            task.getItemId(), task.getLockDate(), 
task.getLockDate().getTime(), now, lockAge,
+            lease, task.getLockLeaseMillis(), lockTimeout, expired);
         return expired;
     }
 }
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 43b29e368..d41c34c30 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
@@ -255,6 +255,7 @@ public class TaskRecoveryManager {
         }
         latest.setLockOwner(null);
         latest.setLockDate(null);
+        latest.setLockLeaseMillis(0);
 
         // Record the crash in execution history
         recordCrash(latest, previousOwner);
@@ -273,6 +274,7 @@ public class TaskRecoveryManager {
         task.setStatus(latest.getStatus());
         task.setLockOwner(null);
         task.setLockDate(null);
+        task.setLockLeaseMillis(0);
         task.setStatusDetails(latest.getStatusDetails());
         task.setCurrentStep(latest.getCurrentStep());
         task.setLastError(latest.getLastError());
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 c8877bd72..d8bf91266 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
@@ -148,6 +148,7 @@ public class TaskStateManager {
     private void clearTaskExecution(ScheduledTask task) {
         task.setLockOwner(null);
         task.setLockDate(null);
+        task.setLockLeaseMillis(0);
         task.setWaitingForTaskType(null);
         task.setCurrentStep(null);
     }
@@ -162,6 +163,7 @@ public class TaskStateManager {
     private void clearLockInfo(ScheduledTask task) {
         task.setLockOwner(null);
         task.setLockDate(null);
+        task.setLockLeaseMillis(0);
     }
 
     private void updateRunningState(ScheduledTask task, String nodeId) {
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/ScheduledTaskLeaseSerializationTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/ScheduledTaskLeaseSerializationTest.java
new file mode 100644
index 000000000..126cee701
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/ScheduledTaskLeaseSerializationTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.services.impl.scheduler;
+
+import org.apache.unomi.api.Item;
+import org.apache.unomi.api.tasks.ScheduledTask;
+import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Persistence-format coverage for {@link ScheduledTask#getLockLeaseMillis()}.
+ * <p>
+ * The lock lease is a cross-node security decision (it decides who may 
declare a peer dead), so
+ * its survival through the production serializer is not an implementation 
detail: a field that
+ * silently fails to round-trip would degrade every observer to the legacy 
observer-timeout
+ * fallback and quietly reintroduce the divergent-timeout double-execution 
bug. Both store read
+ * paths are exercised: direct class binding, and the {@code Item}-dispatched 
path the persistence
+ * services actually use ({@code readValue(json, Item.class)} via {@code 
ItemDeserializer}).
+ */
+public class ScheduledTaskLeaseSerializationTest {
+
+    private CustomObjectMapper mapper;
+
+    @BeforeEach
+    public void setUp() {
+        mapper = CustomObjectMapper.getCustomInstance();
+        mapper.registerBuiltInItemTypeClass(ScheduledTask.ITEM_TYPE, 
ScheduledTask.class);
+    }
+
+    private ScheduledTask lockedTask() {
+        ScheduledTask task = new ScheduledTask();
+        task.setItemId("lease-serialization-test");
+        task.setTaskType("lease-serialization-test");
+        task.setStatus(ScheduledTask.TaskStatus.RUNNING);
+        task.setLockOwner("node-a");
+        task.setLockDate(new Date());
+        task.setLockLeaseMillis(12345);
+        return task;
+    }
+
+    @Test
+    public void leaseSurvivesRoundTripViaDirectClassBinding() throws Exception 
{
+        String json = mapper.writeValueAsString(lockedTask());
+        assertTrue(json.contains("\"lockLeaseMillis\":12345"), "lease must be 
serialized: " + json);
+
+        ScheduledTask back = mapper.readValue(json, ScheduledTask.class);
+        assertEquals(12345, back.getLockLeaseMillis());
+        assertEquals("node-a", back.getLockOwner());
+    }
+
+    @Test
+    public void leaseSurvivesRoundTripViaItemDispatchedPath() throws Exception 
{
+        // This is the path the persistence services use when loading store 
documents.
+        String json = mapper.writeValueAsString(lockedTask());
+        Item item = mapper.readValue(json, Item.class);
+        assertTrue(item instanceof ScheduledTask, "itemType dispatch should 
yield a ScheduledTask");
+        assertEquals(12345, ((ScheduledTask) item).getLockLeaseMillis());
+    }
+
+    /**
+     * A document written BEFORE lease recording (no {@code lockLeaseMillis} 
field) must load with
+     * lease 0, which {@code TaskLockManager#isLockExpired} treats as "fall 
back to the observer's
+     * own timeout" — i.e. exactly the pre-lease behaviour, so a rolling 
upgrade cannot make old
+     * locks unexpirable or instantly expired.
+     */
+    @Test
+    public void legacyDocumentWithoutLeaseLoadsAsZero() throws Exception {
+        String legacyJson = "{" +
+            "\"itemId\":\"legacy-task\"," +
+            "\"itemType\":\"scheduledTask\"," +
+            "\"taskType\":\"legacy-task\"," +
+            "\"status\":\"RUNNING\"," +
+            "\"lockOwner\":\"old-node\"," +
+            "\"lockDate\":\"2026-01-01T00:00:00Z\"" +
+            "}";
+        Item item = mapper.readValue(legacyJson, Item.class);
+        ScheduledTask task = (ScheduledTask) item;
+        assertEquals(0, task.getLockLeaseMillis(), "missing lease must read as 
0 (legacy fallback)");
+        assertEquals("old-node", task.getLockOwner());
+    }
+
+    /**
+     * A document written by a NEWER version carrying a field this version 
does not know must
+     * still deserialize (rolling upgrade window: older binaries keep reading 
scheduler state
+     * written by upgraded peers). Pinned by {@code 
@JsonIgnoreProperties(ignoreUnknown = true)}
+     * on ScheduledTask — without it, Jackson's default rejects the first 
unknown field and the
+     * older node loses access to every task document the newer node has 
touched.
+     */
+    @Test
+    public void documentFromNewerVersionWithUnknownFieldStillLoads() throws 
Exception {
+        String futureJson = "{" +
+            "\"itemId\":\"future-task\"," +
+            "\"itemType\":\"scheduledTask\"," +
+            "\"taskType\":\"future-task\"," +
+            "\"status\":\"SCHEDULED\"," +
+            "\"lockLeaseMillis\":5000," +
+            "\"someFieldAddedInAFutureVersion\":\"whatever\"" +
+            "}";
+        Item item = mapper.readValue(futureJson, Item.class);
+        assertNotNull(item);
+        assertEquals(5000, ((ScheduledTask) item).getLockLeaseMillis());
+    }
+}
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerDiagnosticsExtension.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerDiagnosticsExtension.java
new file mode 100644
index 000000000..73bf1eb5d
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerDiagnosticsExtension.java
@@ -0,0 +1,271 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.services.impl.scheduler;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.CyclicBufferAppender;
+import org.apache.unomi.api.tasks.ScheduledTask;
+import org.apache.unomi.persistence.spi.PersistenceService;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.TestExecutionExceptionHandler;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Field;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Makes an intermittent scheduler test failure diagnosable from the CI log it 
failed in.
+ * <p>
+ * Scheduler failures are almost always about state and ordering: which node 
held a lock, when it
+ * was renewed, who decided it had expired, what the store actually contained. 
The scheduler already
+ * logs all of that as {@code LOCK-DIAG} lines, but only at DEBUG, and CI does 
not run at DEBUG --
+ * so every intermittent failure historically arrived as a bare assertion 
message with the evidence
+ * discarded. Re-running with {@code -DTEST_LOG_LEVEL=DEBUG} rarely helps, 
because an intermittent
+ * failure usually does not recur on demand.
+ * <p>
+ * This extension therefore captures DEBUG for the scheduler packages into a 
bounded in-memory ring
+ * buffer that costs nothing on a passing test, and dumps it -- together with 
a snapshot of every
+ * task document in the store -- at the moment a test fails. The snapshot is 
taken from
+ * {@link TestExecutionExceptionHandler}, which runs before the test's own 
{@code @AfterEach}
+ * teardown, so the store is still alive and holds the state that caused the 
failure rather than
+ * whatever cleanup left behind.
+ * <p>
+ * When {@code -DTEST_LOG_LEVEL} is set explicitly, the extension stays out of 
the way and leaves
+ * logback's configured behaviour alone: an explicit request for console 
output should get console
+ * output.
+ */
+public class SchedulerDiagnosticsExtension
+        implements BeforeEachCallback, AfterEachCallback, 
TestExecutionExceptionHandler {
+
+    /** Enough lines to cover several checker ticks across a handful of nodes. 
*/
+    private static final int BUFFER_SIZE = 4000;
+
+    /** Packages whose DEBUG output explains scheduler behaviour. */
+    private static final String[] CAPTURED_LOGGERS = {
+        "org.apache.unomi.services.impl.scheduler",
+        "org.apache.unomi.services.impl.cluster"
+    };
+
+    private static final String APPENDER_NAME = 
"scheduler-diagnostics-ring-buffer";
+    private static final ExtensionContext.Namespace NAMESPACE =
+        ExtensionContext.Namespace.create(SchedulerDiagnosticsExtension.class);
+
+    private static boolean explicitLogLevelRequested() {
+        String requested = System.getProperty("TEST_LOG_LEVEL");
+        return requested != null && !requested.trim().isEmpty();
+    }
+
+    @Override
+    public void beforeEach(ExtensionContext context) {
+        if (explicitLogLevelRequested()) {
+            return;
+        }
+        if (!(LoggerFactory.getILoggerFactory() instanceof LoggerContext)) {
+            return; // not logback (shaded/OSGi runs); nothing to attach to
+        }
+        LoggerContext loggerContext = (LoggerContext) 
LoggerFactory.getILoggerFactory();
+
+        CyclicBufferAppender<ILoggingEvent> buffer = new 
CyclicBufferAppender<>();
+        buffer.setContext(loggerContext);
+        buffer.setName(APPENDER_NAME);
+        buffer.setMaxSize(BUFFER_SIZE);
+        buffer.start();
+
+        for (String name : CAPTURED_LOGGERS) {
+            ch.qos.logback.classic.Logger logger = 
loggerContext.getLogger(name);
+            // additive=false keeps the captured DEBUG out of the console on 
passing runs; the
+            // buffer dump below is the only consumer, and it only fires on 
failure.
+            logger.setLevel(Level.DEBUG);
+            logger.setAdditive(false);
+            logger.addAppender(buffer);
+        }
+        context.getStore(NAMESPACE).put(APPENDER_NAME, buffer);
+    }
+
+    @Override
+    public void afterEach(ExtensionContext context) {
+        @SuppressWarnings("unchecked")
+        CyclicBufferAppender<ILoggingEvent> buffer =
+            context.getStore(NAMESPACE).remove(APPENDER_NAME, 
CyclicBufferAppender.class);
+        if (buffer == null || !(LoggerFactory.getILoggerFactory() instanceof 
LoggerContext)) {
+            return;
+        }
+        LoggerContext loggerContext = (LoggerContext) 
LoggerFactory.getILoggerFactory();
+        for (String name : CAPTURED_LOGGERS) {
+            ch.qos.logback.classic.Logger logger = 
loggerContext.getLogger(name);
+            logger.detachAppender(buffer);
+            logger.setAdditive(true);
+            logger.setLevel(null); // inherit from root again
+        }
+        buffer.stop();
+    }
+
+    @Override
+    public void handleTestExecutionException(ExtensionContext context, 
Throwable throwable)
+            throws Throwable {
+        StringBuilder report = new StringBuilder();
+        report.append("\n================ SCHEDULER DIAGNOSTICS for ")
+            .append(context.getRequiredTestClass().getSimpleName()).append('.')
+            .append(context.getRequiredTestMethod().getName())
+            .append(" ================\n")
+            .append("Failure: ").append(throwable).append('\n');
+
+        appendTaskSnapshot(report, context);
+        appendBufferedLog(report, context);
+
+        report.append("================ END SCHEDULER DIAGNOSTICS 
================\n");
+        // stdout, not a logger: this must survive whatever logging 
configuration is in force, and
+        // Surefire captures stdout into the report the CI log shows.
+        System.out.println(report);
+
+        throw throwable;
+    }
+
+    /**
+     * Dumps every task document the test's persistence service can see. Taken 
before teardown, so
+     * this is the state that produced the failure.
+     */
+    private void appendTaskSnapshot(StringBuilder report, ExtensionContext 
context) {
+        report.append("\n-- task documents in the store at failure time --\n");
+        PersistenceService persistenceService = 
findPersistenceService(context);
+        if (persistenceService == null) {
+            report.append("  (no PersistenceService field found on the test 
instance)\n");
+            return;
+        }
+        try {
+            // getAllItems is search-based, and both the in-memory harness and 
a real cluster hold a
+            // refresh interval behind the store. Force visibility first: the 
test has already
+            // failed, so there is no state left worth preserving, and a 
snapshot that silently
+            // reports "(none)" because of refresh lag is worse than useless.
+            try {
+                persistenceService.refreshIndex(ScheduledTask.class);
+                persistenceService.refresh();
+            } catch (Exception ignored) {
+                report.append("  (refresh before snapshot failed; list may lag 
the store)\n");
+            }
+            List<ScheduledTask> tasks =
+                persistenceService.getAllItems(ScheduledTask.class, 0, -1, 
null).getList();
+            if (tasks.isEmpty()) {
+                report.append("  (none)\n");
+                return;
+            }
+            SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss.SSS");
+            for (ScheduledTask task : tasks) {
+                report.append("  ").append(task.getItemId())
+                    .append(" type=").append(task.getTaskType())
+                    .append(" status=").append(task.getStatus())
+                    .append(" enabled=").append(task.isEnabled())
+                    .append(" execNode=").append(task.getExecutingNodeId())
+                    .append(" lockOwner=").append(task.getLockOwner())
+                    .append(" lockDate=")
+                    .append(task.getLockDate() == null ? "null" : 
fmt.format(task.getLockDate()))
+                    .append(" 
lease=").append(task.getLockLeaseMillis()).append("ms")
+                    .append(" success=").append(task.getSuccessCount())
+                    .append(" failure=").append(task.getFailureCount())
+                    .append(" nextExec=")
+                    .append(task.getNextScheduledExecution() == null
+                        ? "null" : 
fmt.format(task.getNextScheduledExecution()))
+                    .append(" history=").append(historySize(task))
+                    .append(" lastError=").append(task.getLastError())
+                    .append('\n');
+            }
+        } catch (Exception e) {
+            report.append("  (failed to read tasks: ").append(e).append(")\n");
+        }
+    }
+
+    private static int historySize(ScheduledTask task) {
+        Map<String, Object> details = task.getStatusDetails();
+        if (details == null) {
+            return 0;
+        }
+        Object history = details.get("executionHistory");
+        return history instanceof Collection ? ((Collection<?>) 
history).size() : 0;
+    }
+
+    /**
+     * Finds a {@link PersistenceService} on the test instance. Reflection 
rather than an interface
+     * the tests must implement: the point is that adding this extension to a 
test class costs one
+     * annotation and no other change.
+     */
+    private PersistenceService findPersistenceService(ExtensionContext 
context) {
+        Object testInstance = context.getTestInstance().orElse(null);
+        if (testInstance == null) {
+            return null;
+        }
+        for (Class<?> type = testInstance.getClass(); type != null; type = 
type.getSuperclass()) {
+            for (Field field : type.getDeclaredFields()) {
+                if 
(!PersistenceService.class.isAssignableFrom(field.getType())) {
+                    continue;
+                }
+                try {
+                    field.setAccessible(true);
+                    PersistenceService value = (PersistenceService) 
field.get(testInstance);
+                    if (value != null) {
+                        return value;
+                    }
+                } catch (ReflectiveOperationException | RuntimeException 
ignored) {
+                    // Not readable; keep looking.
+                }
+            }
+        }
+        return null;
+    }
+
+    private void appendBufferedLog(StringBuilder report, ExtensionContext 
context) {
+        @SuppressWarnings("unchecked")
+        CyclicBufferAppender<ILoggingEvent> buffer =
+            context.getStore(NAMESPACE).get(APPENDER_NAME, 
CyclicBufferAppender.class);
+        if (buffer == null) {
+            report.append("\n-- captured scheduler DEBUG log --\n")
+                .append("  (not captured; -DTEST_LOG_LEVEL was set, so the log 
went to the console)\n");
+            return;
+        }
+        int count = buffer.getLength();
+        report.append("\n-- captured scheduler DEBUG log (last ").append(count)
+            .append(" events, newest last) --\n");
+        if (count == 0) {
+            report.append("  (empty)\n");
+            return;
+        }
+        SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss.SSS");
+        for (int i = 0; i < count; i++) {
+            ILoggingEvent event = buffer.get(i);
+            if (event == null) {
+                continue;
+            }
+            report.append("  ").append(fmt.format(new 
Date(event.getTimeStamp())))
+                .append(" [").append(event.getThreadName()).append("] ")
+                .append(event.getLevel()).append(' ')
+                .append(shortLoggerName(event.getLoggerName())).append(" - ")
+                .append(event.getFormattedMessage()).append('\n');
+        }
+    }
+
+    private static String shortLoggerName(String loggerName) {
+        int lastDot = loggerName.lastIndexOf('.');
+        return lastDot < 0 ? loggerName : loggerName.substring(lastDot + 1);
+    }
+}
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceClusterRaceTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceClusterRaceTest.java
index 62ae062bd..d2ce88f7f 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceClusterRaceTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceClusterRaceTest.java
@@ -75,6 +75,7 @@ import static org.mockito.Mockito.when;
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
 @Tag("ClusterTests")
+@ExtendWith(SchedulerDiagnosticsExtension.class)
 public class SchedulerServiceClusterRaceTest {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SchedulerServiceClusterRaceTest.class);
 
@@ -331,6 +332,150 @@ public class SchedulerServiceClusterRaceTest {
         assertEquals(1, executors.size(), "Exactly one node should have 
executed");
     }
 
+    /**
+     * A node configured with a SHORTER lock timeout than a peer must not 
"recover" that peer's
+     * live, renewed lock.
+     * <p>
+     * The owner renews its lock every {@code lockTimeout/3} — a cadence 
derived from its OWN
+     * timeout. Before lock leases were recorded ({@link 
ScheduledTask#getLockLeaseMillis()}),
+     * expiry was judged against the <em>observer's</em> timeout, so an 
observer whose timeout was
+     * shorter than the owner's renewal cadence saw every renewal gap as an 
expired lock: it marked
+     * the live execution CRASHED and cleared the lock, and the next peer tick 
re-dispatched the
+     * task while the original execution was still running. This reproduced 
deterministically as
+     * {@code maxConcurrent=2} with a 1s-timeout observer against 10s-timeout 
workers, and is also a
+     * production hazard under config drift or rolling upgrades. The recorded 
lease makes expiry
+     * owner-relative, so the divergent observer becomes harmless.
+     */
+    @Test
+    public void testShortTimeoutObserverCannotRecoverLiveRenewedLock() throws 
Exception {
+        SchedulerServiceImpl worker1 = createNode("lease-worker1", true, 
10000);
+        SchedulerServiceImpl worker2 = createNode("lease-worker2", true, 
10000);
+        // Divergent config: this node judges everything with a 500ms timeout. 
It registers no
+        // executor for the task type, so any double execution must come via a 
worker re-dispatch.
+        SchedulerServiceImpl watchdog = createNode("lease-watchdog", true, 
500);
+        seedActiveNodes("lease-worker1", "lease-worker2", "lease-watchdog");
+
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicInteger executions = new AtomicInteger(0);
+
+        TaskExecutor executor = new TaskExecutor() {
+            @Override
+            public String getTaskType() {
+                return "lease-liveness-test";
+            }
+
+            @Override
+            public void execute(ScheduledTask task, TaskStatusCallback 
callback) throws Exception {
+                executions.incrementAndGet();
+                started.countDown();
+                assertTrue(release.await(TEST_TIMEOUT_MS, 
TimeUnit.MILLISECONDS));
+                callback.complete();
+            }
+        };
+        worker1.registerTaskExecutor(executor);
+        worker2.registerTaskExecutor(executor);
+
+        ScheduledTask task = worker1.newTask("lease-liveness-test")
+            .disallowParallelExecution()
+            .asOneShot()
+            .schedule();
+
+        assertTrue(started.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS), "One 
worker should start the task");
+
+        // Let the lock age past the watchdog's 500ms timeout while staying 
far inside the owner's
+        // 10s lease (the owner's renewal cadence is 10s/3, so the age check 
below cannot be
+        // satisfied by a renewal racing us — any observed age > 600ms is a 
genuine renewal gap).
+        long deadline = System.currentTimeMillis() + TEST_TIMEOUT_MS;
+        while (System.currentTimeMillis() < deadline) {
+            ScheduledTask stored = persistenceService.load(task.getItemId(), 
ScheduledTask.class);
+            if (stored != null && stored.getLockDate() != null
+                    && System.currentTimeMillis() - 
stored.getLockDate().getTime() > 600) {
+                break;
+            }
+            Thread.sleep(50);
+        }
+
+        // Force the divergent observer's recovery pass repeatedly — the 
deterministic version of
+        // the background tick that used to steal the lock.
+        for (int i = 0; i < 3; i++) {
+            watchdog.recoverCrashedTasks();
+        }
+
+        ScheduledTask observed = persistenceService.load(task.getItemId(), 
ScheduledTask.class);
+        assertEquals(ScheduledTask.TaskStatus.RUNNING, observed.getStatus(),
+            "A live, renewed lock must not be marked CRASHED by a 
shorter-timeout observer");
+        assertNotNull(observed.getLockOwner(), "The owner's lock must not be 
cleared");
+
+        release.countDown();
+
+        ScheduledTask done = waitForStatus(worker1, task.getItemId(), 
ScheduledTask.TaskStatus.COMPLETED, TEST_TIMEOUT_MS);
+        assertEquals(ScheduledTask.TaskStatus.COMPLETED, done.getStatus());
+        assertEquals(1, executions.get(),
+            "The task must execute exactly once despite the divergent-timeout 
observer");
+    }
+
+    /**
+     * The recovery-enabling direction of lease-based expiry: a genuinely DEAD 
owner must still be
+     * recovered, and the moment that happens is decided by the lease the dead 
owner recorded, not
+     * by the survivor's own (here much longer) timeout. This is the guarantee 
that keeps crash
+     * failover working after the lease change — and it is now faster when the 
dead node ran with
+     * a short timeout, because peers no longer wait out their own longer 
opinion.
+     */
+    @Test
+    public void 
testDeadOwnersShortLeaseDrivesPromptRecoveryByPatientSurvivor() throws 
Exception {
+        SchedulerServiceImpl survivor = createNode("lease-survivor", true, 
30_000);
+        seedActiveNodes("lease-survivor");
+
+        CountDownLatch recovered = new CountDownLatch(1);
+        TaskExecutor executor = new TaskExecutor() {
+            @Override
+            public String getTaskType() {
+                return "dead-owner-lease-test";
+            }
+
+            @Override
+            public void execute(ScheduledTask task, TaskStatusCallback 
callback) {
+                recovered.countDown();
+                callback.complete();
+            }
+        };
+        survivor.registerTaskExecutor(executor);
+
+        // Manufacture what a crashed node leaves behind: RUNNING, locked, 
lease recorded from a
+        // short timeout, and silent (no renewal will ever come). lockDate is 
backdated past the
+        // lease so the very first recovery pass can act.
+        ScheduledTask ghost = new ScheduledTask();
+        ghost.setItemId("ghost-owned-task");
+        ghost.setTaskType("dead-owner-lease-test");
+        ghost.setEnabled(true);
+        ghost.setPersistent(true);
+        ghost.setOneShot(true);
+        ghost.setStatus(ScheduledTask.TaskStatus.RUNNING);
+        ghost.setExecutingNodeId("ghost-node");
+        ghost.setLockOwner("ghost-node");
+        ghost.setLockDate(new Date(System.currentTimeMillis() - 2000));
+        ghost.setLockLeaseMillis(500);
+        persistenceService.save(ghost);
+        persistenceService.refreshIndex(ScheduledTask.class);
+        persistenceService.refresh();
+
+        // Force recovery passes rather than waiting for background ticks. The 
survivor's own
+        // timeout is 30s: pre-lease it would have refused to touch this lock 
for 30s, and this
+        // latch (10s) would time out. The recorded 500ms lease is what lets 
it act now.
+        long deadline = System.currentTimeMillis() + TEST_TIMEOUT_MS;
+        while (recovered.getCount() > 0 && System.currentTimeMillis() < 
deadline) {
+            survivor.recoverCrashedTasks();
+            recovered.await(250, TimeUnit.MILLISECONDS);
+        }
+
+        assertTrue(recovered.getCount() == 0,
+            "a patient survivor must recover a dead owner's task as soon as 
the OWNER's lease expires");
+        ScheduledTask done = waitForStatus(survivor, "ghost-owned-task", 
ScheduledTask.TaskStatus.COMPLETED, TEST_TIMEOUT_MS);
+        assertEquals(ScheduledTask.TaskStatus.COMPLETED, done.getStatus(),
+            "the recovered task must run to completion on the survivor");
+    }
+
     @Test
     public void testAffinityOpenFieldAfterBackupWindowsWhenPrimaryDead() 
throws Exception {
         SchedulerServiceImpl backup1 = createNode("aff-backup1", true, 10000);
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 d3e91d56c..0521f476b 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
@@ -74,9 +74,30 @@ import static org.mockito.Mockito.when;
  * - RetryTests: Task retry behavior and delay
  * - MaintenanceTests: Task cleanup and maintenance
  * - QueryTests: Task querying and filtering
+ *
+ * <h3>Debugging</h3>
+ * Logging is configured by {@code src/test/resources/logback-test.xml}; run 
with
+ * {@code -DTEST_LOG_LEVEL=DEBUG} to see the scheduler's {@code LOCK-DIAG} 
traces, which record
+ * every lock acquisition, renewal, expiry verdict and recovery decision. A 
bare assertion
+ * failure from this suite is rarely diagnosable without them.
+ *
+ * <h3>Timing rules for this suite</h3>
+ * The {@code setUp()} scheduler keeps polling in the background for the whole 
test, so:
+ * <ul>
+ *   <li>Multi-node tests that do not use the setUp scheduler must {@code 
preDestroy()} it first —
+ *       otherwise it participates in the shared persistence store as an 
extra, unaccounted node
+ *       (see {@code testNodeFailure} for the pattern).</li>
+ *   <li>Never assert an exact execution count while the task can still fire: 
cancel the task or
+ *       stop the scheduler first, or assert a lower bound.</li>
+ *   <li>Prefer latches the test releases over {@code Thread.sleep(N)} for 
"keep the executor busy
+ *       while I check something" — a fixed sleep is a bet on scheduler timing 
that loaded CI
+ *       runners lose. Sleeps are acceptable as poll intervals inside bounded 
retry loops and as
+ *       genuine workload where the duration itself is the test subject.</li>
+ * </ul>
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
+@ExtendWith(SchedulerDiagnosticsExtension.class)
 public class SchedulerServiceImplTest {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SchedulerServiceImplTest.class);
 
@@ -89,7 +110,17 @@ public class SchedulerServiceImplTest {
     private static final long TEST_TIMEOUT = 15000; // 15 seconds — extra 
margin for loaded CI runners
     /** Time unit for test timeouts */
     private static final TimeUnit TEST_TIME_UNIT = TimeUnit.MILLISECONDS;
-    /** Lock timeout for testing lock expiration */
+    /**
+     * Lock timeout for the setUp scheduler and for multi-node tests, matching
+     * {@code TaskLockManager}'s production default. Deliberately NOT short: 
the setUp scheduler
+     * keeps polling in the background during every test, and a node whose 
lock timeout is shorter
+     * than a peer's renewal cadence (peer timeout / 3) declares that peer's 
live locks expired in
+     * the gap between renewals — before lock leases this stole locks from 
mid-execution tasks and
+     * double-ran them (the CI flake in testConcurrentLockAcquisition). All 
nodes sharing one store
+     * must agree on this value unless lock expiry itself is the behaviour 
under test.
+     */
+    private static final long DEFAULT_LOCK_TIMEOUT = 10000; // 10 seconds
+    /** Short lock timeout for tests that exercise lock expiration; set it 
explicitly per test. */
     private static final long TEST_LOCK_TIMEOUT = 1000; // 1 second
     /** Thread pool size for parallel execution */
     private static final int TEST_THREAD_POOL_SIZE = 4;
@@ -113,18 +144,12 @@ public class SchedulerServiceImplTest {
 
     // Test categories with documentation
     // JUnit 5 provides tags; marker interfaces removed
-
-    private static void configureDebugLogging() {
-        // Enable debug logging for scheduler package
-        
System.setProperty("org.slf4j.simpleLogger.log.org.apache.unomi.services.impl.scheduler",
 "DEBUG");
-        System.setProperty("org.slf4j.simpleLogger.showDateTime", "true");
-        System.setProperty("org.slf4j.simpleLogger.dateTimeFormat", 
"yyyy-MM-dd HH:mm:ss.SSS");
-        System.setProperty("org.slf4j.simpleLogger.showThreadName", "true");
-    }
+    // (An earlier configureDebugLogging() helper set org.slf4j.simpleLogger.* 
properties here;
+    // it was dead code — logback-test.xml binds logback, which ignores those. 
Use
+    // -DTEST_LOG_LEVEL=DEBUG instead, see the class javadoc.)
 
     @BeforeEach
     public void setUp() throws IOException {
-        configureDebugLogging();
         
CustomObjectMapper.getCustomInstance().registerBuiltInItemTypeClass(ScheduledTask.ITEM_TYPE,
 ScheduledTask.class);
 
         securityService = TestHelper.createSecurityService();
@@ -155,9 +180,10 @@ public class SchedulerServiceImplTest {
             false,
             0); // Set TTL to 0 for immediate purging in tests
 
-        // Configure scheduler for testing
+        // Configure scheduler for testing. The lock timeout matches the 
production default and
+        // the multi-node tests' nodes; tests exercising expiry shorten it 
themselves.
         schedulerService.setThreadPoolSize(TEST_THREAD_POOL_SIZE);
-        schedulerService.setLockTimeout(TEST_LOCK_TIMEOUT);
+        schedulerService.setLockTimeout(DEFAULT_LOCK_TIMEOUT);
         schedulerService.postConstruct();
     }
 
@@ -284,7 +310,10 @@ public class SchedulerServiceImplTest {
             .schedule();
 
         assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task 
should execute three times");
-        assertEquals(3, executionCount.get(), "Task should execute exactly 
three times");
+        // Lower bound, not equality: the periodic task keeps firing between 
the latch release
+        // and this line, so an exact count is a race against the next period 
(cf. the fixed-rate
+        // test above, which already asserts >= for the same reason).
+        assertTrue(executionCount.get() >= 3, "Task should execute at least 
three times");
         if (workerError.get() != null) {
             throw new AssertionError("Assertion failed in worker thread", 
workerError.get());
         }
@@ -452,7 +481,10 @@ public class SchedulerServiceImplTest {
     @Test
     @Tag("ClusterTests")
     public void testClusteringSupport() throws Exception {
-        // Test clustering behavior with multiple nodes
+        // Test clustering behavior with multiple nodes. The setUp scheduler 
is not part of this
+        // cluster: stop it so it cannot interfere with the three nodes' tasks 
or the node
+        // detection markers below (testNodeFailure pattern).
+        schedulerService.preDestroy();
         SchedulerServiceImpl node1 = 
TestHelper.createSchedulerService("node1", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl node2 = 
TestHelper.createSchedulerService("node2", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl nonExecutorNode = 
TestHelper.createSchedulerService("node3", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, false, true);
@@ -470,7 +502,10 @@ public class SchedulerServiceImplTest {
             persistenceService.refresh();
 
             CountDownLatch exclusiveLatch = new CountDownLatch(1);
-            CountDownLatch allNodesLatch = new CountDownLatch(3); // one 
execution observed per node
+            // Opens on the FIRST runOnAllNodes execution, on whichever node 
wins the first
+            // round; allNodesNodes records the distinct winners (see the 
comment below on why
+            // "all three nodes" is not a property the implementation 
promises).
+            CountDownLatch allNodesLatch = new CountDownLatch(1);
             Set<String> exclusiveNodes = ConcurrentHashMap.newKeySet();
             Set<String> allNodesNodes = ConcurrentHashMap.newKeySet();
 
@@ -496,9 +531,8 @@ public class SchedulerServiceImplTest {
 
                 @Override
                 public void execute(ScheduledTask task, TaskStatusCallback 
callback) {
-                    if (allNodesNodes.add(task.getExecutingNodeId())) {
-                        allNodesLatch.countDown();
-                    }
+                    allNodesNodes.add(task.getExecutingNodeId());
+                    allNodesLatch.countDown();
                     callback.complete();
                 }
             };
@@ -531,13 +565,22 @@ public class SchedulerServiceImplTest {
                 exclusiveNodes.contains("node3"),
                 "Exclusive task must not execute on a non-executor node");
 
+            // What runOnAllNodes actually promises, as implemented: ANY node 
- including a
+            // non-executor - may poll and run the task. It does NOT promise 
that every node runs
+            // it: all nodes share the task's single schedule (one 
lastExecutionDate /
+            // nextScheduledExecution on one document), so each period has ONE 
phase-dependent
+            // winner and there is no fairness across nodes. This test used to 
demand an execution
+            // from all three nodes within the timeout, which made it a 
lottery over checker-tick
+            // phases - the "runOnAllNodes task should execute on every node" 
CI flake. The
+            // non-executor half of the guarantee is pinned deterministically 
in
+            // testRunOnAllNodesExecutesOnNonExecutorNode, where the 
non-executor is the only node.
             assertTrue(
                 allNodesLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT),
-                "runOnAllNodes task should execute on every node including 
non-executors");
-            assertTrue(allNodesNodes.contains("node1"), "runOnAllNodes should 
run on node1");
-            assertTrue(allNodesNodes.contains("node2"), "runOnAllNodes should 
run on node2");
-            assertTrue(allNodesNodes.contains("node3"), "runOnAllNodes should 
run on non-executor node3");
+                "runOnAllNodes task should execute on at least one node");
 
+            // Keep the lock-inspection task's execution alive until this test 
has finished
+            // inspecting its lock, instead of betting on a fixed sleep 
outlasting the checks.
+            CountDownLatch lockTaskRelease = new CountDownLatch(1);
             TaskExecutor clusterLockTestExecutor = new TaskExecutor() {
                 @Override
                 public String getTaskType() {
@@ -546,7 +589,7 @@ public class SchedulerServiceImplTest {
                 @Override
                 public void execute(ScheduledTask task, TaskStatusCallback 
callback) {
                     try {
-                        Thread.sleep(5000);
+                        lockTaskRelease.await(TEST_TIMEOUT, TEST_TIME_UNIT);
                         callback.complete();
                     } catch (InterruptedException e) {
                         callback.fail(e.getMessage());
@@ -554,39 +597,103 @@ public class SchedulerServiceImplTest {
                 }
             };
 
-            schedulerService.registerTaskExecutor(clusterLockTestExecutor);
+            // Register on the cluster's own executor nodes (the setUp 
scheduler is stopped).
+            node1.registerTaskExecutor(clusterLockTestExecutor);
+            node2.registerTaskExecutor(clusterLockTestExecutor);
 
-            // Test lock management
-            ScheduledTask lockTask = node1.newTask("cluster-lock-test")
-                .disallowParallelExecution()
-                .schedule();
+            try {
+                // Test lock management
+                ScheduledTask lockTask = node1.newTask("cluster-lock-test")
+                    .disallowParallelExecution()
+                    .schedule();
+
+                    // Refresh persistence to ensure task updates are 
available (handles refresh delay)
+                persistenceService.refresh();
+                // Wait until the task has a lock owner. Deadline-based rather 
than
+                // TestHelper.retryUntil's fixed 20x100ms budget, which a 
loaded runner exceeds
+                // (dispatch needs a checker tick plus the simulated refresh 
delay).
+                ScheduledTask lockedTask = null;
+                long lockDeadline = System.currentTimeMillis() + TEST_TIMEOUT;
+                while (System.currentTimeMillis() < lockDeadline) {
+                    lockedTask = persistenceService.load(lockTask.getItemId(), 
ScheduledTask.class);
+                    if (lockedTask != null && lockedTask.getLockOwner() != 
null) {
+                        break;
+                    }
+                    Thread.sleep(100);
+                }
+                assertNotNull(lockedTask, "Lock task should be persisted");
+                assertNotNull(lockedTask.getLockOwner(), "Task should have 
lock owner");
+                assertNotNull(lockedTask.getLockDate(), "Task should have lock 
date");
+
+                // Test lock release - directly update task in persistence
+                lockedTask.setLockOwner(null);
+                lockedTask.setLockDate(null);
+                lockedTask.setLockLeaseMillis(0);
+                persistenceService.save(lockedTask);
+
+                // Refresh index to ensure changes are visible
+                persistenceService.refreshIndex(ScheduledTask.class);
+
+                // Get latest state and verify lock release
+                ScheduledTask releasedTask = 
persistenceService.load(lockTask.getItemId(), ScheduledTask.class);
+                assertNull(releasedTask.getLockOwner(), "Lock should be 
released");
+            } finally {
+                lockTaskRelease.countDown();
+            }
 
-            // Refresh persistence to ensure task updates are available 
(handles refresh delay)
-            persistenceService.refresh();
-            // Retry until task has lock owner (handles refresh delay for 
updates)
-            ScheduledTask lockedTask = TestHelper.retryUntil(
-                () -> persistenceService.load(lockTask.getItemId(), 
ScheduledTask.class),
-                t -> t != null && t.getLockOwner() != null
-            );
-            assertNotNull(lockedTask.getLockOwner(), "Task should have lock 
owner");
-            assertNotNull(lockedTask.getLockDate(), "Task should have lock 
date");
+        } finally {
+            node1.preDestroy();
+            node2.preDestroy();
+            nonExecutorNode.preDestroy();
+        }
+    }
 
-            // Test lock release - directly update task in persistence
-            lockedTask.setLockOwner(null);
-            lockedTask.setLockDate(null);
-            persistenceService.save(lockedTask);
+    /**
+     * The non-executor half of the runOnAllNodes guarantee, pinned 
deterministically: a node
+     * with {@code executorNode=false} must still poll for and execute 
runOnAllNodes tasks.
+     * <p>
+     * testClusteringSupport cannot assert this reliably — with executor nodes 
present, all nodes
+     * race on the task's single shared schedule and there is no fairness, so 
whether the
+     * non-executor ever wins a round within the timeout is checker-phase 
luck. Here the
+     * non-executor is the ONLY node, so if it does not poll runOnAllNodes 
work (the regression
+     * this pins), nothing executes and the latch times out.
+     */
+    @Test
+    @Tag("ClusterTests")
+    public void testRunOnAllNodesExecutesOnNonExecutorNode() throws Exception {
+        schedulerService.preDestroy();
+        SchedulerServiceImpl nonExecutorOnly = 
TestHelper.createSchedulerService(
+            "solo-non-executor", persistenceService, executionContextManager, 
bundleContext, clusterService, -1, false, true);
 
-            // Refresh index to ensure changes are visible
-            persistenceService.refreshIndex(ScheduledTask.class);
+        try {
+            CountDownLatch executed = new CountDownLatch(1);
+            AtomicReference<String> executingNode = new AtomicReference<>();
 
-            // Get latest state and verify lock release
-            ScheduledTask releasedTask = 
persistenceService.load(lockTask.getItemId(), ScheduledTask.class);
-            assertNull(releasedTask.getLockOwner(), "Lock should be released");
+            TaskExecutor executor = new TaskExecutor() {
+                @Override
+                public String getTaskType() {
+                    return "all-nodes-solo-test";
+                }
 
+                @Override
+                public void execute(ScheduledTask task, TaskStatusCallback 
callback) {
+                    executingNode.set(task.getExecutingNodeId());
+                    executed.countDown();
+                    callback.complete();
+                }
+            };
+            nonExecutorOnly.registerTaskExecutor(executor);
+
+            nonExecutorOnly.newTask("all-nodes-solo-test")
+                .runOnAllNodes()
+                .withPeriod(100, TimeUnit.MILLISECONDS)
+                .schedule();
+
+            assertTrue(executed.await(TEST_TIMEOUT, TEST_TIME_UNIT),
+                "a non-executor node must poll for and run runOnAllNodes 
tasks");
+            assertEquals("solo-non-executor", executingNode.get());
         } finally {
-            node1.preDestroy();
-            node2.preDestroy();
-            nonExecutorNode.preDestroy();
+            nonExecutorOnly.preDestroy();
         }
     }
 
@@ -748,17 +855,29 @@ public class SchedulerServiceImplTest {
             failureLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT),
             "Task should fail once");
 
-        // Verify metrics and history
+        // The 100ms-period task keeps executing (and failing) after the 
latches fire, so exact
+        // counts are a race against the next period. Cancel it and wait for 
the cancellation to
+        // land before reading anything.
+        schedulerService.cancelTask(task.getItemId());
+        TestHelper.retryUntil(
+            () -> schedulerService.getTask(task.getItemId()),
+            t -> t != null && t.getStatus() != ScheduledTask.TaskStatus.RUNNING
+                && t.getStatus() != ScheduledTask.TaskStatus.SCHEDULED);
+
+        // Verify metrics and history. Successes are exact (the executor only 
ever completes the
+        // first two); failures are a lower bound (every later period failed 
until the cancel won).
         ScheduledTask finalTask = schedulerService.getTask(task.getItemId());
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> history =
             (List<Map<String, Object>>) 
finalTask.getStatusDetails().get("executionHistory");
 
         assertNotNull(history, "Should have execution history");
-        assertEquals(3, history.size(), "Should have 3 history entries");
+        assertTrue(history.size() >= 3, "Should have at least 3 history 
entries, had " + history.size());
         assertEquals(2, finalTask.getSuccessCount(), "Should have 2 successful 
executions");
-        assertEquals(1, finalTask.getFailureCount(), "Should have 1 failed 
execution");
-        assertEquals(3, finalTask.getSuccessCount() + 
finalTask.getFailureCount(), "Total executions should be 3");
+        assertTrue(finalTask.getFailureCount() >= 1,
+            "Should have at least 1 failed execution, had " + 
finalTask.getFailureCount());
+        // No history-size == successCount+failureCount equality here: an 
execution in flight
+        // while the cancel lands may or may not get its failure recorded, by 
design.
 
         // Verify history entries
         int successEntries = 0;
@@ -774,7 +893,7 @@ public class SchedulerServiceImplTest {
         }
 
         assertEquals(2, successEntries, "Should have 2 successful executions");
-        assertEquals(1, failureEntries, "Should have 1 failed execution");
+        assertTrue(failureEntries >= 1, "Should have at least 1 failed 
execution");
 
         // Verify metrics
         assertTrue(schedulerService.getMetric("tasks.completed") > 0, "Should 
have completed tasks metric");
@@ -932,12 +1051,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
@@ -1023,13 +1164,24 @@ public class SchedulerServiceImplTest {
      * tasks that already executed, stranding the task in CRASHED state 
forever. The
      * execution manager must recognize that the execution it owns is still 
alive, reclaim
      * the task and process the failure (and its retry) normally.
+     *
+     * <p>NOTE: since lock renewal was introduced (the lock is re-stamped 
every lockTimeout/3
+     * while the executor runs), a stalled-but-live execution's lock no longer 
expires from
+     * natural timing, so the CRASH-mark this test was written around does not 
fire anymore -
+     * verified from the LOCK-DIAG traces: renewal succeeds throughout the 
stall and no expiry
+     * verdict ever triggers. The test remains valuable as a pin on the 
surviving behaviour
+     * (a failure reported after a stall longer than the lock timeout still 
schedules its
+     * retries and completes), and its assertions were already written to 
tolerate both worlds
+     * (>= 3 executions). The reclaim path itself is now only reachable when 
renewal genuinely
+     * stops (e.g. a GC pause longer than the full lease) and is covered at 
unit level in
+     * TaskExecutionManagerTest.
      */
     @Test
     @Tag("RetryTests")
     public void testOneShotRetryAfterRecoveryMarksLiveExecutionCrashed() 
throws Exception {
-        // setUp() only sets the lock timeout on the scheduler service; the 
lock manager
-        // created by TestHelper keeps its 10s default. Shorten it here so a 
stalled
-        // execution's lock actually expires within this test's stall window.
+        // Shorten the lock timeout so the stall below dwarfs it. 
(setLockTimeout on the service
+        // propagates to the lock manager as well; setting the lock manager 
directly is
+        // equivalent and kept for clarity about what the timeout is FOR here.)
         schedulerService.getLockManager().setLockTimeout(TEST_LOCK_TIMEOUT);
 
         CountDownLatch completionLatch = new CountDownLatch(1);
@@ -1320,6 +1472,7 @@ public class SchedulerServiceImplTest {
         schedulerService.setLockTimeout(TEST_LOCK_TIMEOUT);
 
         CountDownLatch executionLatch = new CountDownLatch(1);
+        CountDownLatch holdRelease = new CountDownLatch(1);
         AtomicBoolean taskStarted = new AtomicBoolean(false);
 
         TaskExecutor executor = new TaskExecutor() {
@@ -1335,8 +1488,9 @@ public class SchedulerServiceImplTest {
                     taskStarted.set(true);
                     executionLatch.countDown();
 
-                    // Hold the lock longer than timeout
-                    Thread.sleep(TEST_LOCK_TIMEOUT * 2);
+                    // Hold the lock until the test has finished inspecting it 
- a latch the
+                    // test releases, not a fixed sleep the test hopes is long 
enough.
+                    holdRelease.await(TEST_TIMEOUT, TEST_TIME_UNIT);
                     callback.complete();
                 } catch (InterruptedException e) {
                     Thread.currentThread().interrupt();
@@ -1359,12 +1513,15 @@ public class SchedulerServiceImplTest {
         // Directly update task to simulate lock expiration
         runningTask.setLockOwner(null);
         runningTask.setLockDate(null);
+        runningTask.setLockLeaseMillis(0);
         persistenceService.save(runningTask);
         persistenceService.refreshIndex(ScheduledTask.class);
 
         // Check lock status after manual release
         ScheduledTask updatedTask = persistenceService.load(task.getItemId(), 
ScheduledTask.class);
         assertNull(updatedTask.getLockOwner(), "Lock should be released after 
manual update");
+
+        holdRelease.countDown();
     }
 
     /**
@@ -1631,6 +1788,12 @@ public class SchedulerServiceImplTest {
     @Test
     @Tag("ClusterTests")
     public void testConcurrentLockAcquisition() throws Exception {
+        // This test is about node1/node2 only: stop the setUp scheduler so 
the "two-node" cluster
+        // really has two nodes (testNodeFailure pattern). It used to stay up 
with a 1s lock
+        // timeout against these nodes' 10s, declare their live locks expired 
between renewals,
+        // and mark the running task CRASHED — which a peer then re-dispatched 
concurrently
+        // (the "expected: <1> but was: <2>" CI flake).
+        schedulerService.preDestroy();
         SchedulerServiceImpl node1 = 
TestHelper.createSchedulerService("node1", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl node2 = 
TestHelper.createSchedulerService("node2", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
 
@@ -1738,6 +1901,8 @@ public class SchedulerServiceImplTest {
     @Test
     @Tag("ClusterTests")
     public void testTaskRebalancing() throws Exception {
+        // Two-node test: stop the setUp scheduler so it is not a hidden third 
participant.
+        schedulerService.preDestroy();
         SchedulerServiceImpl node1 = 
TestHelper.createSchedulerService("node1", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl node2 = null;
         try {
@@ -1861,6 +2026,8 @@ public class SchedulerServiceImplTest {
     @Test
     @Tag("ClusterTests")
     public void testLockStealing() throws Exception {
+        // Two-node test: stop the setUp scheduler so it is not a hidden third 
participant.
+        schedulerService.preDestroy();
         SchedulerServiceImpl node1 = 
TestHelper.createSchedulerService("node1", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl node2 = 
TestHelper.createSchedulerService("node2", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
 
@@ -1946,6 +2113,10 @@ public class SchedulerServiceImplTest {
 
     @Test
     public void testNodeAffinity() throws Exception {
+        // Three-node test. Stop the setUp scheduler: getActiveNodes() falls 
back to scanning
+        // tasks with recent locks, and a foreign recovery pass that clears 
the detection tasks'
+        // locks below would silently shrink the cluster this test asserts on.
+        schedulerService.preDestroy();
         // Create test nodes with cluster service
         SchedulerServiceImpl node1 = 
TestHelper.createSchedulerService("node1", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
         SchedulerServiceImpl node2 = 
TestHelper.createSchedulerService("node2", persistenceService, 
executionContextManager, bundleContext, clusterService, -1, true, true);
@@ -2425,7 +2596,9 @@ public class SchedulerServiceImplTest {
         newSchedulerService.preDestroy();
 
         assertTrue(executed, "Task should execute after scheduler restart");
-        assertEquals(2, executionCount.get(), "Task should have executed 
twice");
+        // Lower bound: the 500ms-period task may legitimately fire again 
between the latch
+        // release and preDestroy() completing on a slow runner.
+        assertTrue(executionCount.get() >= 2, "Task should have executed at 
least twice");
 
         // Verify the reloaded task has same ID
         ScheduledTask reloadedTask = 
persistenceService.load(persistentTask.getItemId(), ScheduledTask.class);
@@ -2757,10 +2930,13 @@ public class SchedulerServiceImplTest {
         assertTrue(
             secondExecutionLatch.await(TEST_TIMEOUT * 2, TEST_TIME_UNIT),
             "Task should execute after restart with dedicated executor");
-        assertEquals(2, executionCount.get(), "Task should execute twice");
 
-        // Clean up
+        // Stop the scheduler before asserting the count, exactly like the 
first assertion above:
+        // the task runs at 100ms fixed rate, so a third tick can fire between 
the latch release
+        // and the assert. After preDestroy() the count is stable; >= 
tolerates a tick that
+        // squeezed in before shutdown took effect.
         newSchedulerService.preDestroy();
+        assertTrue(executionCount.get() >= 2, "Task should execute at least 
twice");
     }
 
     /**
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 bafcc5356..8aa3105b0 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;
@@ -45,6 +49,7 @@ import static org.mockito.Mockito.*;
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
+@ExtendWith(SchedulerDiagnosticsExtension.class)
 public class TaskExecutionManagerTest {
 
     private static final String NODE = "exec-node";
@@ -91,6 +96,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");
@@ -128,6 +189,19 @@ public class TaskExecutionManagerTest {
         assertEquals(ScheduledTask.TaskStatus.SCHEDULED, task.getStatus());
     }
 
+    /**
+     * Waits for the wrapper's asynchronous terminal transition to land on the 
shared task object.
+     * The executor's callback returns before the wrapper finishes its 
bookkeeping, so asserting
+     * the final status right after the latch (or after a fixed sleep) races 
the wrapper thread.
+     */
+    private static void awaitStatus(ScheduledTask task, 
ScheduledTask.TaskStatus expected, long timeoutMs)
+            throws InterruptedException {
+        long deadline = System.currentTimeMillis() + timeoutMs;
+        while (task.getStatus() != expected && System.currentTimeMillis() < 
deadline) {
+            Thread.sleep(20);
+        }
+    }
+
     @Test
     public void testExecuteTaskDuplicateDispatchIsSkipped() throws Exception {
         CountDownLatch started = new CountDownLatch(1);
@@ -148,6 +222,9 @@ public class TaskExecutionManagerTest {
         // Second dispatch while claim held
         executionManager.executeTask(task, executor);
         release.countDown();
+        // Deliberate quiet window for a NEGATIVE assertion: a wrongly 
accepted duplicate
+        // dispatch would start within milliseconds. Too short can only miss a 
violation
+        // (false green), never fail a healthy run.
         Thread.sleep(200);
         assertEquals(1, runs.get());
     }
@@ -273,7 +350,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.FAILED, 5000);
         assertEquals(ScheduledTask.TaskStatus.FAILED, task.getStatus());
         assertEquals(1, task.getFailureCount());
         assertTrue(task.isEnabled());
@@ -298,7 +375,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.SCHEDULED, 5000);
         assertEquals(0, task.getFailureCount());
         assertEquals(ScheduledTask.TaskStatus.SCHEDULED, task.getStatus());
         assertNotNull(task.getNextScheduledExecution());
@@ -340,7 +417,7 @@ public class TaskExecutionManagerTest {
         releaser.start();
         executionManager.shutdown();
         releaser.join(2000);
-        Thread.sleep(200);
+        awaitStatus(task, ScheduledTask.TaskStatus.SCHEDULED, 5000);
         assertEquals(ScheduledTask.TaskStatus.SCHEDULED, task.getStatus());
         assertEquals(1, task.getFailureCount());
         // No second attempt — retry schedule skipped after scheduler shutdown
@@ -362,7 +439,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.COMPLETED, 5000);
         assertEquals(ScheduledTask.TaskStatus.COMPLETED, task.getStatus());
         assertFalse(task.isEnabled());
         assertNull(task.getNextScheduledExecution());
@@ -384,7 +461,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.SCHEDULED, 5000);
         assertEquals(ScheduledTask.TaskStatus.SCHEDULED, task.getStatus());
         assertNotNull(task.getNextScheduledExecution());
         assertTrue(task.getNextScheduledExecution().getTime() >= before + 
5_000);
@@ -406,7 +483,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.COMPLETED, 5000);
         assertEquals(ScheduledTask.TaskStatus.COMPLETED, task.getStatus());
     }
 
@@ -427,6 +504,8 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
+        // Deliberate quiet window for a NEGATIVE assertion (callbacks must 
have been ignored);
+        // a poll cannot confirm that nothing happened.
         Thread.sleep(100);
         assertEquals(ScheduledTask.TaskStatus.CANCELLED, task.getStatus());
         assertEquals(completedBefore, 
metricsManager.getMetric(TaskMetricsManager.METRIC_TASKS_COMPLETED));
@@ -448,7 +527,7 @@ public class TaskExecutionManagerTest {
 
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
+        awaitStatus(task, ScheduledTask.TaskStatus.COMPLETED, 5000);
         assertEquals(ScheduledTask.TaskStatus.COMPLETED, task.getStatus());
         assertFalse(task.isEnabled());
     }
@@ -545,12 +624,12 @@ public class TaskExecutionManagerTest {
         ScheduledTask task = TaskTestFixtures.baseTask("cancel-race");
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
-        assertEquals(ScheduledTask.TaskStatus.CANCELLED, task.getStatus());
         // persistTerminalState() is skipped (terminal transition correctly 
bailed out above), but
         // the wrapper's cleanup still CAS-clears executingNodeId once; that 
write is expected to
         // fail harmlessly against a real store since the document moved on to 
CANCELLED.
-        verify(schedulerService, times(1)).saveTaskWithRefresh(any());
+        // timeout() waits for the asynchronous cleanup instead of betting a 
fixed sleep on it.
+        verify(schedulerService, 
timeout(5000).times(1)).saveTaskWithRefresh(any());
+        assertEquals(ScheduledTask.TaskStatus.CANCELLED, task.getStatus());
         assertEquals(0, 
metricsManager.getMetric(TaskMetricsManager.METRIC_TASKS_COMPLETED));
     }
 
@@ -572,12 +651,12 @@ public class TaskExecutionManagerTest {
         ScheduledTask task = TaskTestFixtures.baseTask("peer-lock");
         executionManager.executeTask(task, executor);
         assertTrue(done.await(5, TimeUnit.SECONDS));
-        Thread.sleep(100);
-        assertEquals(ScheduledTask.TaskStatus.RUNNING, task.getStatus());
         // persistTerminalState() is skipped (peer holds the lock), but the 
wrapper's cleanup still
         // CAS-clears executingNodeId once; that write is expected to fail 
harmlessly against a real
         // store since the peer is the authoritative owner.
-        verify(schedulerService, times(1)).saveTaskWithRefresh(any());
+        // timeout() waits for the asynchronous cleanup instead of betting a 
fixed sleep on it.
+        verify(schedulerService, 
timeout(5000).times(1)).saveTaskWithRefresh(any());
+        assertEquals(ScheduledTask.TaskStatus.RUNNING, task.getStatus());
     }
 
     @Test
@@ -600,9 +679,11 @@ public class TaskExecutionManagerTest {
         };
         ScheduledTask task = TaskTestFixtures.baseTask("abort-prep");
         executionManager.executeTask(task, executor);
-        Thread.sleep(300);
-        assertEquals(1, executed.getCount(), "executor must not run after 
shutdown-abort");
+        // Positive half: wait for the asynchronous abort to land instead of a 
fixed sleep.
+        awaitStatus(task, ScheduledTask.TaskStatus.CRASHED, 5000);
         assertEquals(ScheduledTask.TaskStatus.CRASHED, task.getStatus());
+        // Negative half: the executor must never have run (green-direction 
check).
+        assertEquals(1, executed.getCount(), "executor must not run after 
shutdown-abort");
         assertNull(task.getLockOwner());
         verify(schedulerService, 
atLeastOnce()).saveTask(any(ScheduledTask.class), eq(true));
     }
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskLockManagerTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskLockManagerTest.java
index 249783884..57cec8d58 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskLockManagerTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/TaskLockManagerTest.java
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.*;
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
+@ExtendWith(SchedulerDiagnosticsExtension.class)
 public class TaskLockManagerTest {
 
     private static final String NODE = "lock-node";
@@ -216,6 +217,188 @@ public class TaskLockManagerTest {
         assertFalse(lockManager.isLockExpired(task));
     }
 
+    /**
+     * Expiry must be judged against the lease the OWNER recorded with the 
lock, not this
+     * observer's own timeout. An observer configured shorter than the owner's 
renewal cadence
+     * would otherwise "recover" a live, renewed lock between two renewals and 
double-run the
+     * task (see 
SchedulerServiceClusterRaceTest#testShortTimeoutObserverCannotRecoverLiveRenewedLock
+     * for the end-to-end version).
+     */
+    @Test
+    public void testIsLockExpiredHonoursRecordedLeaseOverObserverTimeout() {
+        ScheduledTask task = TaskTestFixtures.baseTask("lease");
+        task.setLockDate(new Date(System.currentTimeMillis() - 5000));
+
+        // 5s-old lock, observer timeout 1s: expired by observer maths, but 
the owner granted 10s.
+        task.setLockLeaseMillis(10000);
+        assertFalse(lockManager.isLockExpired(task),
+            "a lock inside its owner-recorded lease must not expire under a 
shorter observer timeout");
+
+        // The reverse also holds: an owner that granted itself a SHORT lease 
is expired even
+        // when the observer's own timeout would still consider it live.
+        task.setLockDate(new Date(System.currentTimeMillis() - 500));
+        task.setLockLeaseMillis(100);
+        assertTrue(lockManager.isLockExpired(task),
+            "a lock past its owner-recorded lease is expired regardless of the 
observer timeout");
+    }
+
+    /** Locks written before lease recording (lease 0) fall back to the 
observer's own timeout. */
+    @Test
+    public void testIsLockExpiredFallsBackToObserverTimeoutForLegacyLocks() {
+        ScheduledTask task = TaskTestFixtures.baseTask("legacy");
+        task.setLockDate(new Date(System.currentTimeMillis() - 5000));
+        task.setLockLeaseMillis(0);
+        assertTrue(lockManager.isLockExpired(task));
+
+        task.setLockDate(new Date());
+        assertFalse(lockManager.isLockExpired(task));
+    }
+
+    /** A corrupt negative lease must not wedge or widen the lock: treat it 
like a legacy lock. */
+    @Test
+    public void testIsLockExpiredNegativeLeaseFallsBackToObserverTimeout() {
+        ScheduledTask task = TaskTestFixtures.baseTask("corrupt");
+        task.setLockDate(new Date(System.currentTimeMillis() - 5000));
+        task.setLockLeaseMillis(-1);
+        assertTrue(lockManager.isLockExpired(task), "negative lease + old 
lock: observer timeout applies");
+
+        task.setLockDate(new Date());
+        assertFalse(lockManager.isLockExpired(task), "negative lease + fresh 
lock: observer timeout applies");
+    }
+
+    /** Boundary parity with the observer-timeout path: age == lease is NOT 
yet expired. */
+    @Test
+    public void testIsLockExpiredFalseWhenAgeEqualsRecordedLease() {
+        ScheduledTask task = TaskTestFixtures.baseTask("edge");
+        task.setLockLeaseMillis(2000);
+        task.setLockDate(new Date(System.currentTimeMillis() - 2000));
+        assertFalse(lockManager.isLockExpired(task));
+    }
+
+    /**
+     * An absurd lease (misconfigured or corrupt owner) must not overflow the 
arithmetic. The lock
+     * is honoured as unexpired — the owner declared it, and stealing it risks 
double execution;
+     * a genuinely wedged task from a dead misconfigured node is an operator 
decision, not one a
+     * peer may take unilaterally with a shorter opinion.
+     */
+    @Test
+    public void testIsLockExpiredHugeLeaseIsHonouredWithoutOverflow() {
+        ScheduledTask task = TaskTestFixtures.baseTask("huge");
+        task.setLockLeaseMillis(Long.MAX_VALUE);
+        task.setLockDate(new Date(System.currentTimeMillis() - 100_000));
+        assertFalse(lockManager.isLockExpired(task));
+    }
+
+    // ------------------------------------------------------------------ 
lease stamping
+    // Every path that writes a lock must record the owner's lease with it, 
and every path that
+    // clears a lock must clear the lease: a cleared owner with a leftover 
lease (or the reverse)
+    // would make expiry decisions against a lock that no longer exists.
+
+    @Test
+    public void testParallelAcquireStampsLease() {
+        ScheduledTask task = TaskTestFixtures.baseTask("parallel-lease");
+        task.setAllowParallelExecution(true);
+        assertTrue(lockManager.acquireLock(task));
+        assertEquals(1000, task.getLockLeaseMillis(), "parallel marker must 
record the owner's lease");
+    }
+
+    @Test
+    public void testInMemoryAcquireStampsLease() {
+        ScheduledTask task = TaskTestFixtures.baseTask("mem-lease");
+        task.setPersistent(false);
+        assertTrue(lockManager.acquireLock(task));
+        assertEquals(1000, task.getLockLeaseMillis(), "in-memory lock must 
record the owner's lease");
+    }
+
+    @Test
+    public void testDistributedAcquireStampsLease() {
+        ScheduledTask task = TaskTestFixtures.baseTask("dist-lease");
+        task.setNextScheduledExecution(new Date(System.currentTimeMillis() - 
10_000));
+        ScheduledTask latest = TaskTestFixtures.baseTask("dist-lease");
+        latest.setItemId(task.getItemId());
+        latest.setSystemMetadata("seq_no", 3L);
+        latest.setSystemMetadata("primary_term", 1L);
+        when(schedulerService.getTask(task.getItemId())).thenReturn(latest);
+        
when(schedulerService.saveTaskWithRefresh(any(ScheduledTask.class))).thenReturn(true);
+
+        assertTrue(lockManager.acquireLock(task));
+        assertEquals(1000, task.getLockLeaseMillis(), "distributed lock must 
record the owner's lease");
+    }
+
+    /**
+     * Renewal re-stamps the lease from the owner's CURRENT timeout, so a 
runtime configuration
+     * change (ConfigAdmin update) propagates to the store within one renewal 
interval instead of
+     * peers judging against a stale grant for the rest of the execution.
+     */
+    @Test
+    public void testRenewLockRestampsLeaseFromCurrentTimeout() {
+        ScheduledTask task = TaskTestFixtures.baseTask("renew-lease");
+        task.setLockOwner(NODE);
+        task.setLockDate(new Date());
+        task.setLockLeaseMillis(1000);
+
+        ScheduledTask storeView = TaskTestFixtures.baseTask("renew-lease");
+        storeView.setItemId(task.getItemId());
+        storeView.setLockOwner(NODE);
+        storeView.setLockDate(task.getLockDate());
+        storeView.setLockLeaseMillis(1000);
+        when(schedulerService.getTask(task.getItemId())).thenReturn(storeView);
+        when(schedulerService.saveTaskWithRefresh(storeView)).thenReturn(true);
+
+        lockManager.setLockTimeout(5000);
+        assertTrue(lockManager.renewLock(task));
+        assertEquals(5000, storeView.getLockLeaseMillis(), "store must carry 
the current lease");
+        assertEquals(5000, task.getLockLeaseMillis(), "caller's view must be 
synced to the current lease");
+    }
+
+    @Test
+    public void testReleaseLockClearsLease() {
+        ScheduledTask task = TaskTestFixtures.baseTask("release-lease");
+        task.setLockOwner(NODE);
+        task.setLockDate(new Date());
+        task.setLockLeaseMillis(1000);
+
+        ScheduledTask stored = TaskTestFixtures.baseTask("release-lease");
+        stored.setItemId(task.getItemId());
+        stored.setLockOwner(NODE);
+        stored.setLockDate(task.getLockDate());
+        stored.setLockLeaseMillis(1000);
+        when(schedulerService.getTask(eq(task.getItemId()), 
eq(true))).thenReturn(stored);
+
+        assertTrue(lockManager.releaseLock(task));
+        assertEquals(0, task.getLockLeaseMillis(), "release must clear the 
caller's lease");
+        assertEquals(0, stored.getLockLeaseMillis(), "release must clear the 
persisted lease");
+    }
+
+    /**
+     * The recovery-enabling direction: a dead owner that granted itself a 
SHORT lease is
+     * recoverable by an observer configured with a much longer timeout — the 
observer must not
+     * impose its own, slower opinion on a lock whose owner promised to renew 
far sooner.
+     */
+    @Test
+    public void testNonOwnerCanReleaseLockPastItsShortRecordedLease() {
+        lockManager.setLockTimeout(60_000); // observer is very patient by its 
own config
+
+        ScheduledTask stored = TaskTestFixtures.baseTask("dead-short-lease");
+        stored.setLockOwner("dead-node");
+        stored.setLockDate(new Date(System.currentTimeMillis() - 2000));
+        stored.setLockLeaseMillis(500); // owner promised renewal every ~166ms 
and is silent for 2s
+
+        ScheduledTask callerView = 
TaskTestFixtures.baseTask("dead-short-lease");
+        callerView.setItemId(stored.getItemId());
+        callerView.setLockOwner("dead-node");
+        callerView.setLockDate(stored.getLockDate());
+        callerView.setLockLeaseMillis(500);
+        when(schedulerService.getTask(eq(callerView.getItemId()), 
eq(true))).thenReturn(stored);
+
+        assertTrue(lockManager.isLockExpired(callerView),
+            "a lock silent past its own lease is expired even for a patient 
observer");
+        assertTrue(lockManager.releaseLock(callerView),
+            "recovery must be able to clear a dead owner's expired-by-lease 
lock");
+        assertNull(stored.getLockOwner());
+        assertEquals(0, stored.getLockLeaseMillis());
+    }
+
     @Test
     public void testAffinityBlocksBackupDuringPrimaryWindow() {
         List<String> nodes = Arrays.asList("aaa-node", NODE, "zzz-node");

Reply via email to