This is an automated email from the ASF dual-hosted git repository.
joerghoh pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-event.git
The following commit(s) were added to refs/heads/master by this push:
new 10f5854 SLING-13170 Replace Timer with injectable
ScheduledExecutorService (#55)
10f5854 is described below
commit 10f58542032116887f6fe0c4cf61918a122a0358
Author: Daniel Iancu <[email protected]>
AuthorDate: Wed Jun 24 15:18:31 2026 +0300
SLING-13170 Replace Timer with injectable ScheduledExecutorService (#55)
Replace the fire-and-forget Timer in startProcessing() with a
ScheduledExecutorService that is properly shut down on deactivate
and can be replaced in tests via setScheduler().
This eliminates the flaky Thread.sleep(4000) in testTopologyChange
by using a ManualScheduler that captures tasks for on-demand execution,
making the test deterministic and ~8x faster.
Co-authored-by: Daniel Iancu <[email protected]>
---
.../impl/jobs/config/JobManagerConfiguration.java | 44 +++--
.../jobs/config/JobManagerConfigurationTest.java | 198 ++++++++++++++++++---
.../event/impl/jobs/config/ManualScheduler.java | 191 ++++++++++++++++++++
3 files changed, 398 insertions(+), 35 deletions(-)
diff --git
a/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
b/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
index 1cba173..8f2ccd5 100644
---
a/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
+++
b/src/main/java/org/apache/sling/event/impl/jobs/config/JobManagerConfiguration.java
@@ -23,10 +23,12 @@ import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Timer;
-import java.util.TimerTask;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.PersistenceException;
@@ -172,6 +174,14 @@ public class JobManagerConfiguration {
private volatile long startupDelay;
+ /** Scheduler for delayed topology change processing. Replaceable in
tests. */
+ private final AtomicReference<ScheduledExecutorService> scheduler = new
AtomicReference<>();
+
+ /** Package-private for testing only. */
+ void setScheduler(ScheduledExecutorService s) {
+ this.scheduler.set(s);
+ }
+
/**
* The max count of job progress log messages
*/
@@ -307,6 +317,7 @@ public class JobManagerConfiguration {
resolver.close();
}
this.active.set(true);
+ this.scheduler.compareAndSet(null,
Executors.newSingleThreadScheduledExecutor());
// SLING-5560 : use an InitDelayingTopologyEventListener
if (this.startupDelay > 0) {
@@ -357,6 +368,11 @@ public class JobManagerConfiguration {
this.startupDelayListener.dispose();
this.startupDelayListener = null;
}
+ // this only applies to tests
+ final ScheduledExecutorService s = this.scheduler.getAndSet(null);
+ if (s != null) {
+ s.shutdownNow();
+ }
this.stopProcessing();
}
@@ -604,23 +620,27 @@ public class JobManagerConfiguration {
} else {
// and run checker again in some seconds (if leader)
// notify listeners afterwards
- final Timer timer = new Timer();
- timer.schedule(
- new TimerTask() {
-
- @Override
- public void run() {
+ final ScheduledExecutorService s = this.scheduler.get();
+ if (s == null) {
+ logger.debug("Scheduler already shut down — skipping delayed
listener notification");
+ return;
+ }
+ try {
+ s.schedule(
+ () -> {
if (newCaps == topologyCapabilities &&
newCaps.isActive()) {
- // start listeners
notifyListeners();
if (newCaps.isLeader() && newCaps.isActive()) {
final CheckTopologyTask mt = new
CheckTopologyTask(JobManagerConfiguration.this);
mt.fullRun();
}
}
- }
- },
- this.backgroundLoadDelay * 1000);
+ },
+ this.backgroundLoadDelay,
+ TimeUnit.SECONDS);
+ } catch (final java.util.concurrent.RejectedExecutionException e) {
+ logger.debug("Scheduler shut down before task could be
submitted — skipping delayed notification", e);
+ }
}
logger.debug("Job processing started");
}
diff --git
a/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
b/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
index 23a08db..05e40ed 100644
---
a/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
+++
b/src/test/java/org/apache/sling/event/impl/jobs/config/JobManagerConfigurationTest.java
@@ -84,15 +84,10 @@ public class JobManagerConfigurationTest {
}
}
- @Test
- public void testTopologyChange() throws Exception {
- // mock scheduler
- final ChangeListener ccl = new ChangeListener();
-
- // add change listener and verify
- ccl.init(1);
+ private JobManagerConfiguration createConfig(ManualScheduler
manualScheduler) {
final JobManagerConfiguration config = new JobManagerConfiguration();
((AtomicBoolean) TestUtil.getFieldValue(config, "active")).set(true);
+ config.setScheduler(manualScheduler);
InitDelayingTopologyEventListener startupDelayListener =
new InitDelayingTopologyEventListener(1, new
TopologyEventListener() {
@@ -102,46 +97,203 @@ public class JobManagerConfigurationTest {
}
});
TestUtil.setFieldValue(config, "startupDelayListener",
startupDelayListener);
+ return config;
+ }
+
+ @Test
+ public void testTopologyChange() throws Exception {
+ final ChangeListener ccl = new ChangeListener();
+ final ManualScheduler manualScheduler = new ManualScheduler();
+ final JobManagerConfiguration config = createConfig(manualScheduler);
// Create and bind the condition
Condition condition = mock(Condition.class);
config.bindJobProcessingEnabledCondition(condition);
+ ccl.init(1);
config.addListener(ccl);
ccl.await();
assertEquals(1, ccl.events.size());
assertFalse(ccl.events.get(0));
- // create init view
+ // TOPOLOGY_INIT notifies listeners synchronously (no scheduler
involved)
ccl.init(1);
final TopologyView initView = createView();
- final TopologyEvent init = new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, initView);
- config.handleTopologyEvent(init);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, initView));
ccl.await();
assertEquals(1, ccl.events.size());
assertTrue(ccl.events.get(0));
- // change view, followed by change props
- ccl.init(2);
+ // TOPOLOGY_CHANGED: stopProcessing fires synchronous false,
startProcessing schedules delayed task
+ ccl.init(1);
final TopologyView view2 = createView();
Mockito.when(initView.isCurrent()).thenReturn(false);
- final TopologyEvent change1 = new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, initView, view2);
- final TopologyView view3 = createView();
- final TopologyEvent change2 = new
TopologyEvent(TopologyEvent.Type.PROPERTIES_CHANGED, view2, view3);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, initView, view2));
+ ccl.await();
+
+ assertEquals(1, ccl.events.size());
+ assertFalse("stopProcessing should fire active=false",
ccl.events.get(0));
+ assertEquals("TOPOLOGY_CHANGED should schedule one delayed task", 1,
manualScheduler.pendingCount());
- config.handleTopologyEvent(change1);
+ // PROPERTIES_CHANGED: for views with same capabilities,
stopProcessing is skipped,
+ // only a new delayed task is scheduled
+ final TopologyView view3 = createView();
Mockito.when(view2.isCurrent()).thenReturn(false);
- config.handleTopologyEvent(change2);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.PROPERTIES_CHANGED, view2, view3));
+
+ assertEquals("Two delayed tasks pending", 2,
manualScheduler.pendingCount());
+ // Execute all pending tasks — only the latest should fire (earlier
caps were deactivated)
+ ccl.init(1);
+ manualScheduler.runAll();
ccl.await();
- assertEquals(2, ccl.events.size());
- assertFalse(ccl.events.get(0));
- assertTrue(ccl.events.get(1));
- // we wait another 4 secs to see if there is no another event
- Thread.sleep(4000);
- assertEquals(2, ccl.events.size());
+ assertEquals(1, ccl.events.size());
+ assertTrue("Delayed task should notify active=true",
ccl.events.get(0));
+ assertEquals("No pending tasks remain", 0,
manualScheduler.pendingCount());
+ }
+
+ /**
+ * Verify that deactivation prevents delayed listener notifications from
firing
+ * and that a schedule/shutdown race does not produce exceptions.
+ */
+ @Test
+ public void testDeactivatePreventsDelayedNotification() throws Exception {
+ final ChangeListener ccl = new ChangeListener();
+ final ManualScheduler manualScheduler = new ManualScheduler();
+ final JobManagerConfiguration config = createConfig(manualScheduler);
+
+ Condition condition = mock(Condition.class);
+ config.bindJobProcessingEnabledCondition(condition);
+
+ ccl.init(1);
+ config.addListener(ccl);
+ ccl.await(); // initial state: no topology → false
+
+ // Establish topology via TOPOLOGY_INIT (synchronous notification)
+ ccl.init(1);
+ final TopologyView initView = createView();
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, initView));
+ ccl.await();
+ assertTrue("Should be active after TOPOLOGY_INIT", ccl.events.get(0));
+
+ // TOPOLOGY_CHANGED queues a delayed task via the scheduler
+ ccl.init(1);
+ final TopologyView view2 = createView();
+ Mockito.when(initView.isCurrent()).thenReturn(false);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, initView, view2));
+ ccl.await(); // synchronous false from stopProcessing
+ assertEquals("One delayed task should be pending", 1,
manualScheduler.pendingCount());
+
+ // Deactivate before the delayed task runs — this calls shutdownNow()
on the scheduler
+ // (deactivate also calls stopProcessing → notifyListeners which fires
a synchronous event)
+ config.deactivate();
+ ccl.events.clear();
+
+ // shutdownNow() must have cleared the pending task
+ assertEquals("Pending tasks must be cleared after deactivate", 0,
manualScheduler.pendingCount());
+ assertTrue("Scheduler must be shut down",
manualScheduler.isShutdown());
+
+ // Running the scheduler after shutdown must not deliver any events
+ manualScheduler.runAll();
+ assertTrue("No events should be delivered after deactivate",
ccl.events.isEmpty());
+
+ // Simulate the race: a topology event arrives while deactivation is
in progress.
+ // The production code reads scheduler.get() which now returns null
(AtomicReference
+ // was cleared by deactivate). This must not throw NPE.
+ // To also exercise the RejectedExecutionException path, we re-inject
the shut-down
+ // scheduler and trigger a non-INIT event.
+ config.setScheduler(manualScheduler);
+ ((AtomicBoolean) TestUtil.getFieldValue(config, "active")).set(true);
+ InitDelayingTopologyEventListener startupDelayListener =
+ new InitDelayingTopologyEventListener(1, new
TopologyEventListener() {
+
+ @Override
+ public void handleTopologyEvent(TopologyEvent event) {
+ config.doHandleTopologyEvent(event);
+ }
+ });
+ TestUtil.setFieldValue(config, "startupDelayListener",
startupDelayListener);
+
+ // This TOPOLOGY_INIT sets up topology state so the next
TOPOLOGY_CHANGED reaches startProcessing
+ final TopologyView view3 = createView();
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, view3));
+
+ // Now trigger TOPOLOGY_CHANGED — scheduler.schedule() will throw
RejectedExecutionException
+ // which must be caught by the production code, not escape to the
caller
+ ccl.events.clear();
+ final TopologyView view4 = createView();
+ Mockito.when(view3.isCurrent()).thenReturn(false);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, view3, view4));
+
+ // No task should have been queued (scheduler rejected it)
+ assertEquals("No tasks should be queued on shut-down scheduler", 0,
manualScheduler.pendingCount());
+ }
+
+ /**
+ * Verify that startProcessing() handles a null scheduler reference
gracefully.
+ * This covers the race where deactivate() has already cleared the
AtomicReference
+ * (via getAndSet(null)) before a topology event reaches startProcessing().
+ */
+ @Test
+ public void testStartProcessingWithNullScheduler() throws Exception {
+ final ChangeListener ccl = new ChangeListener();
+ final ManualScheduler manualScheduler = new ManualScheduler();
+ final JobManagerConfiguration config = createConfig(manualScheduler);
+
+ Condition condition = mock(Condition.class);
+ config.bindJobProcessingEnabledCondition(condition);
+
+ ccl.init(1);
+ config.addListener(ccl);
+ ccl.await();
+
+ // Establish topology via TOPOLOGY_INIT
+ ccl.init(1);
+ final TopologyView initView = createView();
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_INIT, null, initView));
+ ccl.await();
+ assertTrue(ccl.events.get(0));
+
+ // Clear the scheduler reference directly — simulates deactivate()
having already
+ // called scheduler.getAndSet(null) before the topology event arrives
+ config.setScheduler(null);
+
+ // TOPOLOGY_CHANGED triggers stopProcessing (synchronous false) then
startProcessing
+ // which reads scheduler.get() → null and must return early without
throwing NPE
+ ccl.init(1);
+ final TopologyView view2 = createView();
+ Mockito.when(initView.isCurrent()).thenReturn(false);
+ config.handleTopologyEvent(new
TopologyEvent(TopologyEvent.Type.TOPOLOGY_CHANGED, initView, view2));
+ ccl.await();
+
+ // Only the synchronous false from stopProcessing should have been
delivered
+ assertEquals(1, ccl.events.size());
+ assertFalse("Only stopProcessing event should fire",
ccl.events.get(0));
+ // No task queued anywhere — scheduler was null
+ assertEquals("No tasks should be pending", 0,
manualScheduler.pendingCount());
+ }
+
+ /**
+ * Verify that deactivate() handles a null scheduler reference gracefully.
+ * This covers the case where the scheduler was never initialized (e.g.
activate()
+ * was never called) or was already cleared by a prior deactivate().
+ */
+ @Test
+ public void testDeactivateWithNullScheduler() {
+ final JobManagerConfiguration config = new JobManagerConfiguration();
+ ((AtomicBoolean) TestUtil.getFieldValue(config, "active")).set(true);
+ // Do NOT set a scheduler — the AtomicReference holds null
+
+ // deactivate() must not throw NPE when scheduler.getAndSet(null)
returns null
+ config.deactivate();
+
+ assertFalse("Component should be inactive after deactivate",
config.isActive());
+
+ // Calling deactivate() a second time must also be safe
(double-deactivate)
+ config.deactivate();
+ assertFalse("Component should remain inactive", config.isActive());
}
}
diff --git
a/src/test/java/org/apache/sling/event/impl/jobs/config/ManualScheduler.java
b/src/test/java/org/apache/sling/event/impl/jobs/config/ManualScheduler.java
new file mode 100644
index 0000000..e27bf7c
--- /dev/null
+++ b/src/test/java/org/apache/sling/event/impl/jobs/config/ManualScheduler.java
@@ -0,0 +1,191 @@
+/*
+ * 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.sling.event.impl.jobs.config;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.*;
+
+/**
+ * A ScheduledExecutorService that captures submitted tasks without executing
them.
+ * Models realistic shutdown semantics: {@link #shutdownNow()} marks the
scheduler as shut down
+ * and clears pending tasks; {@link #schedule} after shutdown throws {@link
RejectedExecutionException};
+ * {@link #runAll()} skips execution after shutdown.
+ */
+class ManualScheduler implements ScheduledExecutorService {
+
+ private final List<Runnable> tasks = new ArrayList<>();
+ private boolean shutdown;
+
+ /**
+ * Execute all captured tasks and clear the list. No-op after shutdown.
+ */
+ public void runAll() {
+ if (shutdown) {
+ return;
+ }
+ final List<Runnable> copy = new ArrayList<>(tasks);
+ tasks.clear();
+ copy.forEach(Runnable::run);
+ }
+
+ /**
+ * Return the number of pending (un-executed) tasks.
+ */
+ public int pendingCount() {
+ return tasks.size();
+ }
+
+ @Override
+ public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit
unit) {
+ if (shutdown) {
+ throw new RejectedExecutionException("Scheduler has been shut
down");
+ }
+ tasks.add(command);
+ return new CompletedFuture<>();
+ }
+
+ // -- unused ScheduledExecutorService methods --
+
+ @Override
+ public <V> ScheduledFuture<V> schedule(java.util.concurrent.Callable<V>
callable, long delay, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long
initialDelay, long period, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long
initialDelay, long delay, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void shutdown() {
+ shutdown = true;
+ }
+
+ @Override
+ public List<Runnable> shutdownNow() {
+ shutdown = true;
+ final List<Runnable> pending = new ArrayList<>(tasks);
+ tasks.clear();
+ return pending;
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return shutdown;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return shutdown;
+ }
+
+ @Override
+ public boolean awaitTermination(long timeout, TimeUnit unit) {
+ return true;
+ }
+
+ @Override
+ public <T> java.util.concurrent.Future<T>
submit(java.util.concurrent.Callable<T> task) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> java.util.concurrent.Future<T> submit(Runnable task, T result) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public java.util.concurrent.Future<?> submit(Runnable task) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> List<java.util.concurrent.Future<T>> invokeAll(
+ java.util.Collection<? extends java.util.concurrent.Callable<T>>
tasks) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> List<java.util.concurrent.Future<T>> invokeAll(
+ java.util.Collection<? extends java.util.concurrent.Callable<T>>
tasks, long timeout, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> T invokeAny(java.util.Collection<? extends
java.util.concurrent.Callable<T>> tasks) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <T> T invokeAny(
+ java.util.Collection<? extends java.util.concurrent.Callable<T>>
tasks, long timeout, TimeUnit unit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Minimal no-op ScheduledFuture returned by schedule().
+ */
+ private static class CompletedFuture<V> implements ScheduledFuture<V> {
+ @Override
+ public long getDelay(TimeUnit unit) {
+ return 0;
+ }
+
+ @Override
+ public int compareTo(Delayed o) {
+ return 0;
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ return false;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return false;
+ }
+
+ @Override
+ public boolean isDone() {
+ return true;
+ }
+
+ @Override
+ public V get() {
+ return null;
+ }
+
+ @Override
+ public V get(long timeout, TimeUnit unit) {
+ return null;
+ }
+ }
+}