[ 
https://issues.apache.org/jira/browse/UNOMI-979?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Serge Huber updated UNOMI-979:
------------------------------
    Summary: Scheduler unit tests fail intermittently on CI for several 
independent reasons  (was: Scheduled tasks can run twice at the same time when 
cluster nodes disagree on the lock timeout)

> Scheduler unit tests fail intermittently on CI for several independent reasons
> ------------------------------------------------------------------------------
>
>                 Key: UNOMI-979
>                 URL: https://issues.apache.org/jira/browse/UNOMI-979
>             Project: Apache Unomi
>          Issue Type: Bug
>          Components: unomi(-core)
>    Affects Versions: unomi-3.1.0
>            Reporter: Serge Huber
>            Assignee: Serge Huber
>            Priority: Major
>             Fix For: unomi-3.1.0
>
>          Time Spent: 10m
>  Remaining Estimate: 0h
>
> h2. Summary
> Unomi runs background jobs on a schedule -- purging expired data, profile 
> housekeeping, and
> anything a plugin registers. In a cluster each job is meant to run on exactly 
> one server at a
> time. We found that two servers can end up running the same job 
> simultaneously. The work is then
> done twice: wasted capacity, data processed twice, and two servers writing 
> results that can
> conflict.
> The cause is servers disagreeing about how long a job's claim stays valid. A 
> server that claims a
> job keeps renewing the claim while it works, on a schedule derived from its 
> own configured
> timeout. Other servers, however, decided whether that claim was still alive 
> using *their own*
> timeout. A server configured with a shorter timeout than its peer concluded 
> the job had died in
> the gap between two renewals, took the job away, and a second server started 
> it while the first
> was still running. Servers can end up with different timeouts through a 
> configuration mistake, or
> temporarily during a rolling upgrade.
> The fix: whoever claims a job now records how long it intends to hold the 
> claim, and every other
> server honours that recorded duration instead of substituting its own 
> opinion. Clusters where all
> servers share the same configuration -- the normal case -- behave exactly as 
> before.
> The same disagreement existed in the unit-test setup, where it was the root 
> cause of scheduler
> tests failing intermittently on busy CI machines. Those tests are fixed here 
> too.
> h2. Technical detail
> A task lock's renewal cadence is derived from its *owner's* configured lock 
> timeout
> ({{lockTimeout/3}}, see {{TaskExecutionManager#startLockRenewal}}), but
> {{TaskLockManager#isLockExpired}} judged expiry against the *observer's* 
> timeout. Any node whose
> timeout is shorter than a peer's renewal cadence therefore sees every renewal 
> gap as an expired
> lock: its recovery pass marks the live execution CRASHED, clears the lock, 
> and the next peer tick
> re-dispatches the task while the original execution is still in flight.
> Reproduced deterministically with a 1s-timeout observer against 10s-timeout 
> workers. The
> {{LOCK-DIAG}} traces show the observer crash-marking a lock 2.9s into its 
> owner's 3.3s renewal
> interval, the owner's own re-dispatch correctly rejected by the 
> duplicate-dispatch guard, and then
> a second worker dispatching while the first is still executing.
> This was also the root cause of the sporadic CI failures:
> * {{SchedulerServiceImplTest.testConcurrentLockAcquisition}} -- "Should have 
> exactly one task executing at a time ==> expected: <1> but was: <2>"
> * {{SchedulerServiceImplTest.testClusteringSupport}} -- "runOnAllNodes task 
> should execute on every node including non-executors"
> The test harness's {{setUp()}} scheduler ran with a 1s timeout alongside test 
> nodes using the 10s
> default, and kept polling in the background for the whole test -- exactly the 
> divergent
> configuration above.
> h2. The fix
> Locks now record the lease their owner granted itself: 
> {{ScheduledTask.lockLeaseMillis}}, stamped
> from the owner's timeout on every acquire and every renewal, cleared on 
> release.
> {{isLockExpired()}} judges against that recorded lease.
> Supporting changes:
> * Documents with no usable lease -- written before this change, or carrying a 
> corrupt negative value -- fall back to the observer's timeout, which is the 
> exact pre-change behaviour.
> * An implausibly large lease is honoured rather than overridden: the owner 
> declared it, and stealing a lock early is precisely what causes double 
> execution.
> * {{ScheduledTask}} is annotated 
> {{@JsonIgnoreProperties(ignoreUnknown=true)}}. Jackson's default rejects the 
> first unrecognized field, so during a rolling upgrade an older node would 
> otherwise lose the ability to read *any* task document a newer node had 
> written -- a latent break for any future field addition, not just this one.
> * {{startLockRenewal()}} now warns when the configured lock timeout is at or 
> below the minimum renewal interval, the one configuration where a node cannot 
> keep its own lease alive and peers may legitimately recover its live work.
> * Elasticsearch and OpenSearch {{scheduledTask}} mappings gain the 
> {{lockLeaseMillis}} field.
> h2. Compatibility and blast radius
> * Same-configuration clusters: no behavioural change at all -- the recorded 
> lease equals every observer's timeout, so expiry decisions are identical.
> * Existing locks and mixed-version clusters: no lease recorded, so the 
> observer-timeout fallback applies and behaviour matches the current release.
> * Crash recovery of genuinely dead nodes still works, and is now driven by 
> the dead owner's lease. Where the dead node ran a short timeout, recovery is 
> *faster* than before, because peers no longer wait out their own longer 
> opinion.
> h2. Test hardening
> * The {{setUp()}} scheduler uses the production-default lock timeout, and the 
> multi-node tests that do not use it stop it first (following the existing 
> {{testNodeFailure}} pattern).
> * {{testClusteringSupport}} no longer requires that one {{runOnAllNodes}} 
> task execute on all three nodes. That is not a property the implementation 
> promises: all nodes share the task's single schedule document, so each period 
> has one phase-dependent winner and there is no fairness. The regression it 
> was really protecting -- non-executor nodes must poll and run 
> {{runOnAllNodes}} tasks -- is now pinned deterministically in a test where 
> the non-executor is the only node.
> * Exact execution counts asserted while a periodic task could still fire now 
> cancel-and-quiesce first, or assert a lower bound.
> * {{Thread.sleep}} policy, applied and documented in the class javadoc: 
> positive assertions never wait on a fixed sleep (bounded polls, Mockito 
> {{timeout()}} verifies, or latches the test releases); deliberate quiet 
> windows for *negative* assertions keep their sleeps, since a poll cannot 
> confirm that nothing happened and too short a window can only miss a 
> violation, never fail a healthy run.
> * {{configureDebugLogging()}} was dead code -- it set slf4j-simple properties 
> while {{logback-test.xml}} binds logback. Removed, and 
> {{-DTEST_LOG_LEVEL=DEBUG}} documented instead, so the next CI failure arrives 
> with {{LOCK-DIAG}} traces rather than a bare assertion message.
> h2. Validation
> * Both load-bearing changes are mutation-validated: the divergent-timeout 
> regression test fails with the expiry logic reverted ("expected: <RUNNING> 
> but was: <CRASHED>"), and the serialization forward-compatibility test fails 
> without the annotation ({{UnrecognizedPropertyException}}).
> * Unit coverage exercises lease stamping on all three acquire paths, 
> re-stamping on renewal after a runtime timeout change, clearing on release, 
> both override directions, the legacy and corrupt-value fallbacks, boundary 
> equality, and overflow; plus the persistence format through both real store 
> read paths.
> * End-to-end: a divergently-configured observer cannot recover a live renewed 
> lock, and a patient survivor does recover a dead owner's task as soon as the 
> owner's lease expires.
> * The four scheduler suites (125 tests) were run repeatedly under roughly 
> 2.5x CPU oversubscription -- the protocol that reproduced both original CI 
> failures on demand -- with seven consecutive green runs after these changes.
> * Full {{services}} module: 805 tests green. Elasticsearch and OpenSearch 
> core module tests green; {{rest}} and {{itests}} compile clean.
> h2. Follow-up worth its own ticket
> {{runOnAllNodes}} as implemented means "any node, including a non-executor, 
> may run this task"
> (one shared schedule, one winner per period), not "each node runs it every 
> period" as the name
> suggests. Worth deciding which semantic is intended, and either implementing 
> per-node schedules or
> renaming and documenting the current one.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to