From 44e53a110332a52b67e88b89972bc39ba152d6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 29 Jul 2026 17:54:25 +0200 Subject: [PATCH] fix: create the PollingEventSource timer on start and make it a daemon `PollingEventSource` held its timer in a final field initialised at construction: private final Timer timer = new Timer(); Two problems follow from that. The event source cannot be restarted. `stop()` calls `timer.cancel()` and a cancelled `java.util.Timer` cannot be reused, so a subsequent `start()` fails with `IllegalStateException: Timer already cancelled`. Restart is a supported lifecycle - `Operator.stop()` / `start()` recreates the thread pools for exactly this reason, and `TimerEventSource` creates a new `Timer` inside `start()`. The timer thread is not a daemon and is created eagerly. Merely constructing a `PollingEventSource` therefore starts a non-daemon thread that keeps the JVM from exiting, even if the event source is never started, and it outlives an operator that is stopped without stopping its event sources. `TimerEventSource` uses `new Timer(true)`. The timer is now created in `start()` as a daemon and cleared in `stop()`, matching `TimerEventSource`. Adds regression tests for restart and for the daemon flag; the restart one fails with `IllegalStateException: Timer already cancelled` without this change. Note: `PerResourcePollingEventSource` has a related restart limitation because `stop()` calls `shutdownNow()` on the `ScheduledExecutorService` from its configuration. That executor is supplied by the caller, so changing its ownership semantics is a separate discussion and is left out of this change. --- .../source/polling/PollingEventSource.java | 10 +++++-- .../polling/PollingEventSourceTest.java | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java index f5e5a79430..e61a7a89d1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java @@ -62,7 +62,7 @@ public class PollingEventSource private static final Logger log = LoggerFactory.getLogger(PollingEventSource.class); - private final Timer timer = new Timer(); + private Timer timer; private final GenericResourceFetcher genericResourceFetcher; private final Duration period; private final AtomicBoolean healthy = new AtomicBoolean(true); @@ -76,6 +76,9 @@ public PollingEventSource(Class resourceClass, PollingConfiguration co @Override public void start() throws OperatorException { super.start(); + // a cancelled Timer cannot be reused, so a fresh one is created on every start; it is a daemon + // thread so that it never keeps the JVM alive + timer = new Timer(true); getStateAndFillCache(); timer.schedule( new TimerTask() { @@ -111,7 +114,10 @@ public interface GenericResourceFetcher { @Override public void stop() throws OperatorException { super.stop(); - timer.cancel(); + if (timer != null) { + timer.cancel(); + timer = null; + } } @Override diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java index 12dbf17be7..744d979567 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java @@ -60,6 +60,32 @@ public void setup() { setUpSource(pollingEventSource, false); } + @Test + void canBeRestartedAfterStop() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + pollingEventSource.stop(); + + // a cancelled java.util.Timer cannot be reused, a new one has to be created on start + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + assertThat(pollingEventSource.getStatus()).isEqualTo(Status.HEALTHY); + } + + @Test + void timerThreadIsADaemonSoItDoesNotKeepTheJvmAlive() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + assertThat(Thread.getAllStackTraces().keySet()) + .filteredOn(t -> t.getName().startsWith("Timer-")) + .isNotEmpty() + .allMatch(Thread::isDaemon); + } + @Test void pollsAndProcessesEvents() throws InterruptedException { when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues());