Fix ImapIdleChA for scheduling race condition (#8670)

* Fix ImapIdleChA for scheduling race condition

The IMAP IDLE is long-lived process and can be blocked
waiting for any reply from the server.
This way it is not suited to be used in a `TaskScheduler`
especially when it has only one thread in its pool in Spring Boot by default.
Another concurrent scheduled task is exactly an `ImapMailReceiver.IdleCanceller`.
With a single thread in a `TaskScheduler` pool it cannot be reached
therefore we never cancel and IDLE task and cannot react to the connection loss properly

* Rework the `ImapIdleChannelAdapter` logic to use a regular `Executor` and `while()` loop
with a `Thread.sleep()` when we lose connection
* Clean up the `ImapMailReceiverTests` from `TaskScheduler` not used anymore.
* Expose new `taskExecutor` option in the `ImapIdleChannelAdapterSpec` for Java DSL
* Enable `ImapMailReceiverTests.testIdleWithMessageMapping()` with an attempt to see
if this fix covers an unclear problem exposed before

* * The `testIdleWithMessageMapping()` still fails on GH actions
This commit is contained in:
Artem Bilan
2023-07-12 11:00:40 -04:00
committed by GitHub
parent 9597b7addb
commit e4c1851c07
3 changed files with 99 additions and 135 deletions

View File

@@ -17,10 +17,9 @@
package org.springframework.integration.mail;
import java.io.Serial;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import jakarta.mail.Folder;
@@ -31,15 +30,13 @@ import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.integration.endpoint.MessageProducerSupport;
import org.springframework.integration.mail.event.MailIntegrationEvent;
import org.springframework.integration.transaction.IntegrationResourceHolder;
import org.springframework.integration.transaction.IntegrationResourceHolderSynchronization;
import org.springframework.integration.transaction.TransactionSynchronizationFactory;
import org.springframework.messaging.MessagingException;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.Assert;
@@ -63,12 +60,10 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
private static final int DEFAULT_RECONNECT_DELAY = 10000;
private final ExceptionAwarePeriodicTrigger receivingTaskTrigger = new ExceptionAwarePeriodicTrigger();
private final IdleTask idleTask = new IdleTask();
private final ImapMailReceiver mailReceiver;
private Executor taskExecutor;
private TransactionSynchronizationFactory transactionSynchronizationFactory;
private ClassLoader classLoader;
@@ -100,6 +95,16 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
this.adviceChain = adviceChain;
}
/**
* Provide a managed {@link Executor} to schedule a receiving IDLE task.
* @param taskExecutor the {@link Executor} to use.
* @since 6.2
*/
public void setTaskExecutor(Executor taskExecutor) {
Assert.notNull(taskExecutor, "'taskExecutor' must not be null");
this.taskExecutor = taskExecutor;
}
/**
* Specify whether the IDLE task should reconnect automatically after
* catching a {@link jakarta.mail.MessagingException} while waiting for messages. The
@@ -139,6 +144,10 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
protected void onInit() {
super.onInit();
if (this.taskExecutor == null) {
this.taskExecutor = new SimpleAsyncTaskExecutor(getBeanName() + "-");
}
Consumer<?> messageSenderToUse = new MessageSender();
if (!CollectionUtils.isEmpty(this.adviceChain)) {
@@ -153,16 +162,9 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
this.messageSender = (Consumer<Object>) messageSenderToUse;
}
/*
* Lifecycle implementation
*/
@Override // guarded by super#lifecycleLock
@Override
protected void doStart() {
TaskScheduler scheduler = getTaskScheduler();
Assert.notNull(scheduler, "'taskScheduler' must not be null");
this.receivingTask = scheduler.schedule(new ReceivingTask(), this.receivingTaskTrigger);
this.taskExecutor.execute(this::callIdle);
}
@Override
@@ -190,6 +192,70 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
}
}
private void callIdle() {
while (isActive()) {
try {
processIdle();
logger.debug("Task completed successfully. Re-scheduling it again right away.");
}
catch (Exception ex) {
publishException(ex);
if (this.shouldReconnectAutomatically
&& ex.getCause() instanceof jakarta.mail.MessagingException messagingException) {
//run again after a delay
logger.info(messagingException,
() -> "Failed to execute IDLE task. Will attempt to resubmit in "
+ this.reconnectDelay + " milliseconds.");
delayNextIdleCall();
}
else {
logger.warn(ex,
"Failed to execute IDLE task. " +
"Won't resubmit since not a 'shouldReconnectAutomatically' " +
"or not a 'jakarta.mail.MessagingException'");
break;
}
}
}
}
private void processIdle() {
try {
logger.debug("waiting for mail");
this.mailReceiver.waitForNewMessages();
Folder folder = this.mailReceiver.getFolder();
if (folder != null && folder.isOpen() && isRunning()) {
Object[] mailMessages = this.mailReceiver.receive();
logger.debug(() -> "received " + mailMessages.length + " mail messages");
for (Object mailMessage : mailMessages) {
if (isRunning()) {
this.messageSender.accept(mailMessage);
}
}
}
}
catch (jakarta.mail.MessagingException ex) {
logger.warn(ex, "error occurred in idle task");
if (this.shouldReconnectAutomatically) {
throw new IllegalStateException("Failure in 'idle' task. Will resubmit.", ex);
}
else {
throw new MessagingException("Failure in 'idle' task. Will NOT resubmit.", ex);
}
}
}
private void delayNextIdleCall() {
try {
Thread.sleep(this.reconnectDelay);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ex);
}
}
private class MessageSender implements Consumer<Object> {
MessageSender() {
@@ -227,112 +293,6 @@ public class ImapIdleChannelAdapter extends MessageProducerSupport implements Be
}
private class ReceivingTask implements Runnable {
ReceivingTask() {
}
@Override
public void run() {
if (isRunning()) {
try {
ImapIdleChannelAdapter.this.idleTask.run();
logger.debug("Task completed successfully. Re-scheduling it again right away.");
}
catch (Exception ex) {
if (ImapIdleChannelAdapter.this.shouldReconnectAutomatically
&& ex.getCause() instanceof jakarta.mail.MessagingException messagingException) {
//run again after a delay
logger.info(messagingException,
() -> "Failed to execute IDLE task. Will attempt to resubmit in "
+ ImapIdleChannelAdapter.this.reconnectDelay + " milliseconds.");
ImapIdleChannelAdapter.this.receivingTaskTrigger.delayNextExecution();
}
else {
logger.warn(ex,
"Failed to execute IDLE task. " +
"Won't resubmit since not a 'shouldReconnectAutomatically' " +
"or not a 'jakarta.mail.MessagingException'");
ImapIdleChannelAdapter.this.receivingTaskTrigger.stop();
}
publishException(ex);
}
}
}
}
private class IdleTask implements Runnable {
IdleTask() {
}
@Override
public void run() {
if (isRunning()) {
try {
logger.debug("waiting for mail");
ImapIdleChannelAdapter.this.mailReceiver.waitForNewMessages();
Folder folder = ImapIdleChannelAdapter.this.mailReceiver.getFolder();
if (folder != null && folder.isOpen() && isRunning()) {
Object[] mailMessages = ImapIdleChannelAdapter.this.mailReceiver.receive();
logger.debug(() -> "received " + mailMessages.length + " mail messages");
for (Object mailMessage : mailMessages) {
if (isRunning()) {
ImapIdleChannelAdapter.this.messageSender.accept(mailMessage);
}
}
}
}
catch (jakarta.mail.MessagingException ex) {
logger.warn(ex, "error occurred in idle task");
if (ImapIdleChannelAdapter.this.shouldReconnectAutomatically) {
throw new IllegalStateException("Failure in 'idle' task. Will resubmit.", ex);
}
else {
throw new MessagingException("Failure in 'idle' task. Will NOT resubmit.", ex);
}
}
}
}
}
private class ExceptionAwarePeriodicTrigger implements Trigger {
private final AtomicBoolean delayNextExecution = new AtomicBoolean();
private final AtomicBoolean stop = new AtomicBoolean();
ExceptionAwarePeriodicTrigger() {
}
@Override
public Instant nextExecution(TriggerContext triggerContext) {
if (this.stop.getAndSet(false)) {
return null;
}
if (this.delayNextExecution.getAndSet(false)) {
return Instant.now().plusMillis(ImapIdleChannelAdapter.this.reconnectDelay);
}
else {
return Instant.now();
}
}
void delayNextExecution() {
this.delayNextExecution.set(true);
}
void stop() {
this.stop.set(true);
}
}
public class ImapIdleExceptionEvent extends MailIntegrationEvent {
@Serial

View File

@@ -22,6 +22,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -395,6 +396,17 @@ public class ImapIdleChannelAdapterSpec
return _this();
}
/**
* Provide a managed {@link Executor} to schedule a receiving IDLE task.
* @param taskExecutor the {@link Executor} to use.
* @return the spec.
* @since 6.2
*/
public ImapIdleChannelAdapterSpec taskExecutor(Executor taskExecutor) {
this.target.setTaskExecutor(taskExecutor);
return this;
}
@Override
public Map<Object, String> getComponentsToRegister() {
return this.componentsToRegister;

View File

@@ -228,7 +228,6 @@ public class ImapMailReceiverTests {
ImapIdleChannelAdapter adapter = new ImapIdleChannelAdapter(receiver);
QueueChannel channel = new QueueChannel();
adapter.setOutputChannel(channel);
adapter.setTaskScheduler(taskScheduler);
adapter.setReconnectDelay(10);
adapter.afterPropertiesSet();
adapter.start();
@@ -781,17 +780,14 @@ public class ImapMailReceiverTests {
theEvent.set(event);
latch.countDown();
});
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.initialize();
adapter.setTaskScheduler(taskScheduler);
adapter.setReconnectDelay(10);
adapter.afterPropertiesSet();
adapter.start();
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(theEvent.get().toString())
.endsWith("cause=java.lang.IllegalStateException: Failure in 'idle' task. Will resubmit.]");
adapter.stop();
taskScheduler.destroy();
}
@Test // see INT-1801
@@ -967,19 +963,15 @@ public class ImapMailReceiverTests {
i.callRealMethod();
throw new FolderClosedException(folder, "test");
}).given(receiver).waitForNewMessages();
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.initialize();
adapter.setTaskScheduler(taskScheduler);
adapter.setReconnectDelay(10);
adapter.afterPropertiesSet();
final CountDownLatch latch = new CountDownLatch(3);
CountDownLatch latch = new CountDownLatch(3);
adapter.setApplicationEventPublisher(e -> latch.countDown());
adapter.afterPropertiesSet();
adapter.start();
assertThat(latch.await(60, TimeUnit.SECONDS)).isTrue();
verify(store, atLeast(3)).connect();
adapter.stop();
taskScheduler.shutdown();
}
private void setUpScheduler(ImapMailReceiver mailReceiver, ThreadPoolTaskScheduler taskScheduler) {