From 74194d1cfbec84da6a1769aa5921359c364ba75a Mon Sep 17 00:00:00 2001 From: KshitizYadav Date: Tue, 29 Sep 2026 14:49:40 +0530 Subject: [PATCH 1/4] fix(server): create HazelcastNotificationTrigger for property-configured notifiers (#5716) --- ...AdminServerHazelcastAutoConfiguration.java | 37 ++++---- .../AdminServerNotifierAutoConfiguration.java | 12 ++- .../AdminServerAutoConfigurationTest.java | 3 +- ...nServerHazelcastAutoConfigurationTest.java | 84 +++++++++++++++++++ 4 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java index 8c155a69acb..3102859befa 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; -import org.reactivestreams.Publisher; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -38,8 +37,6 @@ import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.eventstore.HazelcastEventStore; import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; -import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger; -import de.codecentric.boot.admin.server.notify.NotificationTrigger; import de.codecentric.boot.admin.server.notify.Notifier; import de.codecentric.boot.admin.server.notify.filter.FilteringNotifier; import de.codecentric.boot.admin.server.notify.filter.NotificationFilter; @@ -59,6 +56,8 @@ public class AdminServerHazelcastAutoConfiguration { public static final String DEFAULT_NAME_NOTIFICATION_FILTERS_MAP = "spring-boot-admin-notification-filters"; + public static final String SENT_NOTIFICATIONS_BEAN_NAME = "sentNotificationsMap"; + @Value("${spring.boot.admin.hazelcast.event-store:" + DEFAULT_NAME_EVENT_STORE_MAP + "}") private final String nameEventStoreMap = DEFAULT_NAME_EVENT_STORE_MAP; @@ -72,21 +71,21 @@ public HazelcastEventStore eventStore(HazelcastInstance hazelcastInstance, eventStoreProperties.isPruneInfoUpdatedEvents()); } - @Configuration(proxyBeanMethods = false) - @ConditionalOnBean(Notifier.class) - public static class NotifierTriggerConfiguration { - - @Value("${spring.boot.admin.hazelcast.sent-notifications:" + DEFAULT_NAME_SENT_NOTIFICATIONS_MAP + "}") - private final String nameSentNotificationsMap = DEFAULT_NAME_SENT_NOTIFICATIONS_MAP; - - @Bean(initMethod = "start", destroyMethod = "stop") - @ConditionalOnMissingBean(NotificationTrigger.class) - public NotificationTrigger notificationTrigger(HazelcastInstance hazelcastInstance, Notifier notifier, - Publisher events) { - return new HazelcastNotificationTrigger(notifier, events, - hazelcastInstance.getMap(this.nameSentNotificationsMap)); - } - + /** + * Distributed map used to deduplicate notifications across the cluster. It is picked + * up by {@link AdminServerNotifierAutoConfiguration}, which is evaluated after all + * notifiers have been registered. + * @param hazelcastInstance the Hazelcast instance + * @param nameSentNotificationsMap the name of the backing map + * @return the map holding the version of the last event a notification was sent for + */ + @Bean(name = SENT_NOTIFICATIONS_BEAN_NAME) + @Lazy + @ConditionalOnMissingBean(name = SENT_NOTIFICATIONS_BEAN_NAME) + public IMap sentNotificationsMap(HazelcastInstance hazelcastInstance, + @Value("${spring.boot.admin.hazelcast.sent-notifications:" + DEFAULT_NAME_SENT_NOTIFICATIONS_MAP + + "}") String nameSentNotificationsMap) { + return hazelcastInstance.getMap(nameSentNotificationsMap); } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerNotifierAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerNotifierAutoConfiguration.java index 800dc9e21ed..aa2a4e6d73f 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerNotifierAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerNotifierAutoConfiguration.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.ConcurrentMap; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.Credentials; @@ -26,6 +27,8 @@ import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.core5.http.HttpHost; import org.reactivestreams.Publisher; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -52,10 +55,12 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.domain.events.InstanceEvent; +import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.notify.CompositeNotifier; import de.codecentric.boot.admin.server.notify.DingTalkNotifier; import de.codecentric.boot.admin.server.notify.DiscordNotifier; import de.codecentric.boot.admin.server.notify.FeiShuNotifier; +import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger; import de.codecentric.boot.admin.server.notify.HipchatNotifier; import de.codecentric.boot.admin.server.notify.LetsChatNotifier; import de.codecentric.boot.admin.server.notify.MailNotifier; @@ -107,7 +112,12 @@ public static class NotifierTriggerConfiguration { @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnMissingBean(NotificationTrigger.class) - public NotificationTrigger notificationTrigger(Notifier notifier, Publisher events) { + public NotificationTrigger notificationTrigger(Notifier notifier, Publisher events, + @Qualifier(AdminServerHazelcastAutoConfiguration.SENT_NOTIFICATIONS_BEAN_NAME) ObjectProvider> sentNotifications) { + ConcurrentMap sentNotificationsMap = sentNotifications.getIfAvailable(); + if (sentNotificationsMap != null) { + return new HazelcastNotificationTrigger(notifier, events, sentNotificationsMap); + } return new NotificationTrigger(notifier, events); } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java index 7bc3f5d3e3c..bfef9fe8911 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java @@ -51,7 +51,8 @@ class AdminServerAutoConfigurationTest { private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(ReactiveHttpClientAutoConfiguration.class, WebClientAutoConfiguration.class, HazelcastAutoConfiguration.class, WebMvcAutoConfiguration.class, - AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class)) + AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class, + AdminServerNotifierAutoConfiguration.class)) .withUserConfiguration(AdminServerMarkerConfiguration.class); @Test diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java new file mode 100644 index 00000000000..6104108ef3b --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed 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 + * + * https://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 de.codecentric.boot.admin.server.config; + +import com.hazelcast.config.Config; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.hazelcast.autoconfigure.HazelcastAutoConfiguration; +import org.springframework.boot.http.client.autoconfigure.reactive.ReactiveHttpClientAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; +import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; +import org.springframework.context.annotation.Bean; + +import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger; +import de.codecentric.boot.admin.server.notify.NotificationTrigger; + +import static org.assertj.core.api.Assertions.assertThat; + +class AdminServerHazelcastAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ReactiveHttpClientAutoConfiguration.class, + WebClientAutoConfiguration.class, HazelcastAutoConfiguration.class, WebMvcAutoConfiguration.class, + AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class, + AdminServerNotifierAutoConfiguration.class)) + .withUserConfiguration(AdminServerMarkerConfiguration.class, HazelcastOnlyConfig.class); + + @Test + void should_use_hazelcast_trigger_for_property_configured_notifier() { + this.contextRunner.withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com") + .run((context) -> { + assertThat(context).hasSingleBean(NotificationTrigger.class); + assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); + }); + } + + @Test + void should_use_hazelcast_trigger_for_multiple_property_configured_notifiers() { + this.contextRunner + .withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com", + "spring.boot.admin.notify.discord.webhook-url:https://example.com") + .run((context) -> { + assertThat(context).hasSingleBean(NotificationTrigger.class); + assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); + }); + } + + @Test + void should_use_plain_trigger_when_hazelcast_is_disabled() { + this.contextRunner + .withPropertyValues("spring.boot.admin.hazelcast.enabled:false", + "spring.boot.admin.notify.slack.webhook-url:https://example.com") + .run((context) -> { + assertThat(context).hasSingleBean(NotificationTrigger.class); + assertThat(context).getBean(NotificationTrigger.class) + .isNotInstanceOf(HazelcastNotificationTrigger.class); + }); + } + + public static class HazelcastOnlyConfig { + + @Bean + public Config config() { + return new Config(); + } + + } + +} From e25b482ad1f4c367f0907c0dd802ad3d405d469b Mon Sep 17 00:00:00 2001 From: KshitizYadav Date: Fri, 16 Oct 2026 16:22:42 +0530 Subject: [PATCH 2/4] fix(server): expose notification filters map instead of auto-creating FilteringNotifier --- .../src/site/docs/02-server/20-Clustering.mdx | 14 +++++ ...AdminServerHazelcastAutoConfiguration.java | 34 ++++++------ .../AdminServerAutoConfigurationTest.java | 5 +- ...nServerHazelcastAutoConfigurationTest.java | 55 +++++++++++++++++++ 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx b/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx index d6bf7cb8de1..e90119a82ce 100644 --- a/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx +++ b/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx @@ -86,3 +86,17 @@ public Config hazelcastConfig() { return config; } ``` + +### Sharing notification filters + +The Hazelcast map `spring-boot-admin-notification-filters` (configurable via `spring.boot.admin.hazelcast.notification-filters`) is available as a bean named `notificationFiltersMap`. To share filters across the cluster, you pass it to the constructor of your own `FilteringNotifier`. This allows filter definitions to be replicated and kept in sync across all cluster nodes. + +```java +@Bean +@Primary +public FilteringNotifier filteringNotifier(@Qualifier("mailNotifier") Notifier delegate, + InstanceRepository repository, + @Qualifier("notificationFiltersMap") ConcurrentMap filters) { + return new FilteringNotifier(delegate, repository, filters); +} +``` diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java index 3102859befa..93f80d1f3d8 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java @@ -32,12 +32,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.domain.events.InstanceEvent; import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.eventstore.HazelcastEventStore; import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; -import de.codecentric.boot.admin.server.notify.Notifier; import de.codecentric.boot.admin.server.notify.filter.FilteringNotifier; import de.codecentric.boot.admin.server.notify.filter.NotificationFilter; @@ -58,6 +56,8 @@ public class AdminServerHazelcastAutoConfiguration { public static final String SENT_NOTIFICATIONS_BEAN_NAME = "sentNotificationsMap"; + public static final String NOTIFICATION_FILTERS_BEAN_NAME = "notificationFiltersMap"; + @Value("${spring.boot.admin.hazelcast.event-store:" + DEFAULT_NAME_EVENT_STORE_MAP + "}") private final String nameEventStoreMap = DEFAULT_NAME_EVENT_STORE_MAP; @@ -88,22 +88,20 @@ public IMap sentNotificationsMap(HazelcastInstance hazelcastIn return hazelcastInstance.getMap(nameSentNotificationsMap); } - @Configuration(proxyBeanMethods = false) - @ConditionalOnBean(Notifier.class) - @AutoConfigureBefore({ AdminServerNotifierAutoConfiguration.NotifierTriggerConfiguration.class }) - public static class NotificationFilterConfiguration { - - @Value("${spring.boot.admin.hazelcast.notification-filters:" + DEFAULT_NAME_NOTIFICATION_FILTERS_MAP + "}") - private final String nameNotificationFiltersMap = DEFAULT_NAME_NOTIFICATION_FILTERS_MAP; - - @Bean - @ConditionalOnMissingBean(FilteringNotifier.class) - public FilteringNotifier filteringNotifier(HazelcastInstance hazelcastInstance, Notifier delegate, - InstanceRepository repository) { - IMap filtersMap = hazelcastInstance.getMap(this.nameNotificationFiltersMap); - return new FilteringNotifier(delegate, repository, filtersMap); - } - + /** + * Distributed map holding the notification filters, so that filters are shared across + * the cluster. Pass it to a {@link FilteringNotifier} declared in your application. + * @param hazelcastInstance the Hazelcast instance + * @param nameNotificationFiltersMap the name of the backing map + * @return the map holding the notification filters + */ + @Bean(name = NOTIFICATION_FILTERS_BEAN_NAME) + @Lazy + @ConditionalOnMissingBean(name = NOTIFICATION_FILTERS_BEAN_NAME) + public IMap notificationFiltersMap(HazelcastInstance hazelcastInstance, + @Value("${spring.boot.admin.hazelcast.notification-filters:" + DEFAULT_NAME_NOTIFICATION_FILTERS_MAP + + "}") String nameNotificationFiltersMap) { + return hazelcastInstance.getMap(nameNotificationFiltersMap); } } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java index bfef9fe8911..aa6fa720dc6 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerAutoConfigurationTest.java @@ -69,9 +69,8 @@ void hazelcastConfig() { this.contextRunner.withUserConfiguration(TestHazelcastConfig.class).run((context) -> { assertThat(context).getBean(InstanceEventStore.class).isInstanceOf(HazelcastEventStore.class); assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); - assertThat(context).hasSingleBean(FilteringNotifier.class); - FilteringNotifier filteringNotifier = context.getBean(FilteringNotifier.class); - assertThat(filteringNotifier).extracting("filters").isInstanceOf(IMap.class); + assertThat(context).doesNotHaveBean(FilteringNotifier.class); + assertThat(context).getBean("notificationFiltersMap").isInstanceOf(IMap.class); }); } diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java index 6104108ef3b..9a0ed9c4f2b 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java @@ -16,8 +16,12 @@ package de.codecentric.boot.admin.server.config; +import java.util.concurrent.ConcurrentMap; + import com.hazelcast.config.Config; +import com.hazelcast.map.IMap; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.hazelcast.autoconfigure.HazelcastAutoConfiguration; import org.springframework.boot.http.client.autoconfigure.reactive.ReactiveHttpClientAutoConfiguration; @@ -25,9 +29,15 @@ import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import reactor.core.publisher.Mono; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger; import de.codecentric.boot.admin.server.notify.NotificationTrigger; +import de.codecentric.boot.admin.server.notify.Notifier; +import de.codecentric.boot.admin.server.notify.filter.FilteringNotifier; +import de.codecentric.boot.admin.server.notify.filter.NotificationFilter; import static org.assertj.core.api.Assertions.assertThat; @@ -72,6 +82,25 @@ void should_use_plain_trigger_when_hazelcast_is_disabled() { }); } + @Test + void should_start_and_use_hazelcast_trigger_with_single_user_defined_notifier() { + this.contextRunner.withUserConfiguration(UserNotifierConfig.class).run((context) -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(NotificationTrigger.class); + assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); + assertThat(context).doesNotHaveBean(FilteringNotifier.class); + }); + } + + @Test + void should_share_filters_map_with_user_defined_filtering_notifier() { + this.contextRunner.withUserConfiguration(UserFilteringNotifierConfig.class).run((context) -> { + assertThat(context).hasNotFailed(); + assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); + assertThat(context.getBean(FilteringNotifier.class)).extracting("filters").isInstanceOf(IMap.class); + }); + } + public static class HazelcastOnlyConfig { @Bean @@ -81,4 +110,30 @@ public Config config() { } + public static class UserNotifierConfig { + + @Bean + public Notifier notifier() { + return (event) -> Mono.empty(); + } + + } + + public static class UserFilteringNotifierConfig { + + @Bean + public Notifier delegateNotifier() { + return (event) -> Mono.empty(); + } + + @Bean + @Primary + public FilteringNotifier filteringNotifier(@Qualifier("delegateNotifier") Notifier delegate, + InstanceRepository repository, + @Qualifier("notificationFiltersMap") ConcurrentMap filters) { + return new FilteringNotifier(delegate, repository, filters); + } + + } + } From b2a9b29276fa0631425f40dc65500006d0d3dc74 Mon Sep 17 00:00:00 2001 From: KshitizYadav Date: Fri, 23 Oct 2026 20:53:50 +0530 Subject: [PATCH 3/4] docs(server): clarify sent-notifications map Javadoc and FilteringNotifier circularity --- .../src/site/docs/02-server/20-Clustering.mdx | 21 +++++++++++++++++++ ...AdminServerHazelcastAutoConfiguration.java | 9 +++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx b/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx index e90119a82ce..a38547f01d8 100644 --- a/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx +++ b/spring-boot-admin-docs/src/site/docs/02-server/20-Clustering.mdx @@ -100,3 +100,24 @@ public FilteringNotifier filteringNotifier(@Qualifier("mailNotifier") Notifier d return new FilteringNotifier(delegate, repository, filters); } ``` + +:::warning +A `FilteringNotifier` that wraps another notifier can end up in a circular dependency with the `CompositeNotifier`. +The `CompositeNotifier` is created when more than one `Notifier` bean exists and none of them is `@Primary`. It +collects every `Notifier` bean, including your `FilteringNotifier`. If the `FilteringNotifier` receives its delegate +as a plain `Notifier` parameter, Spring resolves that parameter to the `CompositeNotifier`, and the application +fails to start with a `BeanCurrentlyInCreationException`. + +To avoid this, declare the `FilteringNotifier` as `@Primary` and inject the notifier it wraps by name with +`@Qualifier` (or by its concrete type), as in the example above. +::: + +```java +// Do not do this: the plain Notifier parameter resolves to the CompositeNotifier, +// which in turn needs this FilteringNotifier. +@Bean +public FilteringNotifier filteringNotifier(Notifier delegate, InstanceRepository repository, + @Qualifier("notificationFiltersMap") ConcurrentMap filters) { + return new FilteringNotifier(delegate, repository, filters); +} +``` diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java index 93f80d1f3d8..06e6074b42a 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java @@ -72,9 +72,12 @@ public HazelcastEventStore eventStore(HazelcastInstance hazelcastInstance, } /** - * Distributed map used to deduplicate notifications across the cluster. It is picked - * up by {@link AdminServerNotifierAutoConfiguration}, which is evaluated after all - * notifiers have been registered. + * Distributed map used to deduplicate notifications across the cluster. It is exposed + * as a bean because {@link AdminServerNotifierAutoConfiguration} must not reference + * Hazelcast classes, as Hazelcast is optional. It obtains the map as a plain + * {@code ConcurrentMap} through an {@code ObjectProvider}, which is resolved lazily + * when the notification trigger is created. The result therefore does not depend on + * the order in which the auto-configurations are evaluated. * @param hazelcastInstance the Hazelcast instance * @param nameSentNotificationsMap the name of the backing map * @return the map holding the version of the last event a notification was sent for From 4c5b3eed642aa5c6503a6b24b9ae31ab86593ee2 Mon Sep 17 00:00:00 2001 From: KshitizYadav Date: Sat, 24 Oct 2026 10:26:17 +0530 Subject: [PATCH 4/4] test(server): cover @Primary FilteringNotifier wrapping a property-configured notifier --- ...nServerHazelcastAutoConfigurationTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java index 9a0ed9c4f2b..b72c7cd0b91 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfigurationTest.java @@ -101,6 +101,17 @@ void should_share_filters_map_with_user_defined_filtering_notifier() { }); } + @Test + void should_start_with_primary_filtering_notifier_wrapping_property_configured_notifier() { + this.contextRunner.withUserConfiguration(PrimaryFilteringNotifierConfig.class) + .withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com") + .run((context) -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(FilteringNotifier.class); + assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class); + }); + } + public static class HazelcastOnlyConfig { @Bean @@ -136,4 +147,16 @@ public FilteringNotifier filteringNotifier(@Qualifier("delegateNotifier") Notifi } + public static class PrimaryFilteringNotifierConfig { + + @Bean + @Primary + public FilteringNotifier filteringNotifier(@Qualifier("slackNotifier") Notifier delegate, + InstanceRepository repository, + @Qualifier("notificationFiltersMap") ConcurrentMap filters) { + return new FilteringNotifier(delegate, repository, filters); + } + + } + }