Federico Mariani created CAMEL-24066:
----------------------------------------

             Summary: camel-spring - SpringScheduledPollConsumerScheduler 
ignores startScheduler=false (breaks ScheduledPollConsumerScheduler contract)
                 Key: CAMEL-24066
                 URL: https://issues.apache.org/jira/browse/CAMEL-24066
             Project: Camel
          Issue Type: Bug
          Components: camel-spring
    Affects Versions: 4.21.0
            Reporter: Federico Mariani


{{SpringScheduledPollConsumerScheduler}} (used with 
{{scheduler=spring&scheduler.cron=...}} on any scheduled-poll endpoint) breaks 
the {{ScheduledPollConsumerScheduler}} SPI contract.

In {{DefaultScheduledPollConsumerScheduler}}, {{doStart()}} only prepares the 
thread pool and the actual scheduling happens in {{startScheduler()}} - that is 
what makes the consumer option {{startScheduler=false}} ("schedule later, on 
demand") work: {{ScheduledPollConsumer.doStart()}} starts the scheduler 
service, but only calls {{scheduler.startScheduler()}} when 
{{isStartScheduler()}} is true.

The Spring implementation inverts this: it schedules the cron trigger inside 
{{doStart()}} ({{taskScheduler.schedule(runnable, trigger)}}) and implements 
{{startScheduler()}} as a no-op. Consequences:

# the documented consumer option {{startScheduler=false}} is silently ignored - 
the cron starts firing as soon as the route starts
# {{GenericFilePollingConsumer}} (i.e. {{pollEnrich()}} on file/FTP endpoints) 
and file browse operations call {{setStartScheduler(false)}} internally because 
they poll manually - combined with {{scheduler=spring}} they get an unwanted 
background cron poll competing with the manual poll
# after {{unscheduleTask()}} (e.g. {{repeatCount}} reached) a later 
{{startScheduler()}} call can never re-arm the trigger, unlike the default 
scheduler

Minor related issue in the same class: {{isSchedulerStarted()}} calls 
{{taskScheduler.getScheduledExecutor()}}, which throws 
{{IllegalStateException}} when the scheduler is not initialized yet, instead of 
returning {{false}}.

Reproducer (verified failing on 4.22.0-SNAPSHOT, dependencies: camel-core, 
camel-spring, camel-file, awaitility, junit-jupiter). The identical route with 
the default scheduler ({{delay=500}} instead of 
{{scheduler=spring&scheduler.cron=...}}) passes:

{code:java}
package org.apache.camel.spring.repro;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.impl.DefaultCamelContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.awaitility.Awaitility.await;

public class SpringSchedulerStartSchedulerOptionTest {

    @TempDir
    Path dir;

    @Test
    void startSchedulerFalseMustNotPoll() throws Exception {
        Files.writeString(dir.resolve("hello.txt"), "hi");

        AtomicInteger polled = new AtomicInteger();
        try (DefaultCamelContext camel = new DefaultCamelContext()) {
            camel.addRoutes(new RouteBuilder() {
                @Override
                public void configure() {
                    from("file:" + dir + "?noop=true&startScheduler=false"
                         + "&scheduler=spring&scheduler.cron=0/1+*+*+*+*+?")
                            .process(e -> polled.incrementAndGet());
                }
            });
            camel.start();

            // EXPECTED: no poll happens, because startScheduler=false means 
the scheduler
            //           must not run until 
ScheduledPollConsumer.startScheduler() is invoked.
            //           (with the default scheduler this test passes)
            // ACTUAL:   with scheduler=spring the cron fires after ~1 second 
anyway
            await().during(3, TimeUnit.SECONDS).atMost(4, TimeUnit.SECONDS)
                    .until(() -> polled.get() == 0);
        }
    }
}
{code}

Suggested fix: align with {{DefaultScheduledPollConsumerScheduler}} - keep 
creating/initializing the {{ThreadPoolTaskScheduler}} in {{doStart()}}, but 
move the {{taskScheduler.schedule(runnable, trigger)}} call into 
{{startScheduler()}} (guarded so it only schedules once / when not already 
scheduled).

Note: the schedule-in-doStart behavior is long-standing, so a review of git 
history / existing users is warranted before changing the contract (e.g. anyone 
relying on {{scheduler=spring}} firing without {{startScheduler()}} being 
called by a custom consumer).

_This issue was found during an AI-assisted code review of the camel-spring 
component. Reported by Claude Code on behalf of Federico Mariani. The 
reproducers were executed and verified against 4.22.0-SNAPSHOT (main) before 
filing._



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

Reply via email to