Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import reactor.core.publisher.Flux;

import de.codecentric.boot.admin.server.domain.entities.Instance;
import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.entities.SnapshottingInstanceRepository;
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.InMemoryEventStore;
import de.codecentric.boot.admin.server.eventstore.InstanceEventPublisher;
import de.codecentric.boot.admin.server.eventstore.InstanceEventStore;
Expand Down Expand Up @@ -162,7 +165,8 @@ public StatusUpdater statusUpdater(InstanceRepository instanceRepository,

@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnMissingBean
public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publisher<InstanceEvent> events) {
public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publisher<InstanceEvent> events,
InstanceRegistry instanceRegistry) {
AdminServerProperties.MonitorProperties monitorProperties = this.adminServerProperties.getMonitor();

Duration defaultTimeout = monitorProperties.getDefaultTimeout();
Expand All @@ -175,7 +179,7 @@ public StatusUpdateTrigger statusUpdateTrigger(StatusUpdater statusUpdater, Publ
}

return new StatusUpdateTrigger(statusUpdater, events, statusInterval, monitorProperties.getStatusLifetime(),
monitorProperties.getStatusMaxBackoff());
monitorProperties.getStatusMaxBackoff(), getExistingInstanceIds(instanceRegistry));
}

@Bean
Expand Down Expand Up @@ -212,10 +216,11 @@ public InfoUpdater infoUpdater(InstanceRepository instanceRepository,

@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnMissingBean
public InfoUpdateTrigger infoUpdateTrigger(InfoUpdater infoUpdater, Publisher<InstanceEvent> events) {
public InfoUpdateTrigger infoUpdateTrigger(InfoUpdater infoUpdater, Publisher<InstanceEvent> events,
InstanceRegistry instanceRegistry) {
return new InfoUpdateTrigger(infoUpdater, events, this.adminServerProperties.getMonitor().getInfoInterval(),
this.adminServerProperties.getMonitor().getInfoLifetime(),
this.adminServerProperties.getMonitor().getInfoMaxBackoff());
this.adminServerProperties.getMonitor().getInfoMaxBackoff(), getExistingInstanceIds(instanceRegistry));
}

@Bean
Expand All @@ -230,4 +235,28 @@ public SnapshottingInstanceRepository instanceRepository(InstanceEventStore even
return new SnapshottingInstanceRepository(eventStore);
}

/*
* Fetches the existing registered instance IDs from the instance registry to use them
* as initial data set for the StatusUpdateTrigger and InfoUpdaterTrigger. This
* ensures that the triggers will update the status and info for all existing
* instances on startup and correctly start polling for the updates. This is necessary
* because the IntervalCheck used in the triggers only updates the status and info for
* instances that have been updated since the last check by checking the local
* "lastChecked" map. On rolling updates with Hazelcast, the details about the
* instances will be migrated from an instance to another, but the "lastChecked" map
* will be empty for the new instance, so the triggers will not update the status and
* info for the existing instances. As such, the existing instance IDs are fetched and
* passed to the triggers to ensure that the "lastChecked" map is aware of them
* accordingly.
*
* @param instanceRegistry the registry to fetch the existing registered instance IDs
* from
*
* @return a Flux of existing registered instance IDs
*/
private static Flux<InstanceId> getExistingInstanceIds(InstanceRegistry instanceRegistry) {
return Flux.defer(
() -> instanceRegistry.getInstances().filter(Instance::isRegistered).map(Instance::getId).distinct());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package de.codecentric.boot.admin.server.domain.entities;

import java.util.Comparator;
import java.util.function.BiFunction;

import org.slf4j.Logger;
Expand All @@ -38,6 +39,8 @@ public class EventsourcingInstanceRepository implements InstanceRepository {

private static final Logger log = LoggerFactory.getLogger(EventsourcingInstanceRepository.class);

private static final Comparator<InstanceEvent> byVersion = Comparator.comparingLong(InstanceEvent::getVersion);

private final InstanceEventStore eventStore;

private final Retry retryOptimisticLockException = Retry.max(10)
Expand All @@ -57,7 +60,7 @@ public Mono<Instance> save(Instance instance) {
public Flux<Instance> findAll() {
return this.eventStore.findAll()
.groupBy(InstanceEvent::getInstance)
.flatMap((f) -> f.reduce(Instance.create(f.key()), Instance::apply));
.flatMap((f) -> f.sort(byVersion).reduce(Instance.create(f.key()), Instance::apply));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package de.codecentric.boot.admin.server.domain.entities;

import java.util.Comparator;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
Expand All @@ -42,6 +43,8 @@ public class SnapshottingInstanceRepository extends EventsourcingInstanceReposit

private static final Logger log = LoggerFactory.getLogger(SnapshottingInstanceRepository.class);

private static final Comparator<InstanceEvent> byVersion = Comparator.comparingLong(InstanceEvent::getVersion);

private final ConcurrentMap<InstanceId, Instance> snapshots = new ConcurrentHashMap<>();

private final Set<InstanceId> outdatedSnapshots = ConcurrentHashMap.newKeySet();
Expand Down Expand Up @@ -79,7 +82,10 @@ public Mono<Instance> save(Instance instance) {
}

public void start() {
this.subscription = this.eventStore.findAll().concatWith(this.eventStore).subscribe(this::updateSnapshot);
Flux<InstanceEvent> initialEvents = this.eventStore.findAll()
.groupBy(InstanceEvent::getInstance)
.flatMap((events) -> events.sort(byVersion));
this.subscription = initialEvents.concatWith(this.eventStore).subscribe(this::updateSnapshot);
}

public void stop() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public abstract class ConcurrentMapEventStore extends InstanceEventPublisher imp

private static final Logger log = LoggerFactory.getLogger(ConcurrentMapEventStore.class);

protected static final long NO_LATEST_VERSION = -1;

private static final Comparator<InstanceEvent> byTimestampAndIdAndVersion = comparing(InstanceEvent::getTimestamp)
.thenComparing(InstanceEvent::getInstance)
.thenComparing(InstanceEvent::getVersion);
Expand Down Expand Up @@ -157,7 +159,7 @@ private OptimisticLockingException createOptimisticLockException(InstanceEvent e
}

protected static long getLastVersion(List<InstanceEvent> events) {
return events.isEmpty() ? -1 : events.get(events.size() - 1).getVersion();
return events.isEmpty() ? NO_LATEST_VERSION : events.get(events.size() - 1).getVersion();
}

private static DistinctEventType getDistinctEventTypeFor(InstanceEvent event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@ public HazelcastEventStore(int maxLogSizePerAggregate, IMap<InstanceId, List<Ins
super(maxLogSizePerAggregate, eventLog);

eventLog.addEntryListener(new EntryAdapter<InstanceId, List<InstanceEvent>>() {
@Override
public void entryAdded(EntryEvent<InstanceId, List<InstanceEvent>> event) {
log.debug("Added {}", event);
publishNewEvents(event, NO_LATEST_VERSION);
}

@Override
public void entryUpdated(EntryEvent<InstanceId, List<InstanceEvent>> event) {
log.debug("Updated {}", event);
long lastKnownVersion = getLastVersion(event.getOldValue());
publishNewEvents(event, getLastVersion(event.getOldValue()));
}

private void publishNewEvents(EntryEvent<InstanceId, List<InstanceEvent>> event, long lastKnownVersion) {
List<InstanceEvent> newEvents = event.getValue()
.stream()
.filter((e) -> e.getVersion() > lastKnownVersion)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@

import java.time.Duration;

import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

Expand All @@ -30,6 +32,8 @@
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;

import static de.codecentric.boot.admin.server.utils.concurrency.ConcurrencyUtils.halfCpus;

public class InfoUpdateTrigger extends AbstractEventHandler<InstanceEvent> {

private static final Logger log = LoggerFactory.getLogger(InfoUpdateTrigger.class);
Expand All @@ -38,11 +42,21 @@ public class InfoUpdateTrigger extends AbstractEventHandler<InstanceEvent> {

private final IntervalCheck intervalCheck;

private final Publisher<InstanceId> existingInstanceIds;

@Nullable private Disposable startupSubscription;

public InfoUpdateTrigger(InfoUpdater infoUpdater, Publisher<InstanceEvent> publisher, Duration updateInterval,
Duration infoLifetime, Duration maxBackoff) {
this(infoUpdater, publisher, updateInterval, infoLifetime, maxBackoff, Flux.empty());
}

public InfoUpdateTrigger(InfoUpdater infoUpdater, Publisher<InstanceEvent> publisher, Duration updateInterval,
Duration infoLifetime, Duration maxBackoff, Publisher<InstanceId> existingInstanceIds) {
super(publisher, InstanceEvent.class);
this.infoUpdater = infoUpdater;
this.intervalCheck = new IntervalCheck("info", this::updateInfo, updateInterval, infoLifetime, maxBackoff);
this.existingInstanceIds = existingInstanceIds;
}

@Override
Expand All @@ -64,10 +78,17 @@ protected Mono<Void> updateInfo(InstanceId instanceId) {
public void start() {
super.start();
this.intervalCheck.start();
this.startupSubscription = Flux.from(this.existingInstanceIds)
.flatMap(this::updateInfo, halfCpus())
.subscribe(null, (ex) -> log.warn("Unexpected error during startup info update", ex));
}

@Override
public void stop() {
if (this.startupSubscription != null) {
this.startupSubscription.dispose();
this.startupSubscription = null;
}
super.stop();
this.intervalCheck.stop();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@

import java.time.Duration;

import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.Disposable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

Expand All @@ -29,6 +31,8 @@
import de.codecentric.boot.admin.server.domain.events.InstanceRegistrationUpdatedEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;

import static de.codecentric.boot.admin.server.utils.concurrency.ConcurrencyUtils.halfCpus;

public class StatusUpdateTrigger extends AbstractEventHandler<InstanceEvent> {

private static final Logger log = LoggerFactory.getLogger(StatusUpdateTrigger.class);
Expand All @@ -37,12 +41,22 @@ public class StatusUpdateTrigger extends AbstractEventHandler<InstanceEvent> {

private final IntervalCheck intervalCheck;

private final Publisher<InstanceId> existingInstanceIds;

@Nullable private Disposable startupSubscription;

public StatusUpdateTrigger(StatusUpdater statusUpdater, Publisher<InstanceEvent> publisher, Duration updateInterval,
Duration statusLifetime, Duration maxBackoff) {
this(statusUpdater, publisher, updateInterval, statusLifetime, maxBackoff, Flux.empty());
}

public StatusUpdateTrigger(StatusUpdater statusUpdater, Publisher<InstanceEvent> publisher, Duration updateInterval,
Duration statusLifetime, Duration maxBackoff, Publisher<InstanceId> existingInstanceIds) {
super(publisher, InstanceEvent.class);
this.statusUpdater = statusUpdater;
this.intervalCheck = new IntervalCheck("status", this::updateStatus, updateInterval, statusLifetime,
maxBackoff);
this.existingInstanceIds = existingInstanceIds;
}

@Override
Expand All @@ -67,10 +81,17 @@ protected Mono<Void> updateStatus(InstanceId instanceId) {
public void start() {
super.start();
this.intervalCheck.start();
this.startupSubscription = Flux.from(this.existingInstanceIds)
.flatMap(this::updateStatus, halfCpus())
.subscribe(null, (ex) -> log.warn("Unexpected error during startup status update", ex));
}

@Override
public void stop() {
if (this.startupSubscription != null) {
this.startupSubscription.dispose();
this.startupSubscription = null;
}
super.stop();
this.intervalCheck.stop();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.utils.concurrency;

public final class ConcurrencyUtils {

private ConcurrencyUtils() {
throw new AssertionError("Utility class should not be instantiated");
}

public static int halfCpus() {
return halfCpus(Runtime.getRuntime().availableProcessors());
}

// Visible for testing
static int halfCpus(int availableProcessors) {
return Math.max(1, availableProcessors / 2);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

package de.codecentric.boot.admin.server.domain.entities;

import java.time.Instant;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
Expand All @@ -24,6 +26,7 @@
import reactor.test.StepVerifier;

import de.codecentric.boot.admin.server.domain.events.InstanceRegisteredEvent;
import de.codecentric.boot.admin.server.domain.events.InstanceStatusChangedEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.domain.values.Registration;
import de.codecentric.boot.admin.server.domain.values.StatusInfo;
Expand Down Expand Up @@ -100,6 +103,24 @@ void should_update_cache_after_error() {
.verifyComplete();
}

@Test
void should_replay_initial_events_in_version_order() {
this.repository.stop();
InstanceId id = InstanceId.of("clock-skewed");
Instant now = Instant.now();
Registration registration = Registration.create("app", "https://health").build();
when(this.eventStore.findAll())
.thenReturn(Flux.just(new InstanceStatusChangedEvent(id, 1L, now.minusSeconds(30), StatusInfo.ofDown()),
new InstanceRegisteredEvent(id, 0L, now, registration)));

this.repository.start();

StepVerifier.create(this.repository.find(id)).assertNext((instance) -> {
assertThat(instance.isRegistered()).isTrue();
assertThat(instance.getStatusInfo().getStatus()).isEqualTo(StatusInfo.STATUS_DOWN);
}).verifyComplete();
}

@Test
void should_return_outdated_instance_not_present_in_cache() {
this.repository.stop();
Expand Down
Loading
Loading