fix(notifications): EmailChannel @Bean registration вместо @Component (race fix)

This commit is contained in:
Александр Зимин
2026-05-15 12:40:39 +00:00
parent ec40e530e1
commit fb7fd51c49
2 changed files with 45 additions and 6 deletions
@@ -8,12 +8,9 @@ import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.mail.MailException; import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
@@ -36,14 +33,25 @@ import java.time.Duration;
* "tried, gave up", не долбим SMTP бесконечно).</li> * "tried, gave up", не долбим SMTP бесконечно).</li>
* </ul> * </ul>
*/ */
@Component
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
// Без JavaMailSender bean (т.е. spring.mail.host не задан) ChannelKind.EMAIL // Без JavaMailSender bean (т.е. spring.mail.host не задан) ChannelKind.EMAIL
// не активируется. Это позволяет включить notifications.enabled=true для // не активируется. Это позволяет включить notifications.enabled=true для
// Express-only deploy без обязательной SMTP-конфигурации, и не вешает Spring // Express-only deploy без обязательной SMTP-конфигурации, и не вешает Spring
// boot на UnsatisfiedDependencyException когда mail starter в classpath, но // boot на UnsatisfiedDependencyException когда mail starter в classpath, но
// инфра почты не подведена. // инфра почты не подведена.
@ConditionalOnBean(JavaMailSender.class) //
// HISTORY: раньше класс был @Component + @ConditionalOnBean(JavaMailSender)
// на class-level. Это classic Spring race ordering bug — @Component scanned
// через scanBasePackages ДО auto-config phase, на момент evaluation
// JavaMailSender bean не создан → @ConditionalOnBean fails → EmailChannel
// не register'ится. MailSenderPropertiesConfiguration создаёт JavaMailSender
// позже, но EmailChannel уже отброшен. Spring docs прямо warning:
// "It is generally safe to use @ConditionalOnBean and @ConditionalOnMissingBean
// at the @Bean method level only".
//
// Fix: @Component удалён, instance создаётся как @Bean в
// NotificationsAutoConfiguration (которая @AutoConfigureAfter MailSenderAutoConfiguration).
// На момент @Bean-метода JavaMailSender уже существует → @ConditionalOnBean
// matches → EmailChannel registered.
public class EmailChannel implements NotificationChannel { public class EmailChannel implements NotificationChannel {
private static final Logger log = LoggerFactory.getLogger(EmailChannel.class); private static final Logger log = LoggerFactory.getLogger(EmailChannel.class);
@@ -1,6 +1,11 @@
package cloud.nstart.terravault.ordinis.notifications.config; package cloud.nstart.terravault.ordinis.notifications.config;
import cloud.nstart.terravault.ordinis.notifications.channel.EmailChannel;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
@@ -8,6 +13,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -29,6 +35,11 @@ import java.nio.charset.StandardCharsets;
@EnableScheduling @EnableScheduling
@EnableCaching @EnableCaching
@ComponentScan(basePackages = "cloud.nstart.terravault.ordinis.notifications") @ComponentScan(basePackages = "cloud.nstart.terravault.ordinis.notifications")
// @AutoConfigureAfter гарантирует что MailSenderAutoConfiguration evaluated
// раньше — на момент создания emailChannel() bean'а JavaMailSender уже
// существует в context'е. Без этого @ConditionalOnBean(JavaMailSender.class)
// fails (race ordering — см. comment в EmailChannel.java HISTORY).
@AutoConfigureAfter(MailSenderAutoConfiguration.class)
public class NotificationsAutoConfiguration { public class NotificationsAutoConfiguration {
/** /**
@@ -46,4 +57,24 @@ public class NotificationsAutoConfiguration {
ms.setUseCodeAsDefaultMessage(false); // throw NoSuchMessageException — fail loud ms.setUseCodeAsDefaultMessage(false); // throw NoSuchMessageException — fail loud
return ms; return ms;
} }
/**
* EmailChannel registered как @Bean (НЕ @Component) — позволяет
* @ConditionalOnBean(JavaMailSender.class) evaluation на bean-method level
* после того как MailSenderAutoConfiguration отработал. Раньше EmailChannel
* был @Component с class-level @ConditionalOnBean → race с component scan
* → JavaMailSender ещё не существует на момент check → bean не register'ится
* → NotificationsDispatcher logs WARN "Channels: []".
*
* <p>Spring docs: "It is generally safe to use @ConditionalOnBean and
* @ConditionalOnMissingBean at the @Bean method level only".
*/
@Bean
@ConditionalOnBean(JavaMailSender.class)
public EmailChannel emailChannel(
JavaMailSender mailSender,
NotificationsProperties props,
MeterRegistry meterRegistry) {
return new EmailChannel(mailSender, props, meterRegistry);
}
} }