From 5de3923773a8793c6af74c87de8fa7685d0c2f5d Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 11:20:34 +0200 Subject: [PATCH 01/50] Round one FSM --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 218 ++----- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 534 ++---------------- 2 files changed, 86 insertions(+), 666 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index e54768642f..1c61e8f7b4 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -1,22 +1,6 @@ -/* - * Copyright 2019-present HiveMQ GmbH - * - * 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 - * - * http://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 com.hivemq.fsm; -import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; -import org.jetbrains.annotations.NotNull; +import com.hivemq.extension.sdk.api.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,9 +11,9 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public abstract class ProtocolAdapterFSM implements Consumer { +public class ProtocolAdapterFSM { - private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); + private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); public enum StateEnum { DISCONNECTED, @@ -43,38 +27,28 @@ public enum StateEnum { NOT_SUPPORTED } - public static final @NotNull Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED - StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // can go back to DISCONNECTED - StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // transition to CONNECTING in case of recovery, DISCONNECTED for direct transition - StateEnum.DISCONNECTING, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can go to DISCONNECTED or CLOSING - StateEnum.CLOSING, Set.of(StateEnum.CLOSED), - StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), - StateEnum.ERROR, Set.of(StateEnum.CONNECTING, StateEnum.DISCONNECTED), // can recover from error - StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING) // can restart from closed or go to closing - ); - - public enum AdapterStateEnum { - STARTING, + public enum AdapterState { STARTED, STOPPING, STOPPED } - public static final Map> possibleAdapterStateTransitions = Map.of( - AdapterStateEnum.STOPPED, Set.of(AdapterStateEnum.STARTING), - AdapterStateEnum.STARTING, Set.of(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPED), - AdapterStateEnum.STARTED, Set.of(AdapterStateEnum.STOPPING), - AdapterStateEnum.STOPPING, Set.of(AdapterStateEnum.STOPPED) + public static final Map> possibleTransitions = Map.of( + StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING), + StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR), + StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING), + StateEnum.DISCONNECTING, Set.of(StateEnum.CONNECTING), + StateEnum.CLOSING, Set.of(StateEnum.CLOSED), + StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR) ); private final AtomicReference northboundState = new AtomicReference<>(StateEnum.DISCONNECTED); private final AtomicReference southboundState = new AtomicReference<>(StateEnum.DISCONNECTED); - private final AtomicReference adapterState = new AtomicReference<>(AdapterStateEnum.STOPPED); + private final AtomicReference adapterState = new AtomicReference<>(AdapterState.STOPPED); private final List> stateTransitionListeners = new CopyOnWriteArrayList<>(); - public record State(AdapterStateEnum state, StateEnum northbound, StateEnum southbound) { } + public record State(AdapterState state, StateEnum northbound, StateEnum southbound) { } private final String adapterId; @@ -82,173 +56,59 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { this.adapterId = adapterId; } - public abstract boolean onStarting(); + public void registerStateTransitionListener(final @NotNull Consumer stateTransitionListener) { + stateTransitionListeners.add(stateTransitionListener); + } - public abstract void onStopping(); + public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { + stateTransitionListeners.remove(stateTransitionListener); + } - public abstract boolean startSouthbound(); + public State currentState() { + return new State(adapterState.get(), northboundState.get(), southboundState.get()); + } - // ADAPTER signals public void startAdapter() { - if(transitionAdapterState(AdapterStateEnum.STARTING)) { - log.debug("Protocol adapter {} starting", adapterId); - if(onStarting()) { - if(!transitionAdapterState(AdapterStateEnum.STARTED)) { - log.warn("Protocol adapter {} already started", adapterId); - } - } else { - transitionAdapterState(AdapterStateEnum.STOPPED); - } + if(adapterState.compareAndSet(AdapterState.STOPPED, AdapterState.STARTED)) { + log.debug("Protocol adapter {} started", adapterId); + notifyListenersAboutStateTransition(getCurrentState()); } else { - log.info("Protocol adapter {} already started or starting", adapterId); + log.info("Protocol adapter {} already started", adapterId); } } public void stopAdapter() { - if(transitionAdapterState(AdapterStateEnum.STOPPING)) { - onStopping(); - if(!transitionAdapterState(AdapterStateEnum.STOPPED)) { - log.warn("Protocol adapter {} already stopped", adapterId); - } + if(adapterState.compareAndSet(AdapterState.STARTED, AdapterState.STOPPING)) { + log.debug("Protocol adapter {} stopped", adapterId); + notifyListenersAboutStateTransition(getCurrentState()); } else { log.info("Protocol adapter {} already stopped or stopping", adapterId); } } - public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) { - final var currentState = adapterState.get(); - if(canTransition(currentState, newState)) { - if(adapterState.compareAndSet(currentState, newState)) { - log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; - } - } else { - throw new IllegalStateException("Cannot transition adapter state to " + newState); - } - return false; - } - - public boolean transitionNorthboundState(final @NotNull StateEnum newState) { - final var currentState = northboundState.get(); - if(canTransition(currentState, newState)) { - if(northboundState.compareAndSet(currentState, newState)) { - log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; + public void transitionNorthboundState(final @NotNull StateEnum newState) { + if(canTransition(northboundState.get(), newState)) { + synchronized (northboundState) { + final StateEnum oldState = northboundState.getAndSet(newState); + log.debug("Northbound state transition from {} to {} for adapter {}", oldState, newState, adapterId); + notifyListenersAboutStateTransition(getCurrentState()); } } else { throw new IllegalStateException("Cannot transition northbound state to " + newState); } - return false; - } - - public boolean transitionSouthboundState(final @NotNull StateEnum newState) { - final var currentState = southboundState.get(); - if(canTransition(currentState, newState)) { - if(southboundState.compareAndSet(currentState, newState)) { - log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; - } - } else { - throw new IllegalStateException("Cannot transition southbound state to " + newState); - } - return false; - } - - @Override - public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) { - final var transitionResult = switch (connectionStatus) { - case CONNECTED -> - transitionNorthboundState(StateEnum.CONNECTED) && startSouthbound(); - - case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); - case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); - case ERROR -> transitionNorthboundState(StateEnum.ERROR); - case UNKNOWN -> transitionNorthboundState(StateEnum.DISCONNECTED); - case STATELESS -> transitionNorthboundState(StateEnum.NOT_SUPPORTED); - }; - if(!transitionResult) { - log.warn("Failed to transition connection state to {} for adapter {}", connectionStatus, adapterId); - } - } - - // Additional methods to support full state machine functionality - - public boolean startDisconnecting() { - return transitionNorthboundState(StateEnum.DISCONNECTING); - } - - public boolean startClosing() { - return transitionNorthboundState(StateEnum.CLOSING); - } - - public boolean startErrorClosing() { - return transitionNorthboundState(StateEnum.ERROR_CLOSING); - } - - public boolean markAsClosed() { - return transitionNorthboundState(StateEnum.CLOSED); - } - - public boolean recoverFromError() { - return transitionNorthboundState(StateEnum.CONNECTING); - } - - public boolean restartFromClosed() { - return transitionNorthboundState(StateEnum.DISCONNECTED); - } - - // Southbound equivalents - public boolean startSouthboundDisconnecting() { - return transitionSouthboundState(StateEnum.DISCONNECTING); - } - - public boolean startSouthboundClosing() { - return transitionSouthboundState(StateEnum.CLOSING); - } - - public boolean startSouthboundErrorClosing() { - return transitionSouthboundState(StateEnum.ERROR_CLOSING); - } - - public boolean markSouthboundAsClosed() { - return transitionSouthboundState(StateEnum.CLOSED); - } - - public boolean recoverSouthboundFromError() { - return transitionSouthboundState(StateEnum.CONNECTING); - } - - public boolean restartSouthboundFromClosed() { - return transitionSouthboundState(StateEnum.DISCONNECTED); - } - - public void registerStateTransitionListener(final @NotNull Consumer stateTransitionListener) { - stateTransitionListeners.add(stateTransitionListener); - } - - public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { - stateTransitionListeners.remove(stateTransitionListener); - } - - public State currentState() { - return new State(adapterState.get(), northboundState.get(), southboundState.get()); } private void notifyListenersAboutStateTransition(final @NotNull State newState) { stateTransitionListeners.forEach(listener -> listener.accept(newState)); } - private static boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { - final var allowedTransitions = possibleTransitions.get(currentState); + private boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { + final Set allowedTransitions = possibleTransitions.get(currentState); return allowedTransitions != null && allowedTransitions.contains(newState); } - private static boolean canTransition(final @NotNull AdapterStateEnum currentState, final @NotNull AdapterStateEnum newState) { - final var allowedTransitions = possibleAdapterStateTransitions.get(currentState); - return allowedTransitions != null && allowedTransitions.contains(newState); + private State getCurrentState() { + return new State(adapterState.get(), northboundState.get(), southboundState.get()); } } diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index 2f20a4b97a..89064209c1 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -1,524 +1,84 @@ -/* - * Copyright 2019-present HiveMQ GmbH - * - * 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 - * - * http://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 com.hivemq.fsm; -import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; +import java.util.concurrent.CountDownLatch; -import static com.hivemq.fsm.ProtocolAdapterFSM.AdapterStateEnum; -import static com.hivemq.fsm.ProtocolAdapterFSM.State; -import static com.hivemq.fsm.ProtocolAdapterFSM.StateEnum; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.awaitility.Awaitility.await; class ProtocolAdapterFSMTest { - private static final @NotNull String ID = "adapterId"; + public static final ProtocolAdapterFSM.State STATE_FULLY_STOPPED = + new ProtocolAdapterFSM.State(ProtocolAdapterFSM.AdapterState.STOPPED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED); - private @NotNull ProtocolAdapterFSM createBasicFSM() { - return new ProtocolAdapterFSM(ID) { - @Override - public boolean onStarting() { - return true; - } - - @Override - public void onStopping() { - // no-op - } - - @Override - public boolean startSouthbound() { - return true; - } - }; - } - - /** - * Creates an FSM that transitions southbound to CONNECTING when northbound connects. - */ - private @NotNull ProtocolAdapterFSM createFSMWithAutoSouthbound() { - return new ProtocolAdapterFSM(ID) { - @Override - public boolean onStarting() { - return true; - } - - @Override - public void onStopping() { - // no-opL - } - - @Override - public boolean startSouthbound() { - return transitionSouthboundState(StateEnum.CONNECTING); - } - }; - } - - private void assertState( - final @NotNull ProtocolAdapterFSM fsm, - final @NotNull AdapterStateEnum adapter, - final @NotNull StateEnum north, - final @NotNull StateEnum south) { - assertThat(fsm.currentState()).isEqualTo(new State(adapter, north, south)); - } - - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - // A D A P T E R L I F E C Y C L E - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - - @Test - void adapter_startsInStoppedState() { - final var fsm = createBasicFSM(); - assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); - } - - @Test - void adapter_successfulStartup() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); - } - - @Test - void adapter_failedStartup_returnsToStopped() { - final var fsm = new ProtocolAdapterFSM(ID) { - @Override - public boolean onStarting() { - return false; // Simulate startup failure - } - - @Override - public void onStopping() { - } - - @Override - public boolean startSouthbound() { - return true; - } - }; - - fsm.startAdapter(); - assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); - } - - @Test - void adapter_stopPreservesConnectionStates() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - fsm.stopAdapter(); - - assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.CONNECTED, StateEnum.DISCONNECTED); - } - - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - // N O R T H B O U N D C O N N E C T I O N - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - - @Test - void northbound_legacyDirectConnect() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.DISCONNECTED); - } - - @Test - void northbound_standardConnectFlow() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); - - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTED); - } - - @Test - void northbound_errorState() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); - fsm.accept(ProtocolAdapterState.ConnectionStatus.ERROR); - - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.ERROR, StateEnum.DISCONNECTED); - } - - @Test - void northbound_disconnecting() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - assertThat(fsm.startDisconnecting()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTING); - - fsm.transitionNorthboundState(StateEnum.DISCONNECTED); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); - } - - @Test - void northbound_closingSequence() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - assertThat(fsm.startClosing()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSING); - - assertThat(fsm.markAsClosed()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSED); - } - - @Test - void northbound_errorClosingSequence() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - assertThat(fsm.startErrorClosing()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR_CLOSING); - - assertThat(fsm.transitionNorthboundState(StateEnum.ERROR)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR); - } - - @Test - void northbound_errorRecovery() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - fsm.transitionNorthboundState(StateEnum.ERROR); - - assertThat(fsm.recoverFromError()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); - } - - @Test - void northbound_closedRestart() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CLOSED); - - assertThat(fsm.restartFromClosed()).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); - } - - @Test - void northbound_reconnectFromConnected() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - // Can transition back to CONNECTING for reconnection - assertThat(fsm.transitionNorthboundState(StateEnum.CONNECTING)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); - } - - @Test - void northbound_directDisconnectFromConnected() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - // Can transition directly to DISCONNECTED - assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); - } - - @Test - void northbound_abortConnecting() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - - // Can abort connection attempt - assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); - } - - @Test - void northbound_connectingToError() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - - assertThat(fsm.transitionNorthboundState(StateEnum.ERROR)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR); - } - - @Test - void northbound_errorToDisconnected() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - fsm.transitionNorthboundState(StateEnum.ERROR); - - // Can give up and go to DISCONNECTED - assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); - } - - @Test - void northbound_disconnectingToClosing() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - fsm.startDisconnecting(); - - // Can escalate disconnect to permanent close - assertThat(fsm.transitionNorthboundState(StateEnum.CLOSING)).isTrue(); - assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSING); - } - - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - // S O U T H B O U N D C O N N E C T I O N - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + public static final ProtocolAdapterFSM.State STATE_STARTED_NOT_CONNECTED = new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterState.STARTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED); @Test - void southbound_startsWhenNorthboundConnects() { - final var fsm = createFSMWithAutoSouthbound(); - fsm.startAdapter(); - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CONNECTING); + public void test_initialValue() { + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(STATE_FULLY_STOPPED); } @Test - void southbound_errorWhileNorthboundConnected() { - final var fsm = createFSMWithAutoSouthbound(); - fsm.startAdapter(); - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + public void test_listenersReceiveUpdates() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); + final var latchListener1 = new CountDownLatch(1); + final var latchListener2 = new CountDownLatch(1); - // Simulate async error - CompletableFuture.runAsync(() -> { - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - fsm.transitionSouthboundState(StateEnum.ERROR); - }); + protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener1.countDown()); + protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener2.countDown()); - await().atMost(1, TimeUnit.SECONDS) - .untilAsserted(() -> assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.ERROR)); - } + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(STATE_FULLY_STOPPED); - @Test - void southbound_fullLifecycle() { - final var fsm = createFSMWithAutoSouthbound(); - fsm.startAdapter(); - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + protocolAdapterFSM.startAdapter(); - // CONNECTING → CONNECTED - assertThat(fsm.transitionSouthboundState(StateEnum.CONNECTED)).isTrue(); + latchListener1.await(); + latchListener2.await(); - // CONNECTED → CLOSING → CLOSED - assertThat(fsm.startSouthboundClosing()).isTrue(); - assertThat(fsm.markSouthboundAsClosed()).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CLOSED); - } + assertThat(latchListener1.getCount()) + .isEqualTo(0); - @Test - void southbound_errorRecovery() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionSouthboundState(StateEnum.CONNECTING); - fsm.transitionSouthboundState(StateEnum.ERROR); + assertThat(latchListener2.getCount()) + .isEqualTo(0); - assertThat(fsm.recoverSouthboundFromError()).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CONNECTING); - } - - @Test - void southbound_closedRestart() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionSouthboundState(StateEnum.CLOSED); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(STATE_STARTED_NOT_CONNECTED); - assertThat(fsm.restartSouthboundFromClosed()).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTED); } @Test - void southbound_errorClosingSequence() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionSouthboundState(StateEnum.CONNECTED); - - assertThat(fsm.startSouthboundErrorClosing()).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.ERROR_CLOSING); + public void test_startThenStopAdapter() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); + final var latchListener1 = new CountDownLatch(1); + final var latchListener2 = new CountDownLatch(1); - assertThat(fsm.transitionSouthboundState(StateEnum.ERROR)).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.ERROR); - } + protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener1.countDown()); + protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener2.countDown()); - @Test - void southbound_disconnecting() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionSouthboundState(StateEnum.CONNECTED); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(STATE_FULLY_STOPPED); - assertThat(fsm.startSouthboundDisconnecting()).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTING); + protocolAdapterFSM.startAdapter(); - assertThat(fsm.transitionSouthboundState(StateEnum.DISCONNECTED)).isTrue(); - assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTED); - } + latchListener1.await(); + latchListener2.await(); - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - // N O T V A L I D T R A N S I T I O N S - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + assertThat(latchListener1.getCount()) + .isEqualTo(0); - @Test - void invalidTransition_disconnectedToClosing() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); + assertThat(latchListener2.getCount()) + .isEqualTo(0); - assertThatThrownBy(() -> fsm.transitionNorthboundState(StateEnum.CLOSING)).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Cannot transition northbound state to CLOSING"); - } + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(STATE_STARTED_NOT_CONNECTED); - @Test - void invalidTransition_connectingToErrorClosing() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - - assertThatThrownBy(() -> fsm.transitionNorthboundState(StateEnum.ERROR_CLOSING)).isInstanceOf( - IllegalStateException.class) - .hasMessageContaining("Cannot transition northbound state to ERROR_CLOSING"); } - @Test - void invalidTransition_southboundDisconnectedToClosing() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - - assertThatThrownBy(() -> fsm.transitionSouthboundState(StateEnum.CLOSING)).isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Cannot transition southbound state to CLOSING"); - } - - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - // S T A T E L I S T E N E R - // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - - @Test - void stateListener_notifiedOnTransition() { - final var fsm = createBasicFSM(); - final var capturedState = new AtomicReference(); - - fsm.registerStateTransitionListener(capturedState::set); - fsm.startAdapter(); - - assertThat(capturedState.get()).isNotNull(); - assertThat(capturedState.get().state()).isEqualTo(AdapterStateEnum.STARTED); - } - - @Test - void stateListener_multipleNotifications() { - final var fsm = createBasicFSM(); - final var stateCount = new java.util.concurrent.atomic.AtomicInteger(0); - - fsm.registerStateTransitionListener(state -> stateCount.incrementAndGet()); - - fsm.startAdapter(); // Triggers 2 transitions: STOPPED→STARTING, STARTING→STARTED - fsm.transitionNorthboundState(StateEnum.CONNECTING); - fsm.transitionNorthboundState(StateEnum.CONNECTED); - - assertThat(stateCount.get()).isEqualTo(4); // STOPPED→STARTING, STARTING→STARTED, DISCONNECTED→CONNECTING, CONNECTING→CONNECTED - } - - @Test - void stateListener_unregister() { - final var fsm = createBasicFSM(); - final var stateCount = new java.util.concurrent.atomic.AtomicInteger(0); - final Consumer listener = state -> stateCount.incrementAndGet(); - - fsm.registerStateTransitionListener(listener); - fsm.startAdapter(); // Should notify twice: STOPPED→STARTING, STARTING→STARTED - - fsm.unregisterStateTransitionListener(listener); - fsm.transitionNorthboundState(StateEnum.CONNECTING); // Should NOT notify - - assertThat(stateCount.get()).isEqualTo(2); // Two startup notifications - } - - @Test - void concurrentTransition_casFailure() { - final var fsm = createBasicFSM(); - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - - // First transition succeeds - final var result1 = fsm.transitionNorthboundState(StateEnum.CONNECTED); - assertThat(result1).isTrue(); - - // Second transition from CONNECTED - demonstrates sequential transitions work - // CONNECTED → CONNECTING is valid (reconnection scenario) - final var result2 = fsm.transitionNorthboundState(StateEnum.CONNECTING); - assertThat(result2).isTrue(); - } - - @Test - void diagramSequence_idealShutdown() { - final var fsm = createFSMWithAutoSouthbound(); - - // Step 1: Both DISCONNECTED (initial state) - assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); - - // Step 2: Start adapter, northbound CONNECTING - fsm.startAdapter(); - fsm.transitionNorthboundState(StateEnum.CONNECTING); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTING, StateEnum.DISCONNECTED); - - // Step 3: Northbound CONNECTED (triggers southbound start) - fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CONNECTING); - - // Step 4: Southbound CONNECTING (already done by accept), transition to CONNECTED - fsm.transitionSouthboundState(StateEnum.CONNECTED); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CONNECTED); - - // Step 5: Southbound CLOSING - fsm.startSouthboundClosing(); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CLOSING); - - // Step 6: Southbound CLOSED - fsm.markSouthboundAsClosed(); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CLOSED); - - // Step 7: Northbound CLOSING - fsm.startClosing(); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CLOSING, StateEnum.CLOSED); - - // Step 8: Northbound CLOSED - fsm.markAsClosed(); - assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CLOSED, StateEnum.CLOSED); - } } From bfca3062fe927fbaf4af355626bfdd8ddc076bd1 Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 12:44:53 +0200 Subject: [PATCH 02/50] Start/Stop working --- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index 89064209c1..8e44531d70 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -3,8 +3,10 @@ import org.junit.jupiter.api.Test; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; class ProtocolAdapterFSMTest { @@ -13,6 +15,11 @@ class ProtocolAdapterFSMTest { ProtocolAdapterFSM.StateEnum.DISCONNECTED, ProtocolAdapterFSM.StateEnum.DISCONNECTED); + public static final ProtocolAdapterFSM.State STATE_STOPPING_WITHOUT_CONNECTION = + new ProtocolAdapterFSM.State(ProtocolAdapterFSM.AdapterState.STOPPING, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED); + public static final ProtocolAdapterFSM.State STATE_STARTED_NOT_CONNECTED = new ProtocolAdapterFSM.State( ProtocolAdapterFSM.AdapterState.STARTED, ProtocolAdapterFSM.StateEnum.DISCONNECTED, @@ -56,28 +63,29 @@ public void test_listenersReceiveUpdates() throws Exception{ @Test public void test_startThenStopAdapter() throws Exception{ final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); - final var latchListener1 = new CountDownLatch(1); - final var latchListener2 = new CountDownLatch(1); - protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener1.countDown()); - protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener2.countDown()); + final var state = new AtomicReference(); + + protocolAdapterFSM.registerStateTransitionListener(state::set); assertThat(protocolAdapterFSM.currentState()) .isEqualTo(STATE_FULLY_STOPPED); protocolAdapterFSM.startAdapter(); - latchListener1.await(); - latchListener2.await(); - - assertThat(latchListener1.getCount()) - .isEqualTo(0); + await().untilAsserted(() -> + assertThat(state.get()) + .isNotNull() + .isEqualTo(STATE_STARTED_NOT_CONNECTED) + ); - assertThat(latchListener2.getCount()) - .isEqualTo(0); + protocolAdapterFSM.stopAdapter(); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(STATE_STARTED_NOT_CONNECTED); + await().untilAsserted(() -> + assertThat(state.get()) + .isNotNull() + .isEqualTo(STATE_STOPPING_WITHOUT_CONNECTION) + ); } From 70d8d94c74ff2ccf227f7f074a3a814d0d26dc36 Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 15:26:14 +0200 Subject: [PATCH 03/50] Northbound working in FSM --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 122 ++++++++++++---- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 137 +++++++++++------- 2 files changed, 181 insertions(+), 78 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 1c61e8f7b4..f013f106f1 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -1,5 +1,6 @@ package com.hivemq.fsm; +import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import com.hivemq.extension.sdk.api.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,7 +12,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public class ProtocolAdapterFSM { +public abstract class ProtocolAdapterFSM implements Consumer { private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); @@ -27,14 +28,8 @@ public enum StateEnum { NOT_SUPPORTED } - public enum AdapterState { - STARTED, - STOPPING, - STOPPED - } - public static final Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING), + StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTED,StateEnum.CONNECTING), //for compatibility, we allow to go from CONNECTING to CONNECTED directly StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR), StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING), StateEnum.DISCONNECTING, Set.of(StateEnum.CONNECTING), @@ -42,13 +37,27 @@ public enum AdapterState { StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR) ); + public enum AdapterStateEnum { + STARTING, + STARTED, + STOPPING, + STOPPED + } + + public static final Map> possibleAdapterStateTransitions = Map.of( + AdapterStateEnum.STOPPED, Set.of(AdapterStateEnum.STARTING), + AdapterStateEnum.STARTING, Set.of(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPED), + AdapterStateEnum.STARTED, Set.of(AdapterStateEnum.STOPPING), + AdapterStateEnum.STOPPING, Set.of(AdapterStateEnum.STOPPED) + ); + private final AtomicReference northboundState = new AtomicReference<>(StateEnum.DISCONNECTED); private final AtomicReference southboundState = new AtomicReference<>(StateEnum.DISCONNECTED); - private final AtomicReference adapterState = new AtomicReference<>(AdapterState.STOPPED); + private final AtomicReference adapterState = new AtomicReference<>(AdapterStateEnum.STOPPED); private final List> stateTransitionListeners = new CopyOnWriteArrayList<>(); - public record State(AdapterState state, StateEnum northbound, StateEnum southbound) { } + public record State(AdapterStateEnum state, StateEnum northbound, StateEnum southbound) { } private final String adapterId; @@ -68,47 +77,110 @@ public State currentState() { return new State(adapterState.get(), northboundState.get(), southboundState.get()); } + public abstract boolean onStarting(); + + // ADAPTER signals public void startAdapter() { - if(adapterState.compareAndSet(AdapterState.STOPPED, AdapterState.STARTED)) { - log.debug("Protocol adapter {} started", adapterId); - notifyListenersAboutStateTransition(getCurrentState()); + if(transitionAdapterState(AdapterStateEnum.STARTING)) { + log.debug("Protocol adapter {} starting", adapterId); + if(onStarting()) { + if(!adapterState.compareAndSet(AdapterStateEnum.STARTING, AdapterStateEnum.STARTED)) { + log.warn("Protocol adapter {} already started", adapterId); + } + } else { + adapterState.compareAndSet(AdapterStateEnum.STARTING, AdapterStateEnum.STOPPED); + } } else { - log.info("Protocol adapter {} already started", adapterId); + log.info("Protocol adapter {} already started or starting", adapterId); } } public void stopAdapter() { - if(adapterState.compareAndSet(AdapterState.STARTED, AdapterState.STOPPING)) { + if(adapterState.compareAndSet(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPING)) { + log.debug("Protocol adapter {} stopping", adapterId); + } else { + log.info("Protocol adapter {} already stopped or stopping", adapterId); + } + } + + public void adapterStopped() { + if(adapterState.compareAndSet(AdapterStateEnum.STOPPING, AdapterStateEnum.STOPPED)) { log.debug("Protocol adapter {} stopped", adapterId); - notifyListenersAboutStateTransition(getCurrentState()); } else { log.info("Protocol adapter {} already stopped or stopping", adapterId); } } - public void transitionNorthboundState(final @NotNull StateEnum newState) { - if(canTransition(northboundState.get(), newState)) { - synchronized (northboundState) { - final StateEnum oldState = northboundState.getAndSet(newState); - log.debug("Northbound state transition from {} to {} for adapter {}", oldState, newState, adapterId); + public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) { + final var currentState = adapterState.get(); + if(canTransition(currentState, newState)) { + if(adapterState.compareAndSet(currentState, newState)) { + log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); notifyListenersAboutStateTransition(getCurrentState()); + return true; + } + } else { + throw new IllegalStateException("Cannot transition adapter state to " + newState); + } + return false; + } + + public boolean transitionNorthboundState(final @NotNull StateEnum newState) { + final var currentState = northboundState.get(); + if(canTransition(currentState, newState)) { + if(northboundState.compareAndSet(currentState, newState)) { + log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); + notifyListenersAboutStateTransition(getCurrentState()); + return true; } } else { throw new IllegalStateException("Cannot transition northbound state to " + newState); } + return false; + } + + public boolean transitionSouthboundState(final @NotNull StateEnum newState) { + final var currentState = southboundState.get(); + if(canTransition(currentState, newState)) { + if(southboundState.compareAndSet(currentState, newState)) { + log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); + notifyListenersAboutStateTransition(getCurrentState()); + return true; + } + } else { + throw new IllegalStateException("Cannot transition southbound state to " + newState); + } + return false; + } + + @Override + public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) { + switch (connectionStatus) { + case CONNECTED -> transitionNorthboundState(StateEnum.CONNECTED); + case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); + case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); + case ERROR -> transitionNorthboundState(StateEnum.ERROR); + case UNKNOWN -> transitionNorthboundState(StateEnum.DISCONNECTED); + case STATELESS -> transitionNorthboundState(StateEnum.NOT_SUPPORTED); + } + } + + private State getCurrentState() { + return new State(adapterState.get(), northboundState.get(), southboundState.get()); } private void notifyListenersAboutStateTransition(final @NotNull State newState) { stateTransitionListeners.forEach(listener -> listener.accept(newState)); } - private boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { - final Set allowedTransitions = possibleTransitions.get(currentState); + private static boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { + final var allowedTransitions = possibleTransitions.get(currentState); return allowedTransitions != null && allowedTransitions.contains(newState); } - private State getCurrentState() { - return new State(adapterState.get(), northboundState.get(), southboundState.get()); + private static boolean canTransition(final @NotNull AdapterStateEnum currentState, final @NotNull AdapterStateEnum newState) { + final var allowedTransitions = possibleAdapterStateTransitions.get(currentState); + return allowedTransitions != null && allowedTransitions.contains(newState); } } diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index 8e44531d70..a0caa7c3d5 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -1,92 +1,123 @@ package com.hivemq.fsm; +import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import org.junit.jupiter.api.Test; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; - import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; class ProtocolAdapterFSMTest { - public static final ProtocolAdapterFSM.State STATE_FULLY_STOPPED = - new ProtocolAdapterFSM.State(ProtocolAdapterFSM.AdapterState.STOPPED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED); + @Test + public void test_startAdapter_withLegacyConnectBehavior() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + @Override + public boolean onStarting() { + return true; + } + }; - public static final ProtocolAdapterFSM.State STATE_STOPPING_WITHOUT_CONNECTION = - new ProtocolAdapterFSM.State(ProtocolAdapterFSM.AdapterState.STOPPING, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STOPPED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - public static final ProtocolAdapterFSM.State STATE_STARTED_NOT_CONNECTED = new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterState.STARTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED); + protocolAdapterFSM.startAdapter(); - @Test - public void test_initialValue() { - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(STATE_FULLY_STOPPED); + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } @Test - public void test_listenersReceiveUpdates() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); - final var latchListener1 = new CountDownLatch(1); - final var latchListener2 = new CountDownLatch(1); - - protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener1.countDown()); - protocolAdapterFSM.registerStateTransitionListener(newState -> latchListener2.countDown()); + public void test_startAdapter_withGoingThroughConnectingState() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + @Override + public boolean onStarting() { + return true; + } + }; assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(STATE_FULLY_STOPPED); + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STOPPED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); protocolAdapterFSM.startAdapter(); - latchListener1.await(); - latchListener2.await(); - - assertThat(latchListener1.getCount()) - .isEqualTo(0); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - assertThat(latchListener2.getCount()) - .isEqualTo(0); + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(STATE_STARTED_NOT_CONNECTED); + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTING, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } @Test - public void test_startThenStopAdapter() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId"); - - final var state = new AtomicReference(); - - protocolAdapterFSM.registerStateTransitionListener(state::set); + public void test_startAdapter_northboundError() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + @Override + public boolean onStarting() { + return true; + } + }; assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(STATE_FULLY_STOPPED); + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STOPPED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); protocolAdapterFSM.startAdapter(); - await().untilAsserted(() -> - assertThat(state.get()) - .isNotNull() - .isEqualTo(STATE_STARTED_NOT_CONNECTED) - ); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); - protocolAdapterFSM.stopAdapter(); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTING, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - await().untilAsserted(() -> - assertThat(state.get()) - .isNotNull() - .isEqualTo(STATE_STOPPING_WITHOUT_CONNECTION) - ); + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.ERROR); + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.ERROR, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } } From 503cea7e80519b9dc11b3c000a7ef96ad3d56f2d Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 15:32:30 +0200 Subject: [PATCH 04/50] CleanUp --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index f013f106f1..99052fcf94 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -65,18 +65,6 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { this.adapterId = adapterId; } - public void registerStateTransitionListener(final @NotNull Consumer stateTransitionListener) { - stateTransitionListeners.add(stateTransitionListener); - } - - public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { - stateTransitionListeners.remove(stateTransitionListener); - } - - public State currentState() { - return new State(adapterState.get(), northboundState.get(), southboundState.get()); - } - public abstract boolean onStarting(); // ADAPTER signals @@ -96,7 +84,7 @@ public void startAdapter() { } public void stopAdapter() { - if(adapterState.compareAndSet(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPING)) { + if(transitionAdapterState(AdapterStateEnum.STOPPING)) { log.debug("Protocol adapter {} stopping", adapterId); } else { log.info("Protocol adapter {} already stopped or stopping", adapterId); @@ -104,7 +92,7 @@ public void stopAdapter() { } public void adapterStopped() { - if(adapterState.compareAndSet(AdapterStateEnum.STOPPING, AdapterStateEnum.STOPPED)) { + if(transitionAdapterState(AdapterStateEnum.STOPPED)) { log.debug("Protocol adapter {} stopped", adapterId); } else { log.info("Protocol adapter {} already stopped or stopping", adapterId); @@ -116,7 +104,7 @@ public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) if(canTransition(currentState, newState)) { if(adapterState.compareAndSet(currentState, newState)) { log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(getCurrentState()); + notifyListenersAboutStateTransition(currentState()); return true; } } else { @@ -130,7 +118,7 @@ public boolean transitionNorthboundState(final @NotNull StateEnum newState) { if(canTransition(currentState, newState)) { if(northboundState.compareAndSet(currentState, newState)) { log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(getCurrentState()); + notifyListenersAboutStateTransition(currentState()); return true; } } else { @@ -144,7 +132,7 @@ public boolean transitionSouthboundState(final @NotNull StateEnum newState) { if(canTransition(currentState, newState)) { if(southboundState.compareAndSet(currentState, newState)) { log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(getCurrentState()); + notifyListenersAboutStateTransition(currentState()); return true; } } else { @@ -165,7 +153,15 @@ public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) } } - private State getCurrentState() { + public void registerStateTransitionListener(final @NotNull Consumer stateTransitionListener) { + stateTransitionListeners.add(stateTransitionListener); + } + + public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { + stateTransitionListeners.remove(stateTransitionListener); + } + + public State currentState() { return new State(adapterState.get(), northboundState.get(), southboundState.get()); } From 8a37383a30745f90661a00cc6d7eb9035e096a08 Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 15:33:41 +0200 Subject: [PATCH 05/50] CleanUp --- .../src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 99052fcf94..13a1cdec8e 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -143,13 +143,16 @@ public boolean transitionSouthboundState(final @NotNull StateEnum newState) { @Override public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) { - switch (connectionStatus) { + final var transitionResult = switch (connectionStatus) { case CONNECTED -> transitionNorthboundState(StateEnum.CONNECTED); case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); case ERROR -> transitionNorthboundState(StateEnum.ERROR); case UNKNOWN -> transitionNorthboundState(StateEnum.DISCONNECTED); case STATELESS -> transitionNorthboundState(StateEnum.NOT_SUPPORTED); + }; + if(!transitionResult) { + log.warn("Failed to transition connection state to {} for adapter {}", connectionStatus, adapterId); } } From 6929eb4350d7095d5ea8d9bf095be62fa85ed19b Mon Sep 17 00:00:00 2001 From: Jochen Mader Date: Fri, 26 Sep 2025 15:47:34 +0200 Subject: [PATCH 06/50] Stop northbound working --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 19 +++--- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 13a1cdec8e..641e3590f5 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -67,16 +67,18 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { public abstract boolean onStarting(); + public abstract void onStopping(); + // ADAPTER signals public void startAdapter() { if(transitionAdapterState(AdapterStateEnum.STARTING)) { log.debug("Protocol adapter {} starting", adapterId); if(onStarting()) { - if(!adapterState.compareAndSet(AdapterStateEnum.STARTING, AdapterStateEnum.STARTED)) { + if(!transitionAdapterState(AdapterStateEnum.STARTED)) { log.warn("Protocol adapter {} already started", adapterId); } } else { - adapterState.compareAndSet(AdapterStateEnum.STARTING, AdapterStateEnum.STOPPED); + transitionAdapterState(AdapterStateEnum.STOPPED); } } else { log.info("Protocol adapter {} already started or starting", adapterId); @@ -85,15 +87,10 @@ public void startAdapter() { public void stopAdapter() { if(transitionAdapterState(AdapterStateEnum.STOPPING)) { - log.debug("Protocol adapter {} stopping", adapterId); - } else { - log.info("Protocol adapter {} already stopped or stopping", adapterId); - } - } - - public void adapterStopped() { - if(transitionAdapterState(AdapterStateEnum.STOPPED)) { - log.debug("Protocol adapter {} stopped", adapterId); + onStopping(); + if(!transitionAdapterState(AdapterStateEnum.STOPPED)) { + log.warn("Protocol adapter {} already stopped", adapterId); + } } else { log.info("Protocol adapter {} already stopped or stopping", adapterId); } diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index a0caa7c3d5..f7d8e07431 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -15,6 +15,11 @@ public void test_startAdapter_withLegacyConnectBehavior() throws Exception{ public boolean onStarting() { return true; } + + @Override + public void onStopping() { + throw new IllegalStateException("Shouldn't be triggered"); + } }; assertThat(protocolAdapterFSM.currentState()) @@ -47,6 +52,11 @@ public void test_startAdapter_withGoingThroughConnectingState() throws Exception public boolean onStarting() { return true; } + + @Override + public void onStopping() { + throw new IllegalStateException("Shouldn't be triggered"); + } }; assertThat(protocolAdapterFSM.currentState()) @@ -87,6 +97,11 @@ public void test_startAdapter_northboundError() throws Exception{ public boolean onStarting() { return true; } + + @Override + public void onStopping() { + throw new IllegalStateException("Shouldn't be triggered"); + } }; assertThat(protocolAdapterFSM.currentState()) @@ -120,4 +135,56 @@ public boolean onStarting() { ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } + @Test + public void test_startAndStopAdapter() throws Exception{ + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + @Override + public boolean onStarting() { + return true; + } + + @Override + public void onStopping() { + } + }; + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STOPPED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.startAdapter(); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTING, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + + protocolAdapterFSM.stopAdapter(); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STOPPED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + } + } From 625fcb144979e6a6da7c3115adc4eeac07fab956 Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 26 Sep 2025 17:04:14 +0200 Subject: [PATCH 07/50] add southbound transition logic --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 48 ++++++++++++- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 72 +++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 641e3590f5..e955ee0759 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -14,6 +14,44 @@ public abstract class ProtocolAdapterFSM implements Consumer { + // +--------------------------------------------------------------------+ + // | | + // | | + // | +--------------+ | + // | | DISCONNECTED | | + // | +--------------+ | + // | ^ | + // | | | + // | +---------------------------+ | + // | | | + // | | | + // | | (connect) | + // | | | + // v v | + // +------------+ | + // | CONNECTING | | + // +--+------+--+ | + // | | | + // | +--------------------------------------------------------+ | + // | | | + // v v | + //+-----------+ +-------+ | + //| CONNECTED | | ERROR | -+ + //+-----+-----+ +-------+ + // | ^ + // +-----------------------------+-------------------------------+ + // | | | + // v v v + //+---------------+ +---------+ +---------------+ + //| DISCONNECTING | | CLOSING | | ERROR_CLOSING | + //+---------------+ +---------+ +---------------+ + // | | | + // | v v + // +-----------------------------+ (to ERROR) + // +--------+ + // | CLOSED | + // +--------+ + private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); public enum StateEnum { @@ -31,8 +69,8 @@ public enum StateEnum { public static final Map> possibleTransitions = Map.of( StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTED,StateEnum.CONNECTING), //for compatibility, we allow to go from CONNECTING to CONNECTED directly StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR), - StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING), - StateEnum.DISCONNECTING, Set.of(StateEnum.CONNECTING), + StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING ,StateEnum.CLOSING, StateEnum.ERROR_CLOSING), // transition to CONNECTING in case of recovery + StateEnum.DISCONNECTING, Set.of(StateEnum.CLOSING), StateEnum.CLOSING, Set.of(StateEnum.CLOSED), StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR) ); @@ -69,6 +107,8 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { public abstract void onStopping(); + public abstract boolean startSouthbound(); + // ADAPTER signals public void startAdapter() { if(transitionAdapterState(AdapterStateEnum.STARTING)) { @@ -141,7 +181,9 @@ public boolean transitionSouthboundState(final @NotNull StateEnum newState) { @Override public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) { final var transitionResult = switch (connectionStatus) { - case CONNECTED -> transitionNorthboundState(StateEnum.CONNECTED); + case CONNECTED -> + transitionNorthboundState(StateEnum.CONNECTED) && startSouthbound(); + case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); case ERROR -> transitionNorthboundState(StateEnum.ERROR); diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index f7d8e07431..4fc1fb20ea 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -3,6 +3,10 @@ import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import org.junit.jupiter.api.Test; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -20,6 +24,11 @@ public boolean onStarting() { public void onStopping() { throw new IllegalStateException("Shouldn't be triggered"); } + + @Override + public boolean startSouthbound() { + return true; + } }; assertThat(protocolAdapterFSM.currentState()) @@ -57,6 +66,11 @@ public boolean onStarting() { public void onStopping() { throw new IllegalStateException("Shouldn't be triggered"); } + + @Override + public boolean startSouthbound() { + return true; + } }; assertThat(protocolAdapterFSM.currentState()) @@ -102,6 +116,11 @@ public boolean onStarting() { public void onStopping() { throw new IllegalStateException("Shouldn't be triggered"); } + + @Override + public boolean startSouthbound() { + return true; + } }; assertThat(protocolAdapterFSM.currentState()) @@ -135,6 +154,54 @@ public void onStopping() { ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } + @Test + public void test_startAdapter_northbound_connected_southbound_error() throws Exception{ + + final CountDownLatch latch = new CountDownLatch(1); + final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + @Override + public boolean onStarting() { + return true; + } + + @Override + public void onStopping() { + } + + @Override + public boolean startSouthbound() { + CompletableFuture.runAsync(() -> { + try { + TimeUnit.SECONDS.sleep(2); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + transitionSouthboundState(StateEnum.ERROR); + }); + return transitionSouthboundState(StateEnum.CONNECTING); + } + }; + + protocolAdapterFSM.startAdapter(); + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.CONNECTING)); + + await().untilAsserted(() -> { + assertThat(protocolAdapterFSM.currentState()) + .isEqualTo(new ProtocolAdapterFSM.State( + ProtocolAdapterFSM.AdapterStateEnum.STARTED, + ProtocolAdapterFSM.StateEnum.CONNECTED, + ProtocolAdapterFSM.StateEnum.ERROR)); // this is a valid state for EDGE + }); + } + @Test public void test_startAndStopAdapter() throws Exception{ final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { @@ -146,6 +213,11 @@ public boolean onStarting() { @Override public void onStopping() { } + + @Override + public boolean startSouthbound() { + return true; + } }; assertThat(protocolAdapterFSM.currentState()) From a72bc002adc8b95d33187f079c6610065b8619e7 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 30 Sep 2025 15:55:32 +0200 Subject: [PATCH 08/50] small improvements --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 46 ++----------------- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 15 +++--- 2 files changed, 13 insertions(+), 48 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index e955ee0759..40cd42eb58 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -1,7 +1,7 @@ package com.hivemq.fsm; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; -import com.hivemq.extension.sdk.api.annotations.NotNull; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,45 +14,7 @@ public abstract class ProtocolAdapterFSM implements Consumer { - // +--------------------------------------------------------------------+ - // | | - // | | - // | +--------------+ | - // | | DISCONNECTED | | - // | +--------------+ | - // | ^ | - // | | | - // | +---------------------------+ | - // | | | - // | | | - // | | (connect) | - // | | | - // v v | - // +------------+ | - // | CONNECTING | | - // +--+------+--+ | - // | | | - // | +--------------------------------------------------------+ | - // | | | - // v v | - //+-----------+ +-------+ | - //| CONNECTED | | ERROR | -+ - //+-----+-----+ +-------+ - // | ^ - // +-----------------------------+-------------------------------+ - // | | | - // v v v - //+---------------+ +---------+ +---------------+ - //| DISCONNECTING | | CLOSING | | ERROR_CLOSING | - //+---------------+ +---------+ +---------------+ - // | | | - // | v v - // +-----------------------------+ (to ERROR) - // +--------+ - // | CLOSED | - // +--------+ - - private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); + private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); public enum StateEnum { DISCONNECTED, @@ -66,8 +28,8 @@ public enum StateEnum { NOT_SUPPORTED } - public static final Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTED,StateEnum.CONNECTING), //for compatibility, we allow to go from CONNECTING to CONNECTED directly + public static final @NotNull Map> possibleTransitions = Map.of( + StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR), StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING ,StateEnum.CLOSING, StateEnum.ERROR_CLOSING), // transition to CONNECTING in case of recovery StateEnum.DISCONNECTING, Set.of(StateEnum.CLOSING), diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index 4fc1fb20ea..a4767dc6e6 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -1,6 +1,7 @@ package com.hivemq.fsm; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import java.util.concurrent.CompletableFuture; @@ -12,9 +13,11 @@ class ProtocolAdapterFSMTest { + private static final @NotNull String ID = "adapterId"; + @Test public void test_startAdapter_withLegacyConnectBehavior() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -45,6 +48,7 @@ public boolean startSouthbound() { ProtocolAdapterFSM.StateEnum.DISCONNECTED, ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // northbound is connected protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); assertThat(protocolAdapterFSM.currentState()) @@ -56,7 +60,7 @@ public boolean startSouthbound() { @Test public void test_startAdapter_withGoingThroughConnectingState() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -106,7 +110,7 @@ public boolean startSouthbound() { @Test public void test_startAdapter_northboundError() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -158,7 +162,7 @@ public boolean startSouthbound() { public void test_startAdapter_northbound_connected_southbound_error() throws Exception{ final CountDownLatch latch = new CountDownLatch(1); - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -204,7 +208,7 @@ public boolean startSouthbound() { @Test public void test_startAndStopAdapter() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM("adapterId") { + final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -258,5 +262,4 @@ public boolean startSouthbound() { ProtocolAdapterFSM.StateEnum.CONNECTED, ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } - } From b0c993462948c0c329bbd3258295d94a46640a9c Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 1 Oct 2025 12:30:23 +0200 Subject: [PATCH 09/50] document and add coverage --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 65 +- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 582 +++++++++++++----- 2 files changed, 472 insertions(+), 175 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 40cd42eb58..f90c71a568 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -29,12 +29,14 @@ public enum StateEnum { } public static final @NotNull Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly - StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR), - StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING ,StateEnum.CLOSING, StateEnum.ERROR_CLOSING), // transition to CONNECTING in case of recovery - StateEnum.DISCONNECTING, Set.of(StateEnum.CLOSING), + StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED + StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // can go back to DISCONNECTED + StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // transition to CONNECTING in case of recovery, DISCONNECTED for direct transition + StateEnum.DISCONNECTING, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can go to DISCONNECTED or CLOSING StateEnum.CLOSING, Set.of(StateEnum.CLOSED), - StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR) + StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), + StateEnum.ERROR, Set.of(StateEnum.CONNECTING, StateEnum.DISCONNECTED), // can recover from error + StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING) // can restart from closed or go to closing ); public enum AdapterStateEnum { @@ -157,11 +159,62 @@ public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) } } + // Additional methods to support full state machine functionality + + public boolean startDisconnecting() { + return transitionNorthboundState(StateEnum.DISCONNECTING); + } + + public boolean startClosing() { + return transitionNorthboundState(StateEnum.CLOSING); + } + + public boolean startErrorClosing() { + return transitionNorthboundState(StateEnum.ERROR_CLOSING); + } + + public boolean markAsClosed() { + return transitionNorthboundState(StateEnum.CLOSED); + } + + public boolean recoverFromError() { + return transitionNorthboundState(StateEnum.CONNECTING); + } + + public boolean restartFromClosed() { + return transitionNorthboundState(StateEnum.DISCONNECTED); + } + + // Southbound equivalents + public boolean startSouthboundDisconnecting() { + return transitionSouthboundState(StateEnum.DISCONNECTING); + } + + public boolean startSouthboundClosing() { + return transitionSouthboundState(StateEnum.CLOSING); + } + + public boolean startSouthboundErrorClosing() { + return transitionSouthboundState(StateEnum.ERROR_CLOSING); + } + + public boolean markSouthboundAsClosed() { + return transitionSouthboundState(StateEnum.CLOSED); + } + + public boolean recoverSouthboundFromError() { + return transitionSouthboundState(StateEnum.CONNECTING); + } + + public boolean restartSouthboundFromClosed() { + return transitionSouthboundState(StateEnum.DISCONNECTED); + } + public void registerStateTransitionListener(final @NotNull Consumer stateTransitionListener) { stateTransitionListeners.add(stateTransitionListener); } - public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { + public void unregisterStateTransitionListener(final @NotNull Consumer stateTransitionListener) { stateTransitionListeners.remove(stateTransitionListener); } diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index a4767dc6e6..aa471854ed 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -5,19 +5,23 @@ import org.junit.jupiter.api.Test; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import static com.hivemq.fsm.ProtocolAdapterFSM.AdapterStateEnum; +import static com.hivemq.fsm.ProtocolAdapterFSM.State; +import static com.hivemq.fsm.ProtocolAdapterFSM.StateEnum; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; class ProtocolAdapterFSMTest { private static final @NotNull String ID = "adapterId"; - @Test - public void test_startAdapter_withLegacyConnectBehavior() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { + private @NotNull ProtocolAdapterFSM createBasicFSM() { + return new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -25,7 +29,7 @@ public boolean onStarting() { @Override public void onStopping() { - throw new IllegalStateException("Shouldn't be triggered"); + // no-op } @Override @@ -33,34 +37,13 @@ public boolean startSouthbound() { return true; } }; - - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STOPPED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - - protocolAdapterFSM.startAdapter(); - - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - - // northbound is connected - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); } - @Test - public void test_startAdapter_withGoingThroughConnectingState() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { + /** + * Creates an FSM that transitions southbound to CONNECTING when northbound connects. + */ + private @NotNull ProtocolAdapterFSM createFSMWithAutoSouthbound() { + return new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { return true; @@ -68,57 +51,51 @@ public boolean onStarting() { @Override public void onStopping() { - throw new IllegalStateException("Shouldn't be triggered"); + // no-opL } @Override public boolean startSouthbound() { - return true; + return transitionSouthboundState(StateEnum.CONNECTING); } }; + } - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STOPPED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - - protocolAdapterFSM.startAdapter(); - - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); - - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + private void assertState( + final @NotNull ProtocolAdapterFSM fsm, + final @NotNull AdapterStateEnum adapter, + final @NotNull StateEnum north, + final @NotNull StateEnum south) { + assertThat(fsm.currentState()).isEqualTo(new State(adapter, north, south)); + } - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTING, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + // A D A P T E R L I F E C Y C L E + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + @Test + void adapter_startsInStoppedState() { + final var fsm = createBasicFSM(); + assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); + } - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + @Test + void adapter_successfulStartup() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); } @Test - public void test_startAdapter_northboundError() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { + void adapter_failedStartup_returnsToStopped() { + final var fsm = new ProtocolAdapterFSM(ID) { @Override public boolean onStarting() { - return true; + return false; // Simulate startup failure } @Override public void onStopping() { - throw new IllegalStateException("Shouldn't be triggered"); } @Override @@ -127,139 +104,406 @@ public boolean startSouthbound() { } }; - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STOPPED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + fsm.startAdapter(); + assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); + } - protocolAdapterFSM.startAdapter(); + @Test + void adapter_stopPreservesConnectionStates() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + fsm.stopAdapter(); - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.CONNECTED, StateEnum.DISCONNECTED); + } - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTING, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + // N O R T H B O U N D C O N N E C T I O N + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.ERROR); + @Test + void northbound_legacyDirectConnect() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.ERROR, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.DISCONNECTED); } @Test - public void test_startAdapter_northbound_connected_southbound_error() throws Exception{ + void northbound_standardConnectFlow() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); - final CountDownLatch latch = new CountDownLatch(1); - final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { - @Override - public boolean onStarting() { - return true; - } + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); - @Override - public void onStopping() { - } + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTED); + } - @Override - public boolean startSouthbound() { - CompletableFuture.runAsync(() -> { - try { - TimeUnit.SECONDS.sleep(2); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - transitionSouthboundState(StateEnum.ERROR); - }); - return transitionSouthboundState(StateEnum.CONNECTING); - } - }; + @Test + void northbound_errorState() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + fsm.accept(ProtocolAdapterState.ConnectionStatus.ERROR); - protocolAdapterFSM.startAdapter(); - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); - - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.CONNECTING)); - - await().untilAsserted(() -> { - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.ERROR)); // this is a valid state for EDGE - }); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.ERROR, StateEnum.DISCONNECTED); } @Test - public void test_startAndStopAdapter() throws Exception{ - final var protocolAdapterFSM = new ProtocolAdapterFSM(ID) { - @Override - public boolean onStarting() { - return true; - } + void northbound_disconnecting() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); - @Override - public void onStopping() { - } + assertThat(fsm.startDisconnecting()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTING); - @Override - public boolean startSouthbound() { - return true; + fsm.transitionNorthboundState(StateEnum.DISCONNECTED); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void northbound_closingSequence() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + + assertThat(fsm.startClosing()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSING); + + assertThat(fsm.markAsClosed()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSED); + } + + @Test + void northbound_errorClosingSequence() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + + assertThat(fsm.startErrorClosing()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR_CLOSING); + + assertThat(fsm.transitionNorthboundState(StateEnum.ERROR)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR); + } + + @Test + void northbound_errorRecovery() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + fsm.transitionNorthboundState(StateEnum.ERROR); + + assertThat(fsm.recoverFromError()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); + } + + @Test + void northbound_closedRestart() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CLOSED); + + assertThat(fsm.restartFromClosed()).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void northbound_reconnectFromConnected() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + + // Can transition back to CONNECTING for reconnection + assertThat(fsm.transitionNorthboundState(StateEnum.CONNECTING)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CONNECTING); + } + + @Test + void northbound_directDisconnectFromConnected() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + + // Can transition directly to DISCONNECTED + assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void northbound_abortConnecting() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + + // Can abort connection attempt + assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void northbound_connectingToError() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + + assertThat(fsm.transitionNorthboundState(StateEnum.ERROR)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.ERROR); + } + + @Test + void northbound_errorToDisconnected() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + fsm.transitionNorthboundState(StateEnum.ERROR); + + // Can give up and go to DISCONNECTED + assertThat(fsm.transitionNorthboundState(StateEnum.DISCONNECTED)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void northbound_disconnectingToClosing() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + fsm.startDisconnecting(); + + // Can escalate disconnect to permanent close + assertThat(fsm.transitionNorthboundState(StateEnum.CLOSING)).isTrue(); + assertThat(fsm.currentState().northbound()).isEqualTo(StateEnum.CLOSING); + } + + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + // S O U T H B O U N D C O N N E C T I O N + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + + @Test + void southbound_startsWhenNorthboundConnects() { + final var fsm = createFSMWithAutoSouthbound(); + fsm.startAdapter(); + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CONNECTING); + } + + @Test + void southbound_errorWhileNorthboundConnected() { + final var fsm = createFSMWithAutoSouthbound(); + fsm.startAdapter(); + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + // Simulate async error + CompletableFuture.runAsync(() -> { + try { + TimeUnit.MILLISECONDS.sleep(100); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); } - }; + fsm.transitionSouthboundState(StateEnum.ERROR); + }); + + await().atMost(1, TimeUnit.SECONDS) + .untilAsserted(() -> assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.ERROR)); + } + + @Test + void southbound_fullLifecycle() { + final var fsm = createFSMWithAutoSouthbound(); + fsm.startAdapter(); + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + + // CONNECTING → CONNECTED + assertThat(fsm.transitionSouthboundState(StateEnum.CONNECTED)).isTrue(); + + // CONNECTED → CLOSING → CLOSED + assertThat(fsm.startSouthboundClosing()).isTrue(); + assertThat(fsm.markSouthboundAsClosed()).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CLOSED); + } + + @Test + void southbound_errorRecovery() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionSouthboundState(StateEnum.CONNECTING); + fsm.transitionSouthboundState(StateEnum.ERROR); + + assertThat(fsm.recoverSouthboundFromError()).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.CONNECTING); + } + + @Test + void southbound_closedRestart() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionSouthboundState(StateEnum.CLOSED); + + assertThat(fsm.restartSouthboundFromClosed()).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + @Test + void southbound_errorClosingSequence() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionSouthboundState(StateEnum.CONNECTED); + + assertThat(fsm.startSouthboundErrorClosing()).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.ERROR_CLOSING); + + assertThat(fsm.transitionSouthboundState(StateEnum.ERROR)).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.ERROR); + } + + @Test + void southbound_disconnecting() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionSouthboundState(StateEnum.CONNECTED); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STOPPED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + assertThat(fsm.startSouthboundDisconnecting()).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTING); + + assertThat(fsm.transitionSouthboundState(StateEnum.DISCONNECTED)).isTrue(); + assertThat(fsm.currentState().southbound()).isEqualTo(StateEnum.DISCONNECTED); + } + + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + // N O T V A L I D T R A N S I T I O N S + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + + @Test + void invalidTransition_disconnectedToClosing() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + + assertThatThrownBy(() -> fsm.transitionNorthboundState(StateEnum.CLOSING)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot transition northbound state to CLOSING"); + } + + @Test + void invalidTransition_connectingToErrorClosing() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + + assertThatThrownBy(() -> fsm.transitionNorthboundState(StateEnum.ERROR_CLOSING)).isInstanceOf( + IllegalStateException.class) + .hasMessageContaining("Cannot transition northbound state to ERROR_CLOSING"); + } + + @Test + void invalidTransition_southboundDisconnectedToClosing() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + + assertThatThrownBy(() -> fsm.transitionSouthboundState(StateEnum.CLOSING)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot transition southbound state to CLOSING"); + } + + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + // S T A T E L I S T E N E R + // ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ + + @Test + void stateListener_notifiedOnTransition() { + final var fsm = createBasicFSM(); + final var capturedState = new AtomicReference(); + + fsm.registerStateTransitionListener(capturedState::set); + fsm.startAdapter(); + + assertThat(capturedState.get()).isNotNull(); + assertThat(capturedState.get().state()).isEqualTo(AdapterStateEnum.STARTED); + } + + @Test + void stateListener_multipleNotifications() { + final var fsm = createBasicFSM(); + final var stateCount = new java.util.concurrent.atomic.AtomicInteger(0); + + fsm.registerStateTransitionListener(state -> stateCount.incrementAndGet()); + + fsm.startAdapter(); // Triggers 2 transitions: STOPPED→STARTING, STARTING→STARTED + fsm.transitionNorthboundState(StateEnum.CONNECTING); + fsm.transitionNorthboundState(StateEnum.CONNECTED); + + assertThat(stateCount.get()).isEqualTo(4); // STOPPED→STARTING, STARTING→STARTED, DISCONNECTED→CONNECTING, CONNECTING→CONNECTED + } + + @Test + void stateListener_unregister() { + final var fsm = createBasicFSM(); + final var stateCount = new java.util.concurrent.atomic.AtomicInteger(0); + final Consumer listener = state -> stateCount.incrementAndGet(); + + fsm.registerStateTransitionListener(listener); + fsm.startAdapter(); // Should notify twice: STOPPED→STARTING, STARTING→STARTED + + fsm.unregisterStateTransitionListener(listener); + fsm.transitionNorthboundState(StateEnum.CONNECTING); // Should NOT notify + + assertThat(stateCount.get()).isEqualTo(2); // Two startup notifications + } + + @Test + void concurrentTransition_casFailure() { + final var fsm = createBasicFSM(); + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + + // First transition succeeds + final var result1 = fsm.transitionNorthboundState(StateEnum.CONNECTED); + assertThat(result1).isTrue(); + + // Second transition from CONNECTED - demonstrates sequential transitions work + // CONNECTED → CONNECTING is valid (reconnection scenario) + final var result2 = fsm.transitionNorthboundState(StateEnum.CONNECTING); + assertThat(result2).isTrue(); + } + + @Test + void diagramSequence_idealShutdown() { + final var fsm = createFSMWithAutoSouthbound(); - protocolAdapterFSM.startAdapter(); + // Step 1: Both DISCONNECTED (initial state) + assertState(fsm, AdapterStateEnum.STOPPED, StateEnum.DISCONNECTED, StateEnum.DISCONNECTED); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // Step 2: Start adapter, northbound CONNECTING + fsm.startAdapter(); + fsm.transitionNorthboundState(StateEnum.CONNECTING); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTING, StateEnum.DISCONNECTED); - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTING); + // Step 3: Northbound CONNECTED (triggers southbound start) + fsm.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CONNECTING); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTING, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // Step 4: Southbound CONNECTING (already done by accept), transition to CONNECTED + fsm.transitionSouthboundState(StateEnum.CONNECTED); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CONNECTED); - protocolAdapterFSM.accept(ProtocolAdapterState.ConnectionStatus.CONNECTED); + // Step 5: Southbound CLOSING + fsm.startSouthboundClosing(); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CLOSING); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STARTED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // Step 6: Southbound CLOSED + fsm.markSouthboundAsClosed(); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CONNECTED, StateEnum.CLOSED); - protocolAdapterFSM.stopAdapter(); + // Step 7: Northbound CLOSING + fsm.startClosing(); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CLOSING, StateEnum.CLOSED); - assertThat(protocolAdapterFSM.currentState()) - .isEqualTo(new ProtocolAdapterFSM.State( - ProtocolAdapterFSM.AdapterStateEnum.STOPPED, - ProtocolAdapterFSM.StateEnum.CONNECTED, - ProtocolAdapterFSM.StateEnum.DISCONNECTED)); + // Step 8: Northbound CLOSED + fsm.markAsClosed(); + assertState(fsm, AdapterStateEnum.STARTED, StateEnum.CLOSED, StateEnum.CLOSED); } } From 0ac3de0de4066c677c77c693ee46b49840e9b7ed Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 08:31:36 +0200 Subject: [PATCH 10/50] add license heathers --- .../java/com/hivemq/fsm/ProtocolAdapterFSM.java | 15 +++++++++++++++ .../com/hivemq/fsm/ProtocolAdapterFSMTest.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index f90c71a568..e54768642f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -1,3 +1,18 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 + * + * http://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 com.hivemq.fsm; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index aa471854ed..2f20a4b97a 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -1,3 +1,18 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 + * + * http://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 com.hivemq.fsm; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; From 34ec98a7782f8713bd06d1e53b8b2f139b3333b8 Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 12:31:19 +0200 Subject: [PATCH 11/50] integrate adapter FSM into adapter wrapper --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 5 +- .../ProtocolAdapterStartOutputImpl.java | 18 +- .../protocols/ProtocolAdapterWrapper.java | 374 +++++++------- ...ProtocolAdapterWrapperConcurrencyTest.java | 471 ++++++++++++++++++ 4 files changed, 659 insertions(+), 209 deletions(-) create mode 100644 hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index e54768642f..a10c33db1e 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -44,14 +44,15 @@ public enum StateEnum { } public static final @NotNull Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED + StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED, StateEnum.NOT_SUPPORTED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED; NOT_SUPPORTED for adapters without southbound StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // can go back to DISCONNECTED StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // transition to CONNECTING in case of recovery, DISCONNECTED for direct transition StateEnum.DISCONNECTING, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can go to DISCONNECTED or CLOSING StateEnum.CLOSING, Set.of(StateEnum.CLOSED), StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), StateEnum.ERROR, Set.of(StateEnum.CONNECTING, StateEnum.DISCONNECTED), // can recover from error - StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING) // can restart from closed or go to closing + StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can restart from closed or go to closing + StateEnum.NOT_SUPPORTED, Set.of() // Terminal state for adapters without southbound support ); public enum AdapterStateEnum { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterStartOutputImpl.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterStartOutputImpl.java index 1494f54f2a..4fb098ccfa 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterStartOutputImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterStartOutputImpl.java @@ -20,23 +20,29 @@ import org.jetbrains.annotations.Nullable; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; public class ProtocolAdapterStartOutputImpl implements ProtocolAdapterStartOutput { - private @Nullable volatile String message = null; - private @Nullable volatile Throwable throwable = null; + private @Nullable volatile String message; + private @Nullable volatile Throwable throwable; private final @NotNull CompletableFuture startFuture = new CompletableFuture<>(); + private final @NotNull AtomicBoolean completed = new AtomicBoolean(false); @Override public void startedSuccessfully() { - this.startFuture.complete(null); + if (completed.compareAndSet(false, true)) { + startFuture.complete(null); + } } @Override public void failStart(final @NotNull Throwable t, final @Nullable String errorMessage) { - this.throwable = t; - this.message = errorMessage; - this.startFuture.completeExceptionally(t); + if (completed.compareAndSet(false, true)) { + throwable = t; + message = errorMessage; + startFuture.completeExceptionally(t); + } } public @Nullable Throwable getThrowable() { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 8df0b009bb..5fd4a78481 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -33,6 +33,7 @@ import com.hivemq.edge.modules.adapters.data.TagManager; import com.hivemq.edge.modules.adapters.impl.ProtocolAdapterStateImpl; import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingService; +import com.hivemq.fsm.ProtocolAdapterFSM; import com.hivemq.persistence.mappings.NorthboundMapping; import com.hivemq.persistence.mappings.SouthboundMapping; import com.hivemq.protocols.northbound.NorthboundConsumerFactory; @@ -44,28 +45,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Collectors; -public class ProtocolAdapterWrapper { +public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterWrapper.class); - /** - * Represents the current operation state of the adapter to handle concurrent start/stop operations. - */ - private enum OperationState { - IDLE, - STARTING, - STOPPING - } - private final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService; private final @NotNull ProtocolAdapter adapter; private final @NotNull ProtocolAdapterFactory adapterFactory; @@ -76,31 +67,10 @@ private enum OperationState { private final @NotNull ProtocolAdapterConfig config; private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; private final @NotNull TagManager tagManager; - protected @Nullable Long lastStartAttemptTime; - private final List consumers = new ArrayList<>(); - + private final List consumers = new CopyOnWriteArrayList<>(); private final AtomicReference> startFutureRef = new AtomicReference<>(null); private final AtomicReference> stopFutureRef = new AtomicReference<>(null); - - private final AtomicReference operationState = new AtomicReference<>(OperationState.IDLE); - - /** - * Exception thrown when attempting to start an adapter while a stop operation is in progress. - */ - public static class AdapterStartConflictException extends RuntimeException { - public AdapterStartConflictException(final String adapterId) { - super("Cannot start adapter '" + adapterId + "' while stop operation is in progress"); - } - } - - /** - * Exception thrown when attempting to stop an adapter while a start operation is in progress. - */ - public static class AdapterStopConflictException extends RuntimeException { - public AdapterStopConflictException(final String adapterId) { - super("Cannot stop adapter '" + adapterId + "' while start operation is in progress"); - } - } + protected volatile @Nullable Long lastStartAttemptTime; public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService, @@ -113,6 +83,7 @@ public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterStateImpl protocolAdapterState, final @NotNull NorthboundConsumerFactory northboundConsumerFactory, final @NotNull TagManager tagManager) { + super(config.getAdapterId()); this.protocolAdapterWritingService = protocolAdapterWritingService; this.protocolAdapterPollingService = protocolAdapterPollingService; this.protocolAdapterMetricsService = protocolAdapterMetricsService; @@ -123,59 +94,100 @@ public ProtocolAdapterWrapper( this.config = config; this.northboundConsumerFactory = northboundConsumerFactory; this.tagManager = tagManager; + + // Register FSM state transition listener for debugging + registerStateTransitionListener(state -> log.debug( + "Adapter {} FSM state transition: adapter={}, northbound={}, southbound={}", + adapter.getId(), + state.state(), + state.northbound(), + state.southbound())); + } + + @Override + public boolean onStarting() { + try { + protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STARTED); + return true; + } catch (final Exception e) { + log.error("Adapter starting failed for adapter {}", adapter.getId(), e); + return false; + } + } + + @Override + public void onStopping() { + protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STOPPED); + } + + @Override + public boolean startSouthbound() { + if (!isWriting()) { + transitionSouthboundState(StateEnum.NOT_SUPPORTED); + return true; + } + + final boolean started = startWriting(protocolAdapterWritingService); + if (started) { + log.info("Southbound started for adapter {}", adapter.getId()); + transitionSouthboundState(StateEnum.CONNECTED); + } else { + log.error("Southbound start failed for adapter {}", adapter.getId()); + transitionSouthboundState(StateEnum.ERROR); + } + return started; } public @NotNull CompletableFuture startAsync( final boolean writingEnabled, final @NotNull ModuleServices moduleServices) { - final var existingStartFuture = getOngoingOperationIfPresent(operationState.get(), OperationState.STARTING); - if (existingStartFuture != null) return existingStartFuture; - // Try to atomically transition from IDLE to STARTING - if (!operationState.compareAndSet(OperationState.IDLE, OperationState.STARTING)) { - // State changed between check and set, retry - return startAsync(writingEnabled, moduleServices); + + // Check FSM state to detect ongoing operations + final var currentFsmState = currentState(); + if (currentFsmState.state() == AdapterStateEnum.STARTING) { + final var existingFuture = startFutureRef.get(); + if (existingFuture != null) { + log.info("Start operation already in progress for adapter '{}'", getId()); + return existingFuture; + } } + + if (currentFsmState.state() == AdapterStateEnum.STOPPING) { + log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); + return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + + getId() + + "' while stop operation is in progress")); + } + initStartAttempt(); final var output = new ProtocolAdapterStartOutputImpl(); final var input = new ProtocolAdapterStartInputImpl(moduleServices); - final var startFuture = CompletableFuture - .supplyAsync(() -> { - try { - adapter.start(input, output); - } catch (final Throwable throwable) { - output.getStartFuture().completeExceptionally(throwable); - } - return output.getStartFuture(); - }) - .thenCompose(Function.identity()) - .handle((ignored, error) -> { - if(error != null) { - log.error("Error starting adapter", error); - stopAfterFailedStart(); - protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STOPPED); - //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(error); - } else { - return attemptStartingConsumers(writingEnabled, moduleServices.eventService()) - .map(startException -> { - log.error("Failed to start adapter with id {}", adapter.getId(), startException); - stopAfterFailedStart(); - protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STOPPED); - //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(startException); - }) - .orElseGet(() -> { - protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STARTED); - return CompletableFuture.completedFuture(null); - }); - } - }) - .thenApply(ignored -> (Void)null) - .whenComplete((result, throwable) -> { - //always clean up state - startFutureRef.set(null); - operationState.set(OperationState.IDLE); - }); + + final var startFuture = CompletableFuture.supplyAsync(() -> { + // Signal FSM to start (calls onStarting() internally) + startAdapter(); + + try { + adapter.start(input, output); + } catch (final Throwable throwable) { + output.getStartFuture().completeExceptionally(throwable); + } + return output.getStartFuture(); + }).thenCompose(Function.identity()).handle((ignored, error) -> { + if (error != null) { + log.error("Error starting adapter", error); + stopAfterFailedStart(); + //we still return the initial error since that's the most significant information + return CompletableFuture.failedFuture(error); + } else { + return attemptStartingConsumers(writingEnabled, moduleServices.eventService()).map(startException -> { + log.error("Failed to start adapter with id {}", adapter.getId(), startException); + stopAfterFailedStart(); + //we still return the initial error since that's the most significant information + return CompletableFuture.failedFuture(startException); + }).orElseGet(() -> CompletableFuture.completedFuture(null)); + } + }).thenApply(ignored -> (Void) null).whenComplete((result, throwable) -> startFutureRef.set(null)); startFutureRef.set(startFuture); return startFuture; @@ -200,126 +212,91 @@ private void stopAfterFailedStart() { } } - private Optional attemptStartingConsumers(final boolean writingEnabled, final @NotNull EventService eventService) { + private @NotNull Optional attemptStartingConsumers( + final boolean writingEnabled, + final @NotNull EventService eventService) { try { //Adapter started successfully, now start the consumers createAndSubscribeTagConsumer(); startPolling(protocolAdapterPollingService, eventService); - if(writingEnabled && isWriting()) { - final var started = new AtomicBoolean(false); - protocolAdapterState.setConnectionStatusListener(status -> { - if(status == ProtocolAdapterState.ConnectionStatus.CONNECTED) { - if(started.compareAndSet(false, true)) { - if(startWriting(protocolAdapterWritingService)) { - log.info("Successfully started adapter with id {}", adapter.getId()); - } else { - log.error("Protocol adapter start failed as data hub is not available."); - } - } - } - }); - } + // Wire connection status events to FSM for all adapters + // FSM's accept() method handles: + // 1. Transitioning northbound state + // 2. Triggering startSouthbound() when CONNECTED (only for writing adapters) + protocolAdapterState.setConnectionStatusListener(status -> { + this.accept(status); + + // For non-writing adapters that are only polling, southbound is not applicable + // but we still need to track northbound connection status + }); } catch (final Throwable e) { - log.error("Protocol adapter start failed"); + log.error("Protocol adapter start failed", e); return Optional.of(e); } return Optional.empty(); } public @NotNull CompletableFuture stopAsync(final boolean destroy) { - final var existingStopFuture = getOngoingOperationIfPresent(operationState.get(), OperationState.STOPPING); - if (existingStopFuture != null) return existingStopFuture; - // Try to atomically transition from IDLE to STOPPING - if (!operationState.compareAndSet(OperationState.IDLE, OperationState.STOPPING)) { - // State changed between check and set, retry - return stopAsync(destroy); + // Check FSM state to detect ongoing operations + final var currentFsmState = currentState(); + if (currentFsmState.state() == AdapterStateEnum.STOPPING) { + final var existingFuture = stopFutureRef.get(); + if (existingFuture != null) { + log.info("Stop operation already in progress for adapter '{}'", getId()); + return existingFuture; + } } - // Double-check that no stop future exists - final var existingFuture = stopFutureRef.get(); - if (existingFuture != null) { - log.info("Stop operation already in progress for adapter with id '{}', returning existing future", getId()); - return existingFuture; + + if (currentFsmState.state() == AdapterStateEnum.STARTING) { + log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); + return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + + getId() + + "' while start operation is in progress")); } consumers.forEach(tagManager::removeConsumer); final var input = new ProtocolAdapterStopInputImpl(); final var output = new ProtocolAdapterStopOutputImpl(); - final var stopFuture = CompletableFuture - .supplyAsync(() -> { - stopPolling(protocolAdapterPollingService); - stopWriting(protocolAdapterWritingService); - try { - adapter.stop(input, output); - } catch (final Throwable throwable) { - output.getOutputFuture().completeExceptionally(throwable); - } - return output.getOutputFuture(); - }) - .thenCompose(Function.identity()) - .whenComplete((result, throwable) -> { - if (destroy) { - log.info("Destroying adapter with id '{}'", getId()); - adapter.destroy(); - } - if (throwable == null) { - log.info("Stopped adapter with id {}", adapter.getId()); - } else { - log.error("Error stopping adapter with id {}", adapter.getId(), throwable); - } - protocolAdapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STOPPED); - stopFutureRef.set(null); - operationState.set(OperationState.IDLE); - }); + final var stopFuture = CompletableFuture.supplyAsync(() -> { + // Signal FSM to stop (calls onStopping() internally) + stopAdapter(); + + stopPolling(protocolAdapterPollingService); + stopWriting(protocolAdapterWritingService); + try { + adapter.stop(input, output); + } catch (final Throwable throwable) { + output.getOutputFuture().completeExceptionally(throwable); + } + return output.getOutputFuture(); + }).thenCompose(Function.identity()).whenComplete((result, throwable) -> { + if (destroy) { + log.info("Destroying adapter with id '{}'", getId()); + adapter.destroy(); + } + if (throwable == null) { + log.info("Stopped adapter with id {}", adapter.getId()); + } else { + log.error("Error stopping adapter with id {}", adapter.getId(), throwable); + } + stopFutureRef.set(null); + }); stopFutureRef.set(stopFuture); return stopFuture; } - private @Nullable CompletableFuture getOngoingOperationIfPresent(final @NotNull OperationState currentState, final @NotNull OperationState targetState) { - switch (currentState) { - case STARTING: - if(targetState == OperationState.STARTING) { - // If already starting, return existing future - final var existingStartFuture = startFutureRef.get(); - if (existingStartFuture != null) { - log.info("Start operation already in progress for adapter with id '{}', returning existing future", getId()); - return existingStartFuture; - } - } else { - log.warn("Cannot stop adapter with id '{}' while start operation is in progress", getId()); - return CompletableFuture.failedFuture(new AdapterStopConflictException(getId())); - } - break; - case STOPPING: - if(targetState == OperationState.STOPPING) { - // If already stopping, return existing future - final var existingStopFuture = stopFutureRef.get(); - if (existingStopFuture != null) { - log.info("Stop operation already in progress for adapter with id '{}', returning existing future", getId()); - return existingStopFuture; - } - break; - } - // If stopping, return failed future immediately - log.warn("Cannot start adapter with id '{}' while stop operation is in progress", getId()); - return CompletableFuture.failedFuture(new AdapterStartConflictException(getId())); - case IDLE: - // Proceed with start operation - break; - } - return null; - } - public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { return adapter.getProtocolAdapterInformation(); } public void discoverValues( - final @NotNull ProtocolAdapterDiscoveryInput input, final @NotNull ProtocolAdapterDiscoveryOutput output) { + final @NotNull ProtocolAdapterDiscoveryInput input, + final @NotNull ProtocolAdapterDiscoveryOutput output) { adapter.discoverValues(input, output); } @@ -331,6 +308,10 @@ public void discoverValues( return protocolAdapterState.getRuntimeStatus(); } + public void setRuntimeStatus(final @NotNull ProtocolAdapterState.RuntimeStatus runtimeStatus) { + protocolAdapterState.setRuntimeStatus(runtimeStatus); + } + public @Nullable String getErrorMessage() { return protocolAdapterState.getLastErrorMessage(); } @@ -387,10 +368,6 @@ public void setErrorConnectionStatus(final @NotNull Throwable exception, final @ protocolAdapterState.setErrorConnectionStatus(exception, errorMessage); } - public void setRuntimeStatus(final @NotNull ProtocolAdapterState.RuntimeStatus runtimeStatus) { - protocolAdapterState.setRuntimeStatus(runtimeStatus); - } - public boolean isWriting() { return adapter instanceof WritingProtocolAdapter; } @@ -409,27 +386,23 @@ private void startPolling( if (isBatchPolling()) { log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); - final PerAdapterSampler sampler = - new PerAdapterSampler(this, eventService, tagManager); + final PerAdapterSampler sampler = new PerAdapterSampler(this, eventService, tagManager); protocolAdapterPollingService.schedulePolling(sampler); } if (isPolling()) { config.getTags().forEach(tag -> { - final PerContextSampler sampler = - new PerContextSampler( - this, - new PollingContextWrapper( - "unused", - tag.getName(), - MessageHandlingOptions.MQTTMessagePerTag, - false, - false, - List.of(), - 1, - -1), - eventService, - tagManager); + final PerContextSampler sampler = new PerContextSampler(this, + new PollingContextWrapper("unused", + tag.getName(), + MessageHandlingOptions.MQTTMessagePerTag, + false, + false, + List.of(), + 1, + -1), + eventService, + tagManager); protocolAdapterPollingService.schedulePolling(sampler); }); } @@ -443,36 +416,35 @@ private void stopPolling( } } - private @NotNull boolean startWriting(final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService) { + private boolean startWriting(final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService) { log.debug("Start writing for protocol adapter with id '{}'", getId()); final var southboundMappings = getSouthboundMappings(); final var writingContexts = southboundMappings.stream() - .map(InternalWritingContextImpl::new) - .collect(Collectors.toList()); + .map(InternalWritingContextImpl::new) + .collect(Collectors.toList()); - return protocolAdapterWritingService - .startWriting( - (WritingProtocolAdapter) getAdapter(), - getProtocolAdapterMetricsService(), - writingContexts); + return protocolAdapterWritingService.startWriting((WritingProtocolAdapter) getAdapter(), + getProtocolAdapterMetricsService(), + writingContexts); } private void stopWriting(final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService) { //no check for 'writing is enabled', as we have to stop it anyway since the license could have been removed in the meantime. if (isWriting()) { log.debug("Stopping writing for protocol adapter with id '{}'", getId()); - final var writingContexts = - getSouthboundMappings().stream() - .map(mapping -> (InternalWritingContext)new InternalWritingContextImpl(mapping)) - .toList(); + final var writingContexts = getSouthboundMappings().stream() + .map(mapping -> (InternalWritingContext) new InternalWritingContextImpl(mapping)) + .toList(); protocolAdapterWritingService.stopWriting((WritingProtocolAdapter) getAdapter(), writingContexts); } } private void createAndSubscribeTagConsumer() { getNorthboundMappings().stream() - .map(northboundMapping -> northboundConsumerFactory.build(this, northboundMapping, protocolAdapterMetricsService)) + .map(northboundMapping -> northboundConsumerFactory.build(this, + northboundMapping, + protocolAdapterMetricsService)) .forEach(northboundTagConsumer -> { tagManager.addConsumer(northboundTagConsumer); consumers.add(northboundTagConsumer); diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java new file mode 100644 index 0000000000..3588244287 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java @@ -0,0 +1,471 @@ +/* + * Copyright 2019-present HiveMQ GmbH + * + * 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 + * + * http://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 com.hivemq.protocols; + +import com.hivemq.adapter.sdk.api.ProtocolAdapter; +import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; +import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; +import com.hivemq.adapter.sdk.api.services.ModuleServices; +import com.hivemq.adapter.sdk.api.services.ProtocolAdapterMetricsService; +import com.hivemq.edge.modules.adapters.data.TagManager; +import com.hivemq.edge.modules.adapters.impl.ProtocolAdapterStateImpl; +import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingService; +import com.hivemq.fsm.ProtocolAdapterFSM; +import com.hivemq.protocols.northbound.NorthboundConsumerFactory; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * These tests verify: + * - FSM state transitions are atomic + * - Concurrent operations don't cause race conditions + * - State reads are always consistent + * - North/South bound states are properly managed + */ +class ProtocolAdapterWrapperConcurrencyTest { + + private static final int SMALL_THREAD_COUNT = 10; + private static final int MEDIUM_THREAD_COUNT = 20; + private static final int LARGE_THREAD_COUNT = 50; + private static final int OPERATIONS_PER_THREAD = 100; + private static final int TRANSITIONS_PER_THREAD = 20; + private @Nullable ModuleServices mockModuleServices; + private @Nullable ProtocolAdapterWrapper wrapper; + private @Nullable ExecutorService executor; + + private static void verifyStateConsistency(final ProtocolAdapterFSM.State state) { + final ProtocolAdapterFSM.AdapterStateEnum adapterState = state.state(); + final ProtocolAdapterFSM.StateEnum northbound = state.northbound(); + final ProtocolAdapterFSM.StateEnum southbound = state.southbound(); + + // Verify all states are non-null + assertNotNull(adapterState, "Adapter state should not be null"); + assertNotNull(northbound, "Northbound state should not be null"); + assertNotNull(southbound, "Southbound state should not be null"); + + switch (adapterState) { + case STOPPED: + // STOPPED is a stable state - all connections must be fully disconnected + assertEquals(ProtocolAdapterFSM.StateEnum.DISCONNECTED, + northbound, + "Northbound should be DISCONNECTED when adapter is STOPPED"); + assertEquals(ProtocolAdapterFSM.StateEnum.DISCONNECTED, + southbound, + "Southbound should be DISCONNECTED when adapter is STOPPED"); + break; + + case STARTING: + case STARTED: + case STOPPING: + // Key invariant: When adapter is STOPPED, both connections MUST be DISCONNECTED. + // Other states allow various connection state combinations during transitions. + break; + + default: + fail("Unknown adapter state: " + adapterState); + } + } + + private void runConcurrentOperations(final int threadCount, final @NotNull Runnable operation) + throws InterruptedException { + executor = Executors.newFixedThreadPool(threadCount); + final CyclicBarrier barrier = new CyclicBarrier(threadCount); + final AtomicInteger attempts = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + requireNonNull(executor).submit(() -> { + try { + barrier.await(); + attempts.incrementAndGet(); + operation.run(); + } catch (final Exception ignored) { + // Expected - concurrent operations may fail due to state conflicts + } + }); + } + + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(10, TimeUnit.SECONDS), "All threads should complete"); + assertEquals(threadCount, attempts.get(), "All attempts should be made"); + } + + private void runReaderWriterPattern( + final int readerThreads, + final int writerThreads, + final @NotNull Runnable readerOperation, + final @NotNull Runnable writerOperation, + final @NotNull AtomicInteger readerCounter) throws InterruptedException { + executor = Executors.newFixedThreadPool(readerThreads + writerThreads); + final CountDownLatch stopLatch = new CountDownLatch(1); + + // Readers + for (int i = 0; i < readerThreads; i++) { + requireNonNull(executor).submit(() -> { + try { + while (!stopLatch.await(0, TimeUnit.MILLISECONDS)) { + readerOperation.run(); + Thread.sleep(1); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + // Writers + for (int i = 0; i < writerThreads; i++) { + requireNonNull(executor).submit(() -> { + try { + for (int j = 0; j < 15; j++) { + writerOperation.run(); + Thread.sleep(10); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + Thread.sleep(250); + stopLatch.countDown(); + + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(5, TimeUnit.SECONDS), "All threads should complete"); + assertTrue(readerCounter.get() > 0, "Should have performed operations"); + } + + @BeforeEach + void setUp() { + final ProtocolAdapter mockAdapter = mock(ProtocolAdapter.class); + when(mockAdapter.getId()).thenReturn("test-adapter"); + when(mockAdapter.getProtocolAdapterInformation()).thenReturn(mock(ProtocolAdapterInformation.class)); + + final ProtocolAdapterMetricsService metricsService = mock(ProtocolAdapterMetricsService.class); + final InternalProtocolAdapterWritingService writingService = mock(InternalProtocolAdapterWritingService.class); + final ProtocolAdapterPollingService pollingService = mock(ProtocolAdapterPollingService.class); + final ProtocolAdapterFactory adapterFactory = mock(ProtocolAdapterFactory.class); + final ProtocolAdapterInformation adapterInformation = mock(ProtocolAdapterInformation.class); + final ProtocolAdapterStateImpl adapterState = mock(ProtocolAdapterStateImpl.class); + final NorthboundConsumerFactory consumerFactory = mock(NorthboundConsumerFactory.class); + final TagManager tagManager = mock(TagManager.class); + mockModuleServices = mock(ModuleServices.class); + + final ProtocolAdapterConfig config = mock(ProtocolAdapterConfig.class); + when(config.getAdapterId()).thenReturn("test-adapter"); + when(config.getTags()).thenReturn(java.util.List.of()); + when(config.getNorthboundMappings()).thenReturn(java.util.List.of()); + when(config.getSouthboundMappings()).thenReturn(java.util.List.of()); + + wrapper = new ProtocolAdapterWrapper(metricsService, + writingService, + pollingService, + config, + mockAdapter, + adapterFactory, + adapterInformation, + adapterState, + consumerFactory, + tagManager); + } + + @AfterEach + void tearDown() throws InterruptedException { + if (executor != null && !executor.isShutdown()) { + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + @Timeout(10) + void test_fsmStateTransitions_areAtomic() throws Exception { + executor = Executors.newFixedThreadPool(SMALL_THREAD_COUNT); + final CountDownLatch startLatch = new CountDownLatch(1); + final AtomicInteger invalidTransitions = new AtomicInteger(0); + final AtomicInteger totalAttempts = new AtomicInteger(0); + + for (int i = 0; i < SMALL_THREAD_COUNT; i++) { + final boolean shouldStart = i % 2 == 0; + requireNonNull(executor).submit(() -> { + try { + startLatch.await(); + for (int j = 0; j < TRANSITIONS_PER_THREAD; j++) { + try { + totalAttempts.incrementAndGet(); + if (shouldStart) { + requireNonNull(wrapper).startAdapter(); + } else { + requireNonNull(wrapper).stopAdapter(); + } + + // Verify complete state is valid + final var state = requireNonNull(wrapper).currentState(); + assertNotNull(state, "State should never be null"); + assertNotNull(state.state(), "Adapter state should never be null"); + assertNotNull(state.northbound(), "Northbound state should never be null"); + assertNotNull(state.southbound(), "Southbound state should never be null"); + + // Verify state consistency + verifyStateConsistency(state); + + } catch (final IllegalStateException e) { + // Expected when transition is not allowed + } catch (final Exception e) { + invalidTransitions.incrementAndGet(); + } + + Thread.sleep(1); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + startLatch.countDown(); + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(10, TimeUnit.SECONDS), "All threads should complete"); + + // Verify no invalid transitions occurred + assertEquals(0, + invalidTransitions.get(), + "No invalid state transitions should occur (total attempts: " + totalAttempts.get() + ")"); + + // Verify final state is complete and valid + final var finalState = requireNonNull(wrapper).currentState(); + assertNotNull(finalState, "Final state should not be null"); + assertNotNull(finalState.state(), "Final adapter state should not be null"); + assertNotNull(finalState.northbound(), "Final northbound state should not be null"); + assertNotNull(finalState.southbound(), "Final southbound state should not be null"); + + // Final state should be STOPPED or STARTED + assertTrue(finalState.state() == ProtocolAdapterFSM.AdapterStateEnum.STOPPED || + finalState.state() == ProtocolAdapterFSM.AdapterStateEnum.STARTED, + "Final state should be stable: " + finalState.state()); + } + + @Test + @Timeout(10) + void test_stateReads_alwaysConsistent() throws Exception { + final int READER_THREADS = 5; + final int WRITER_THREADS = 2; + executor = Executors.newFixedThreadPool(READER_THREADS + WRITER_THREADS); + final CountDownLatch stopLatch = new CountDownLatch(1); + final AtomicInteger inconsistentReads = new AtomicInteger(0); + final AtomicInteger totalReads = new AtomicInteger(0); + + // Reader threads - verify complete state consistency + for (int i = 0; i < READER_THREADS; i++) { + requireNonNull(executor).submit(() -> { + try { + while (!stopLatch.await(0, TimeUnit.MILLISECONDS)) { + final var state = requireNonNull(wrapper).currentState(); + assertNotNull(state, "State should never be null"); + assertNotNull(state.state(), "Adapter state should never be null"); + assertNotNull(state.northbound(), "Northbound state should never be null"); + assertNotNull(state.southbound(), "Southbound state should never be null"); + + // Verify state consistency + verifyStateConsistency(state); + + totalReads.incrementAndGet(); + Thread.sleep(1); + } + } catch (final AssertionError e) { + inconsistentReads.incrementAndGet(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + // Writer threads + for (int i = 0; i < WRITER_THREADS; i++) { + requireNonNull(executor).submit(() -> { + try { + for (int j = 0; j < 10; j++) { + try { + requireNonNull(wrapper).startAdapter(); + Thread.sleep(10); + requireNonNull(wrapper).stopAdapter(); + Thread.sleep(10); + } catch (final IllegalStateException e) { + // Expected + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + // Let it run + Thread.sleep(200); + stopLatch.countDown(); + + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(5, TimeUnit.SECONDS), "All threads should complete"); + + // Verify no inconsistent reads + assertEquals(0, inconsistentReads.get(), "No inconsistent state reads should occur"); + assertTrue(totalReads.get() > 0, "Should have performed reads: " + totalReads.get()); + } + + @Test + @Timeout(5) + void test_concurrentStateChecks_noExceptions() throws Exception { + executor = Executors.newFixedThreadPool(MEDIUM_THREAD_COUNT); + final CountDownLatch startLatch = new CountDownLatch(1); + final AtomicInteger successfulChecks = new AtomicInteger(0); + final AtomicInteger failures = new AtomicInteger(0); + + for (int i = 0; i < MEDIUM_THREAD_COUNT; i++) { + requireNonNull(executor).submit(() -> { + try { + startLatch.await(); + + for (int j = 0; j < OPERATIONS_PER_THREAD; j++) { + try { + // Various state check operations + requireNonNull(wrapper).currentState(); + requireNonNull(wrapper).getRuntimeStatus(); + requireNonNull(wrapper).getConnectionStatus(); + requireNonNull(wrapper).getId(); + requireNonNull(wrapper).getAdapterInformation(); + successfulChecks.incrementAndGet(); + } catch (final Exception e) { + failures.incrementAndGet(); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + startLatch.countDown(); + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(5, TimeUnit.SECONDS), "All threads should complete"); + + assertEquals(0, failures.get(), "No exceptions should occur during concurrent state checks"); + assertTrue(successfulChecks.get() >= MEDIUM_THREAD_COUNT * OPERATIONS_PER_THREAD, + "All state checks should succeed"); + } + + @Test + @Timeout(5) + void test_adapterIdAccess_isThreadSafe() throws Exception { + executor = Executors.newFixedThreadPool(LARGE_THREAD_COUNT); + final CountDownLatch startLatch = new CountDownLatch(1); + final AtomicInteger correctReads = new AtomicInteger(0); + + for (int i = 0; i < LARGE_THREAD_COUNT; i++) { + requireNonNull(executor).submit(() -> { + try { + startLatch.await(); + + for (int j = 0; j < OPERATIONS_PER_THREAD; j++) { + final String adapterId = requireNonNull(wrapper).getId(); + if ("test-adapter".equals(adapterId)) { + correctReads.incrementAndGet(); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + + startLatch.countDown(); + requireNonNull(executor).shutdown(); + assertTrue(requireNonNull(executor).awaitTermination(5, TimeUnit.SECONDS), "All threads should complete"); + + assertEquals(LARGE_THREAD_COUNT * OPERATIONS_PER_THREAD, + correctReads.get(), + "All adapter ID reads should be correct"); + } + + @Test + @Timeout(10) + void test_concurrentStartAsync_properSerialization() throws Exception { + runConcurrentOperations(SMALL_THREAD_COUNT, + () -> requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices))); + + final var state = requireNonNull(wrapper).currentState(); + assertNotNull(state); + assertNotNull(state.state()); + } + + @Test + @Timeout(10) + void test_concurrentStopAsync_properSerialization() throws Exception { + requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices)); + Thread.sleep(100); + + runConcurrentOperations(SMALL_THREAD_COUNT, () -> requireNonNull(wrapper).stopAsync(false)); + + final var state = requireNonNull(wrapper).currentState(); + assertNotNull(state); + assertNotNull(state.state()); + } + + @Test + @Timeout(10) + void test_statusQueriesDuringTransitions_noExceptions() throws Exception { + final int READER_THREADS = 5; + final int WRITER_THREADS = 3; + final AtomicInteger totalReads = new AtomicInteger(0); + final AtomicInteger exceptions = new AtomicInteger(0); + + // Test both runtime and connection status reads during transitions + runReaderWriterPattern(READER_THREADS, WRITER_THREADS, () -> { + try { + requireNonNull(wrapper).getRuntimeStatus(); // May return null with mocks + requireNonNull(wrapper).getConnectionStatus(); // May return null with mocks + totalReads.incrementAndGet(); + } catch (final Exception e) { + exceptions.incrementAndGet(); + } + }, () -> { + try { + requireNonNull(wrapper).startAdapter(); + } catch (final IllegalStateException ignored) { + // Expected + } + }, totalReads); + + assertEquals(0, exceptions.get(), "No exceptions during status queries"); + } +} From fe281a3887debb48c2c1c0a1eea3f9ff30e1131c Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 14:36:48 +0200 Subject: [PATCH 12/50] strengthen the thread-safety of the protocol adapter wrapper, clean resources after unit test completes --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 8 +- .../protocols/ProtocolAdapterWrapper.java | 189 ++++++++++-------- .../adapters/opcua/OpcUaClientConnection.java | 1 - .../adapters/opcua/OpcUaProtocolAdapter.java | 8 +- 4 files changed, 118 insertions(+), 88 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index a10c33db1e..1ace6c6407 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -44,13 +44,13 @@ public enum StateEnum { } public static final @NotNull Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED, StateEnum.NOT_SUPPORTED), //for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED; NOT_SUPPORTED for adapters without southbound - StateEnum.CONNECTING, Set.of(StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // can go back to DISCONNECTED - StateEnum.CONNECTED, Set.of(StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // transition to CONNECTING in case of recovery, DISCONNECTED for direct transition + StateEnum.DISCONNECTED, Set.of(StateEnum.DISCONNECTED, StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED, StateEnum.NOT_SUPPORTED), //allow idempotent DISCONNECTED->DISCONNECTED transitions; for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED; NOT_SUPPORTED for adapters without southbound + StateEnum.CONNECTING, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // allow idempotent CONNECTING->CONNECTING; can go back to DISCONNECTED + StateEnum.CONNECTED, Set.of(StateEnum.CONNECTED, StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // allow idempotent CONNECTED->CONNECTED; transition to CONNECTING in case of recovery, DISCONNECTED for direct transition StateEnum.DISCONNECTING, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can go to DISCONNECTED or CLOSING StateEnum.CLOSING, Set.of(StateEnum.CLOSED), StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), - StateEnum.ERROR, Set.of(StateEnum.CONNECTING, StateEnum.DISCONNECTED), // can recover from error + StateEnum.ERROR, Set.of(StateEnum.ERROR, StateEnum.CONNECTING, StateEnum.DISCONNECTED), // allow idempotent ERROR->ERROR; can recover from error StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can restart from closed or go to closing StateEnum.NOT_SUPPORTED, Set.of() // Terminal state for adapters without southbound support ); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 5fd4a78481..79310073bb 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -142,55 +142,63 @@ public boolean startSouthbound() { final boolean writingEnabled, final @NotNull ModuleServices moduleServices) { - // Check FSM state to detect ongoing operations - final var currentFsmState = currentState(); - if (currentFsmState.state() == AdapterStateEnum.STARTING) { + // Atomically check state and claim the operation in a single step + while (true) { final var existingFuture = startFutureRef.get(); - if (existingFuture != null) { + + // If there's already a start operation in progress, return it + if (existingFuture != null && !existingFuture.isDone()) { log.info("Start operation already in progress for adapter '{}'", getId()); return existingFuture; } - } - - if (currentFsmState.state() == AdapterStateEnum.STOPPING) { - log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); - return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + - getId() + - "' while stop operation is in progress")); - } - - initStartAttempt(); - final var output = new ProtocolAdapterStartOutputImpl(); - final var input = new ProtocolAdapterStartInputImpl(moduleServices); - - final var startFuture = CompletableFuture.supplyAsync(() -> { - // Signal FSM to start (calls onStarting() internally) - startAdapter(); - try { - adapter.start(input, output); - } catch (final Throwable throwable) { - output.getStartFuture().completeExceptionally(throwable); + // Check if stop operation is in progress + final var stopFuture = stopFutureRef.get(); + if (stopFuture != null && !stopFuture.isDone()) { + log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); + return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + + getId() + + "' while stop operation is in progress")); } - return output.getStartFuture(); - }).thenCompose(Function.identity()).handle((ignored, error) -> { - if (error != null) { - log.error("Error starting adapter", error); - stopAfterFailedStart(); - //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(error); - } else { - return attemptStartingConsumers(writingEnabled, moduleServices.eventService()).map(startException -> { - log.error("Failed to start adapter with id {}", adapter.getId(), startException); + + // Create the new future before setting it to avoid race condition + initStartAttempt(); + final var output = new ProtocolAdapterStartOutputImpl(); + final var input = new ProtocolAdapterStartInputImpl(moduleServices); + + final var startFuture = CompletableFuture.supplyAsync(() -> { + // Signal FSM to start (calls onStarting() internally) + startAdapter(); + + try { + adapter.start(input, output); + } catch (final Throwable throwable) { + output.getStartFuture().completeExceptionally(throwable); + } + return output.getStartFuture(); + }).thenCompose(Function.identity()).handle((ignored, error) -> { + if (error != null) { + log.error("Error starting adapter", error); stopAfterFailedStart(); //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(startException); - }).orElseGet(() -> CompletableFuture.completedFuture(null)); + return CompletableFuture.failedFuture(error); + } else { + return attemptStartingConsumers(writingEnabled, moduleServices.eventService()).map(startException -> { + log.error("Failed to start adapter with id {}", adapter.getId(), startException); + stopAfterFailedStart(); + //we still return the initial error since that's the most significant information + return CompletableFuture.failedFuture(startException); + }).orElseGet(() -> CompletableFuture.completedFuture(null)); + } + }).thenApply(ignored -> (Void) null).whenComplete((result, throwable) -> startFutureRef.set(null)); + + // Atomically set the future reference if it's still null or completed + // This prevents race conditions where multiple threads try to start simultaneously + if (startFutureRef.compareAndSet(existingFuture, startFuture)) { + return startFuture; } - }).thenApply(ignored -> (Void) null).whenComplete((result, throwable) -> startFutureRef.set(null)); - - startFutureRef.set(startFuture); - return startFuture; + // If CAS failed, another thread won the race - loop back and return their future + } } private void stopAfterFailedStart() { @@ -210,6 +218,13 @@ private void stopAfterFailedStart() { } catch (final Throwable throwable) { log.error("Stopping adapter after a start error failed", throwable); } + // Always destroy to clean up resources after failed start + try { + log.info("Destroying adapter with id '{}' after failed start to release all resources", getId()); + adapter.destroy(); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {} after failed start", adapter.getId(), destroyException); + } } private @NotNull Optional attemptStartingConsumers( @@ -239,55 +254,67 @@ private void stopAfterFailedStart() { public @NotNull CompletableFuture stopAsync(final boolean destroy) { - // Check FSM state to detect ongoing operations - final var currentFsmState = currentState(); - if (currentFsmState.state() == AdapterStateEnum.STOPPING) { + // Atomically check state and claim the operation in a single step + while (true) { final var existingFuture = stopFutureRef.get(); - if (existingFuture != null) { + + // If there's already a stop operation in progress, return it + if (existingFuture != null && !existingFuture.isDone()) { log.info("Stop operation already in progress for adapter '{}'", getId()); return existingFuture; } - } - - if (currentFsmState.state() == AdapterStateEnum.STARTING) { - log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); - return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + - getId() + - "' while start operation is in progress")); - } - - consumers.forEach(tagManager::removeConsumer); - final var input = new ProtocolAdapterStopInputImpl(); - final var output = new ProtocolAdapterStopOutputImpl(); - - final var stopFuture = CompletableFuture.supplyAsync(() -> { - // Signal FSM to stop (calls onStopping() internally) - stopAdapter(); - stopPolling(protocolAdapterPollingService); - stopWriting(protocolAdapterWritingService); - try { - adapter.stop(input, output); - } catch (final Throwable throwable) { - output.getOutputFuture().completeExceptionally(throwable); + // Check if start operation is in progress + final var startFuture = startFutureRef.get(); + if (startFuture != null && !startFuture.isDone()) { + log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); + return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + + getId() + + "' while start operation is in progress")); } - return output.getOutputFuture(); - }).thenCompose(Function.identity()).whenComplete((result, throwable) -> { - if (destroy) { - log.info("Destroying adapter with id '{}'", getId()); - adapter.destroy(); - } - if (throwable == null) { - log.info("Stopped adapter with id {}", adapter.getId()); - } else { - log.error("Error stopping adapter with id {}", adapter.getId(), throwable); - } - stopFutureRef.set(null); - }); - stopFutureRef.set(stopFuture); + // Create the new future before setting it to avoid race condition + consumers.forEach(tagManager::removeConsumer); + final var input = new ProtocolAdapterStopInputImpl(); + final var output = new ProtocolAdapterStopOutputImpl(); + + final var stopFuture = CompletableFuture.supplyAsync(() -> { + // Signal FSM to stop (calls onStopping() internally) + stopAdapter(); + + stopPolling(protocolAdapterPollingService); + stopWriting(protocolAdapterWritingService); + try { + adapter.stop(input, output); + } catch (final Throwable throwable) { + output.getOutputFuture().completeExceptionally(throwable); + } + return output.getOutputFuture(); + }).thenCompose(Function.identity()).whenComplete((result, throwable) -> { + // Always call destroy() to ensure all resources (threads, connections, etc.) are properly released + // This prevents resource leaks from underlying client libraries (OPC UA Milo, database drivers, etc.) + try { + log.info("Destroying adapter with id '{}' to release all resources", getId()); + adapter.destroy(); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); + } + + if (throwable == null) { + log.info("Stopped adapter with id {}", adapter.getId()); + } else { + log.error("Error stopping adapter with id {}", adapter.getId(), throwable); + } + stopFutureRef.set(null); + }); - return stopFuture; + // Atomically set the future reference if it's still null or completed + // This prevents race conditions where multiple threads try to stop simultaneously + if (stopFutureRef.compareAndSet(existingFuture, stopFuture)) { + return stopFuture; + } + // If CAS failed, another thread won the race - loop back and return their future + } } public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { diff --git a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaClientConnection.java b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaClientConnection.java index 6984a6ac93..73f2c5f947 100644 --- a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaClientConnection.java +++ b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaClientConnection.java @@ -48,7 +48,6 @@ import java.util.concurrent.atomic.AtomicReference; import static com.hivemq.edge.adapters.opcua.Constants.PROTOCOL_ID_OPCUA; -import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; public class OpcUaClientConnection { private static final @NotNull Logger log = LoggerFactory.getLogger(OpcUaClientConnection.class); diff --git a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java index f0f5bc6611..a8102357a7 100644 --- a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java +++ b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java @@ -159,10 +159,14 @@ public void destroy() { log.info("Destroying OPC UA protocol adapter {}", adapterId); final OpcUaClientConnection conn = opcUaClientConnection.getAndSet(null); if (conn != null) { - CompletableFuture.runAsync(() -> { + try { + // Destroy synchronously to ensure all resources (threads, connections) are cleaned up + // before returning. This prevents resource leaks in tests and during adapter lifecycle. conn.destroy(); log.info("Destroyed OPC UA protocol adapter {}", adapterId); - }); + } catch (final Exception e) { + log.error("Error destroying OPC UA protocol adapter {}", adapterId, e); + } } else { log.info("Tried destroying stopped OPC UA protocol adapter {}", adapterId); } From bff57b8e11059e4ff312d066bef1344aa4ec93b2 Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 16:19:06 +0200 Subject: [PATCH 13/50] fix broken tests due to missing transition to stopped --- .../api/model/adapters/ProtocolAdapter.java | 12 ++--- .../impl/ProtocolAdapterApiUtils.java | 19 +++---- .../impl/ProtocolAdapterStateImpl.java | 1 - .../protocols/ProtocolAdapterWrapper.java | 4 ++ .../protocols/ProtocolAdapterManagerTest.java | 54 +++++++++++++++++-- ...ProtocolAdapterWrapperConcurrencyTest.java | 28 +++++++++- 6 files changed, 98 insertions(+), 20 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java index cc87cc3c5d..a1584665fd 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java @@ -138,7 +138,7 @@ public ProtocolAdapter( return logoUrl; } - public @Nullable String getProvisioningUrl() { + public @NotNull String getProvisioningUrl() { return provisioningUrl; } @@ -154,25 +154,25 @@ public ProtocolAdapter( return capabilities; } - public @Nullable Boolean getInstalled() { + public @NotNull Boolean getInstalled() { return installed; } - public @Nullable List getTags() { + public @NotNull List getTags() { return tags; } - public @Nullable ProtocolAdapterCategory getCategory() { + public @NotNull ProtocolAdapterCategory getCategory() { return category; } - public @Nullable JsonNode getUiSchema() { + public @NotNull JsonNode getUiSchema() { return uiSchema; } @Override public boolean equals(final @Nullable Object o) { - return this == o || o instanceof final ProtocolAdapter that && Objects.equals(id, that.id); + return this == o || (o instanceof final ProtocolAdapter that && Objects.equals(id, that.id)); } @Override diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java index 91a48d3ca3..c015123317 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java @@ -42,6 +42,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static java.util.Objects.requireNonNullElse; + /** * Utilities that handle the display, sort and filter logic relating to protocol adapters. */ @@ -146,7 +148,7 @@ public class ProtocolAdapterApiUtils { return new ProtocolAdapter(module.getId(), module.getId(), module.getName(), - module.getDescription(), + requireNonNullElse(module.getDescription(), ""), module.getDocumentationLink() != null ? module.getDocumentationLink().getUrl() : null, module.getVersion(), getLogoUrl(module, configurationService), @@ -226,14 +228,13 @@ public class ProtocolAdapterApiUtils { * * @param category the category enum to convert */ - @org.jetbrains.annotations.VisibleForTesting + @VisibleForTesting public static @Nullable ProtocolAdapterCategory convertApiCategory(final @Nullable com.hivemq.adapter.sdk.api.ProtocolAdapterCategory category) { - if (category == null) { - return null; - } - return new ProtocolAdapterCategory(category.name(), - category.getDisplayName(), - category.getDescription(), - category.getImage()); + return category != null ? + new ProtocolAdapterCategory(category.name(), + category.getDisplayName(), + category.getDescription(), + category.getImage()) : + null; } } diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index 7a87ca2fed..a1960537d6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -115,5 +115,4 @@ public void setConnectionStatusListener(final @NotNull Consumer protocolAdapterManager.stopAsync(adapterWrapper, false).get()) .rootCause() @@ -390,4 +395,47 @@ public void stop( return new TestWritingProtocolAdapterInformation(); } } + + static class TestWritingAdapterFailOnStop implements WritingProtocolAdapter { + + final ProtocolAdapterState adapterState; + + TestWritingAdapterFailOnStop(final @NotNull ProtocolAdapterState adapterState) { + this.adapterState = adapterState; + } + + @Override + public void write( + final @NotNull WritingInput writingInput, final @NotNull WritingOutput writingOutput) { + + } + + @Override + public @NotNull Class getMqttPayloadClass() { + return null; + } + + @Override + public @NotNull String getId() { + return "test-writing"; + } + + @Override + public void start( + final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + adapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STARTED); + output.startedSuccessfully(); + } + + @Override + public void stop( + final @NotNull ProtocolAdapterStopInput input, final @NotNull ProtocolAdapterStopOutput output) { + output.failStop(new RuntimeException("failed"), "could not stop"); + } + + @Override + public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { + return new TestWritingProtocolAdapterInformation(); + } + } } diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java index 3588244287..3182541304 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java @@ -44,6 +44,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -172,6 +175,23 @@ void setUp() { when(mockAdapter.getId()).thenReturn("test-adapter"); when(mockAdapter.getProtocolAdapterInformation()).thenReturn(mock(ProtocolAdapterInformation.class)); + // Mock adapter.start() to complete the output future immediately + doAnswer(invocation -> { + final com.hivemq.adapter.sdk.api.model.ProtocolAdapterStartOutput output = invocation.getArgument(1); + output.startedSuccessfully(); + return null; + }).when(mockAdapter).start(any(), any()); + + // Mock adapter.stop() to complete the output future immediately + doAnswer(invocation -> { + final com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopOutput output = invocation.getArgument(1); + output.stoppedSuccessfully(); + return null; + }).when(mockAdapter).stop(any(), any()); + + // Mock adapter.destroy() to do nothing + doNothing().when(mockAdapter).destroy(); + final ProtocolAdapterMetricsService metricsService = mock(ProtocolAdapterMetricsService.class); final InternalProtocolAdapterWritingService writingService = mock(InternalProtocolAdapterWritingService.class); final ProtocolAdapterPollingService pollingService = mock(ProtocolAdapterPollingService.class); @@ -421,7 +441,13 @@ void test_adapterIdAccess_isThreadSafe() throws Exception { @Timeout(10) void test_concurrentStartAsync_properSerialization() throws Exception { runConcurrentOperations(SMALL_THREAD_COUNT, - () -> requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices))); + () -> { + try { + requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices)).get(); + } catch (final Exception e) { + // Expected - concurrent operations may fail + } + }); final var state = requireNonNull(wrapper).currentState(); assertNotNull(state); From eccb1ae039650cfd4e5dacea2f07f0c7a171f6a9 Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 16:56:03 +0200 Subject: [PATCH 14/50] fix retry logic on contention --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 63 +++++++++++-------- .../protocols/ProtocolAdapterWrapper.java | 21 +++---- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 1ace6c6407..3502153d8a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -117,45 +117,54 @@ public void stopAdapter() { } public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) { - final var currentState = adapterState.get(); - if(canTransition(currentState, newState)) { - if(adapterState.compareAndSet(currentState, newState)) { - log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; + while (true) { + final var currentState = adapterState.get(); + if (canTransition(currentState, newState)) { + if (adapterState.compareAndSet(currentState, newState)) { + log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); + notifyListenersAboutStateTransition(currentState()); + return true; + } + // CAS failed due to concurrent modification, retry + } else { + // Transition not allowed from current state + throw new IllegalStateException("Cannot transition adapter state to " + newState); } - } else { - throw new IllegalStateException("Cannot transition adapter state to " + newState); } - return false; } public boolean transitionNorthboundState(final @NotNull StateEnum newState) { - final var currentState = northboundState.get(); - if(canTransition(currentState, newState)) { - if(northboundState.compareAndSet(currentState, newState)) { - log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; + while (true) { + final var currentState = northboundState.get(); + if (canTransition(currentState, newState)) { + if (northboundState.compareAndSet(currentState, newState)) { + log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); + notifyListenersAboutStateTransition(currentState()); + return true; + } + // CAS failed due to concurrent modification, retry + } else { + // Transition not allowed from current state + throw new IllegalStateException("Cannot transition northbound state to " + newState); } - } else { - throw new IllegalStateException("Cannot transition northbound state to " + newState); } - return false; } public boolean transitionSouthboundState(final @NotNull StateEnum newState) { - final var currentState = southboundState.get(); - if(canTransition(currentState, newState)) { - if(southboundState.compareAndSet(currentState, newState)) { - log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); - return true; + while (true) { + final var currentState = southboundState.get(); + if (canTransition(currentState, newState)) { + if (southboundState.compareAndSet(currentState, newState)) { + log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); + notifyListenersAboutStateTransition(currentState()); + return true; + } + // CAS failed due to concurrent modification, retry + } else { + // Transition not allowed from current state + throw new IllegalStateException("Cannot transition southbound state to " + newState); } - } else { - throw new IllegalStateException("Cannot transition southbound state to " + newState); } - return false; } @Override diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 42bc3d74fd..b1620ed891 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -94,14 +94,14 @@ public ProtocolAdapterWrapper( this.config = config; this.northboundConsumerFactory = northboundConsumerFactory; this.tagManager = tagManager; - - // Register FSM state transition listener for debugging - registerStateTransitionListener(state -> log.debug( - "Adapter {} FSM state transition: adapter={}, northbound={}, southbound={}", - adapter.getId(), - state.state(), - state.northbound(), - state.southbound())); + if (log.isDebugEnabled()) { + registerStateTransitionListener(state -> log.debug( + "Adapter {} FSM state transition: adapter={}, northbound={}, southbound={}", + adapter.getId(), + state.state(), + state.northbound(), + state.southbound())); + } } @Override @@ -183,7 +183,8 @@ public boolean startSouthbound() { //we still return the initial error since that's the most significant information return CompletableFuture.failedFuture(error); } else { - return attemptStartingConsumers(writingEnabled, moduleServices.eventService()).map(startException -> { + return attemptStartingConsumers(writingEnabled, + moduleServices.eventService()).map(startException -> { log.error("Failed to start adapter with id {}", adapter.getId(), startException); stopAfterFailedStart(); //we still return the initial error since that's the most significant information @@ -257,7 +258,6 @@ private void stopAfterFailedStart() { } public @NotNull CompletableFuture stopAsync(final boolean destroy) { - // Atomically check state and claim the operation in a single step while (true) { final var existingFuture = stopFutureRef.get(); @@ -414,7 +414,6 @@ public boolean isBatchPolling() { private void startPolling( final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService, final @NotNull EventService eventService) { - if (isBatchPolling()) { log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); final PerAdapterSampler sampler = new PerAdapterSampler(this, eventService, tagManager); From 2b3737e30a5a966190e4f4bc1e341e93d14f84c8 Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 19:19:03 +0200 Subject: [PATCH 15/50] fix race condition on stop --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 2 +- .../protocols/ProtocolAdapterWrapper.java | 72 +++++++++++++++---- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 3502153d8a..9239525023 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -52,7 +52,7 @@ public enum StateEnum { StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), StateEnum.ERROR, Set.of(StateEnum.ERROR, StateEnum.CONNECTING, StateEnum.DISCONNECTED), // allow idempotent ERROR->ERROR; can recover from error StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can restart from closed or go to closing - StateEnum.NOT_SUPPORTED, Set.of() // Terminal state for adapters without southbound support + StateEnum.NOT_SUPPORTED, Set.of(StateEnum.NOT_SUPPORTED) // Terminal state for adapters without southbound support; allow idempotent transitions ); public enum AdapterStateEnum { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index b1620ed891..7698233302 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -152,6 +152,13 @@ public boolean startSouthbound() { return existingFuture; } + // Check if adapter is already started - make start operation idempotent + final var currentState = currentState(); + if (currentState.state() == AdapterStateEnum.STARTED) { + log.info("Adapter '{}' is already started, returning success", getId()); + return CompletableFuture.completedFuture(null); + } + // Check if stop operation is in progress final var stopFuture = stopFutureRef.get(); if (stopFuture != null && !stopFuture.isDone()) { @@ -161,7 +168,17 @@ public boolean startSouthbound() { "' while stop operation is in progress")); } - // Create the new future before setting it to avoid race condition + // Create a placeholder future and try to claim ownership atomically + // This ensures only one thread proceeds to actually start the adapter + final CompletableFuture placeholderFuture = new CompletableFuture<>(); + + if (!startFutureRef.compareAndSet(existingFuture, placeholderFuture)) { + // CAS failed - another thread won the race, loop back to get their future + continue; + } + + // We won the CAS - we now own the start operation + // Create the actual future and execute the start sequence initStartAttempt(); final var output = new ProtocolAdapterStartOutputImpl(); final var input = new ProtocolAdapterStartInputImpl(moduleServices); @@ -193,12 +210,17 @@ public boolean startSouthbound() { } }).thenApply(ignored -> (Void) null).whenComplete((result, throwable) -> startFutureRef.set(null)); - // Atomically set the future reference if it's still null or completed - // This prevents race conditions where multiple threads try to start simultaneously - if (startFutureRef.compareAndSet(existingFuture, startFuture)) { - return startFuture; - } - // If CAS failed, another thread won the race - loop back and return their future + // Replace the placeholder with the actual future and complete the placeholder to unblock any waiters + startFutureRef.set(startFuture); + startFuture.whenComplete((result, throwable) -> { + if (throwable != null) { + placeholderFuture.completeExceptionally(throwable); + } else { + placeholderFuture.complete(result); + } + }); + + return placeholderFuture; } } @@ -268,6 +290,13 @@ private void stopAfterFailedStart() { return existingFuture; } + // Check if adapter is already stopped - make stop operation idempotent + final var currentState = currentState(); + if (currentState.state() == AdapterStateEnum.STOPPED) { + log.info("Adapter '{}' is already stopped, returning success", getId()); + return CompletableFuture.completedFuture(null); + } + // Check if start operation is in progress final var startFuture = startFutureRef.get(); if (startFuture != null && !startFuture.isDone()) { @@ -277,7 +306,17 @@ private void stopAfterFailedStart() { "' while start operation is in progress")); } - // Create the new future before setting it to avoid race condition + // Create a placeholder future and try to claim ownership atomically + // This ensures only one thread proceeds to actually stop the adapter + final CompletableFuture placeholderFuture = new CompletableFuture<>(); + + if (!stopFutureRef.compareAndSet(existingFuture, placeholderFuture)) { + // CAS failed - another thread won the race, loop back to get their future + continue; + } + + // We won the CAS - we now own the stop operation + // Create the actual future and execute the stop sequence consumers.forEach(tagManager::removeConsumer); final var input = new ProtocolAdapterStopInputImpl(); final var output = new ProtocolAdapterStopOutputImpl(); @@ -312,12 +351,17 @@ private void stopAfterFailedStart() { stopFutureRef.set(null); }); - // Atomically set the future reference if it's still null or completed - // This prevents race conditions where multiple threads try to stop simultaneously - if (stopFutureRef.compareAndSet(existingFuture, stopFuture)) { - return stopFuture; - } - // If CAS failed, another thread won the race - loop back and return their future + // Replace the placeholder with the actual future and complete the placeholder to unblock any waiters + stopFutureRef.set(stopFuture); + stopFuture.whenComplete((result, throwable) -> { + if (throwable != null) { + placeholderFuture.completeExceptionally(throwable); + } else { + placeholderFuture.complete(result); + } + }); + + return placeholderFuture; } } From c20668200050f52b8bfd231c57345349c9fa647c Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 19:53:31 +0200 Subject: [PATCH 16/50] cleanup not used parameters --- .../impl/ProtocolAdaptersResourceImpl.java | 4 +- .../AbstractSubscriptionSampler.java | 2 +- .../protocols/ProtocolAdapterManager.java | 14 +- .../protocols/ProtocolAdapterWrapper.java | 312 ++++++++++-------- .../protocols/ProtocolAdapterManagerTest.java | 4 +- ...ProtocolAdapterWrapperConcurrencyTest.java | 6 +- 6 files changed, 190 insertions(+), 152 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java index 5ad7affe6f..e3f6dcf16c 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java @@ -380,14 +380,14 @@ public int getDepth() { log.trace("Adapter '{}' was started successfully.", adapterId); } }); - case STOP -> protocolAdapterManager.stopAsync(adapterId, false).whenComplete((result, throwable) -> { + case STOP -> protocolAdapterManager.stopAsync(adapterId).whenComplete((result, throwable) -> { if (throwable != null) { log.error("Failed to stop adapter '{}'.", adapterId, throwable); } else { log.trace("Adapter '{}' was stopped successfully.", adapterId); } }); - case RESTART -> protocolAdapterManager.stopAsync(adapterId, false) + case RESTART -> protocolAdapterManager.stopAsync(adapterId) .thenRun(() -> protocolAdapterManager.startAsync(adapterId)) .whenComplete((result, throwable) -> { if (throwable != null) { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java index c8d5414f5e..3bda46412e 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java @@ -88,7 +88,7 @@ protected void onSamplerError( final @NotNull Throwable exception, final boolean continuing) { protocolAdapter.setErrorConnectionStatus(exception, null); if (!continuing) { - protocolAdapter.stopAsync(false); + protocolAdapter.stopAsync(); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 7fc4c32645..9eddb2c7e6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -179,7 +179,7 @@ public void refresh(final @NotNull List configs) { if (log.isDebugEnabled()) { log.debug("Deleting adapter '{}'", name); } - stopAsync(name, true).whenComplete((ignored, t) -> deleteAdapterInternal(name)).get(); + stopAsync(name).whenComplete((ignored, t) -> deleteAdapterInternal(name)).get(); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); failedAdapters.add(name); @@ -219,7 +219,7 @@ public void refresh(final @NotNull List configs) { if (log.isDebugEnabled()) { log.debug("Updating adapter '{}'", name); } - stopAsync(name, true).thenApply(v -> { + stopAsync(name).thenApply(v -> { deleteAdapterInternal(name); return null; }) @@ -270,9 +270,9 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte "'not found."))); } - public @NotNull CompletableFuture stopAsync(final @NotNull String protocolAdapterId, final boolean destroy) { + public @NotNull CompletableFuture stopAsync(final @NotNull String protocolAdapterId) { Preconditions.checkNotNull(protocolAdapterId); - return getProtocolAdapterWrapperByAdapterId(protocolAdapterId).map(wrapper -> stopAsync(wrapper, destroy)) + return getProtocolAdapterWrapperByAdapterId(protocolAdapterId).map(this::stopAsync) .orElseGet(() -> CompletableFuture.failedFuture(new ProtocolAdapterException("Adapter '" + protocolAdapterId + "'not found."))); @@ -366,7 +366,7 @@ private void deleteAdapterInternal(final @NotNull String adapterId) { Preconditions.checkNotNull(wrapper); final String wid = wrapper.getId(); log.info("Starting protocol-adapter '{}'.", wid); - return wrapper.startAsync(writingEnabled(), moduleServices).whenComplete((result, throwable) -> { + return wrapper.startAsync(moduleServices).whenComplete((result, throwable) -> { if (throwable == null) { log.info("Protocol-adapter '{}' started successfully.", wid); fireEvent(wrapper, @@ -396,11 +396,11 @@ private void fireEvent( } @VisibleForTesting - @NotNull CompletableFuture stopAsync(final @NotNull ProtocolAdapterWrapper wrapper, final boolean destroy) { + @NotNull CompletableFuture stopAsync(final @NotNull ProtocolAdapterWrapper wrapper) { Preconditions.checkNotNull(wrapper); log.info("Stopping protocol-adapter '{}'.", wrapper.getId()); - return wrapper.stopAsync(destroy).whenComplete((result, throwable) -> { + return wrapper.stopAsync().whenComplete((result, throwable) -> { final Event.SEVERITY severity; final String message; final String wid = wrapper.getId(); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 7698233302..02ded3c3dc 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -49,13 +49,19 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterWrapper.class); + private static final long STOP_TIMEOUT_SECONDS = 30; private final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService; private final @NotNull ProtocolAdapter adapter; @@ -67,10 +73,13 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private final @NotNull ProtocolAdapterConfig config; private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; private final @NotNull TagManager tagManager; - private final List consumers = new CopyOnWriteArrayList<>(); - private final AtomicReference> startFutureRef = new AtomicReference<>(null); - private final AtomicReference> stopFutureRef = new AtomicReference<>(null); + private final @NotNull List consumers; + private final @NotNull ReentrantLock operationLock; + private final @NotNull ExecutorService adapterExecutor; protected volatile @Nullable Long lastStartAttemptTime; + private @Nullable CompletableFuture currentStartFuture; + private @Nullable CompletableFuture currentStopFuture; + private @Nullable Consumer connectionStatusListener; public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService, @@ -94,6 +103,17 @@ public ProtocolAdapterWrapper( this.config = config; this.northboundConsumerFactory = northboundConsumerFactory; this.tagManager = tagManager; + this.consumers = new CopyOnWriteArrayList<>(); + this.operationLock = new ReentrantLock(); + + // Create a named executor for this adapter to help with debugging and monitoring + this.adapterExecutor = Executors.newCachedThreadPool(runnable -> { + final Thread thread = new Thread(runnable); + thread.setName("adapter-" + adapter.getId() + "-worker"); + thread.setDaemon(true); + return thread; + }); + if (log.isDebugEnabled()) { registerStateTransitionListener(state -> log.debug( "Adapter {} FSM state transition: adapter={}, northbound={}, southbound={}", @@ -139,88 +159,85 @@ public boolean startSouthbound() { } public @NotNull CompletableFuture startAsync( - final boolean writingEnabled, final @NotNull ModuleServices moduleServices) { - // Atomically check state and claim the operation in a single step - while (true) { - final var existingFuture = startFutureRef.get(); - - // If there's already a start operation in progress, return it - if (existingFuture != null && !existingFuture.isDone()) { + // Use lock to ensure exclusive access during state checks and future creation + // This prevents race conditions between concurrent start/stop calls + operationLock.lock(); + try { + // 1. Check if start already in progress - return existing future + if (currentStartFuture != null && !currentStartFuture.isDone()) { log.info("Start operation already in progress for adapter '{}'", getId()); - return existingFuture; + return currentStartFuture; } - // Check if adapter is already started - make start operation idempotent + // 2. Check if adapter is already started - idempotent operation final var currentState = currentState(); if (currentState.state() == AdapterStateEnum.STARTED) { log.info("Adapter '{}' is already started, returning success", getId()); return CompletableFuture.completedFuture(null); } - // Check if stop operation is in progress - final var stopFuture = stopFutureRef.get(); - if (stopFuture != null && !stopFuture.isDone()) { + // 3. Check if stop operation is in progress - prevent overlap + if (currentStopFuture != null && !currentStopFuture.isDone()) { log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + getId() + "' while stop operation is in progress")); } - // Create a placeholder future and try to claim ownership atomically - // This ensures only one thread proceeds to actually start the adapter - final CompletableFuture placeholderFuture = new CompletableFuture<>(); - - if (!startFutureRef.compareAndSet(existingFuture, placeholderFuture)) { - // CAS failed - another thread won the race, loop back to get their future - continue; - } - - // We won the CAS - we now own the start operation - // Create the actual future and execute the start sequence + // 4. All checks passed - record start attempt time initStartAttempt(); + + // 5. Create and execute the start operation final var output = new ProtocolAdapterStartOutputImpl(); final var input = new ProtocolAdapterStartInputImpl(moduleServices); - final var startFuture = CompletableFuture.supplyAsync(() -> { - // Signal FSM to start (calls onStarting() internally) - startAdapter(); - - try { - adapter.start(input, output); - } catch (final Throwable throwable) { - output.getStartFuture().completeExceptionally(throwable); - } - return output.getStartFuture(); - }).thenCompose(Function.identity()).handle((ignored, error) -> { - if (error != null) { - log.error("Error starting adapter", error); - stopAfterFailedStart(); - //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(error); - } else { - return attemptStartingConsumers(writingEnabled, - moduleServices.eventService()).map(startException -> { - log.error("Failed to start adapter with id {}", adapter.getId(), startException); - stopAfterFailedStart(); - //we still return the initial error since that's the most significant information - return CompletableFuture.failedFuture(startException); - }).orElseGet(() -> CompletableFuture.completedFuture(null)); - } - }).thenApply(ignored -> (Void) null).whenComplete((result, throwable) -> startFutureRef.set(null)); - - // Replace the placeholder with the actual future and complete the placeholder to unblock any waiters - startFutureRef.set(startFuture); - startFuture.whenComplete((result, throwable) -> { - if (throwable != null) { - placeholderFuture.completeExceptionally(throwable); - } else { - placeholderFuture.complete(result); - } - }); - - return placeholderFuture; + final CompletableFuture startFuture = CompletableFuture.supplyAsync(() -> { + // Signal FSM to start (calls onStarting() internally) + startAdapter(); + + try { + adapter.start(input, output); + } catch (final Throwable throwable) { + output.getStartFuture().completeExceptionally(throwable); + } + return output.getStartFuture(); + }, adapterExecutor) // Use named executor for better monitoring + .thenCompose(Function.identity()).handle((ignored, error) -> { + if (error != null) { + log.error("Error starting adapter", error); + stopAfterFailedStart(); + // Return null - cleanup done, adapter in STOPPED state + // Callers should check getRuntimeStatus() to determine if start succeeded + return null; + } else { + return attemptStartingConsumers(moduleServices.eventService()).map( + startException -> { + log.error("Failed to start adapter with id {}", + adapter.getId(), + startException); + stopAfterFailedStart(); + // Return null - cleanup done, adapter in STOPPED state + return (Void) null; + }).orElse(null); + } + }).whenComplete((result, throwable) -> { + // Clear the current operation when complete + operationLock.lock(); + try { + currentStartFuture = null; + } finally { + operationLock.unlock(); + } + }); + + // 6. Store the future before returning - ensures visibility to other threads + currentStartFuture = startFuture; + return startFuture; + + } finally { + operationLock.unlock(); } } @@ -232,19 +249,27 @@ private void stopAfterFailedStart() { // Transition FSM state back to STOPPED stopAdapter(); + // Clean up listeners to prevent memory leaks + cleanupConnectionStatusListener(); + stopPolling(protocolAdapterPollingService); stopWriting(protocolAdapterWritingService); + try { adapter.stop(stopInput, stopOutput); } catch (final Throwable throwable) { log.error("Stopping adapter after a start error failed", throwable); } - //force waiting for the stop future to complete, we are in a separate thread so no harm caused + + // Wait for stop to complete, but with a timeout to prevent indefinite blocking try { - stopOutput.getOutputFuture().get(); + stopOutput.getOutputFuture().get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (final TimeoutException e) { + log.error("Timeout waiting for adapter {} to stop after failed start", adapter.getId()); } catch (final Throwable throwable) { log.error("Stopping adapter after a start error failed", throwable); } + // Always destroy to clean up resources after failed start try { log.info("Destroying adapter with id '{}' after failed start to release all resources", getId()); @@ -255,23 +280,23 @@ private void stopAfterFailedStart() { } private @NotNull Optional attemptStartingConsumers( - final boolean writingEnabled, final @NotNull EventService eventService) { try { - //Adapter started successfully, now start the consumers + // Adapter started successfully, now start the consumers createAndSubscribeTagConsumer(); startPolling(protocolAdapterPollingService, eventService); - // Wire connection status events to FSM for all adapters + // Create and register connection status listener // FSM's accept() method handles: // 1. Transitioning northbound state // 2. Triggering startSouthbound() when CONNECTED (only for writing adapters) - protocolAdapterState.setConnectionStatusListener(status -> { - this.accept(status); - + connectionStatusListener = status -> { + accept(status); // For non-writing adapters that are only polling, southbound is not applicable // but we still need to track northbound connection status - }); + }; + protocolAdapterState.setConnectionStatusListener(connectionStatusListener); + } catch (final Throwable e) { log.error("Protocol adapter start failed", e); return Optional.of(e); @@ -279,89 +304,102 @@ private void stopAfterFailedStart() { return Optional.empty(); } - public @NotNull CompletableFuture stopAsync(final boolean destroy) { - // Atomically check state and claim the operation in a single step - while (true) { - final var existingFuture = stopFutureRef.get(); + /** + * Cleanup the connection status listener to prevent memory leaks. + * Should be called during stop operations. + */ + private void cleanupConnectionStatusListener() { + if (connectionStatusListener != null) { + // Replace with no-op listener instead of null (API doesn't accept null) + protocolAdapterState.setConnectionStatusListener(status -> { + // No-op - adapter is stopping/stopped + }); + connectionStatusListener = null; + } + } + + public @NotNull CompletableFuture stopAsync() { - // If there's already a stop operation in progress, return it - if (existingFuture != null && !existingFuture.isDone()) { + // Use lock to ensure exclusive access during state checks and future creation + // This prevents race conditions between concurrent start/stop calls + operationLock.lock(); + try { + // 1. Check if stop already in progress - return existing future + if (currentStopFuture != null && !currentStopFuture.isDone()) { log.info("Stop operation already in progress for adapter '{}'", getId()); - return existingFuture; + return currentStopFuture; } - // Check if adapter is already stopped - make stop operation idempotent + // 2. Check if adapter is already stopped - idempotent operation final var currentState = currentState(); if (currentState.state() == AdapterStateEnum.STOPPED) { log.info("Adapter '{}' is already stopped, returning success", getId()); return CompletableFuture.completedFuture(null); } - // Check if start operation is in progress - final var startFuture = startFutureRef.get(); - if (startFuture != null && !startFuture.isDone()) { + // 3. Check if start operation is in progress - prevent overlap + if (currentStartFuture != null && !currentStartFuture.isDone()) { log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + getId() + "' while start operation is in progress")); } - // Create a placeholder future and try to claim ownership atomically - // This ensures only one thread proceeds to actually stop the adapter - final CompletableFuture placeholderFuture = new CompletableFuture<>(); - - if (!stopFutureRef.compareAndSet(existingFuture, placeholderFuture)) { - // CAS failed - another thread won the race, loop back to get their future - continue; - } - - // We won the CAS - we now own the stop operation - // Create the actual future and execute the stop sequence - consumers.forEach(tagManager::removeConsumer); + // 4. Create and execute the stop operation final var input = new ProtocolAdapterStopInputImpl(); final var output = new ProtocolAdapterStopOutputImpl(); final var stopFuture = CompletableFuture.supplyAsync(() -> { - // Signal FSM to stop (calls onStopping() internally) - stopAdapter(); - - stopPolling(protocolAdapterPollingService); - stopWriting(protocolAdapterWritingService); - try { - adapter.stop(input, output); - } catch (final Throwable throwable) { - output.getOutputFuture().completeExceptionally(throwable); - } - return output.getOutputFuture(); - }).thenCompose(Function.identity()).whenComplete((result, throwable) -> { - // Always call destroy() to ensure all resources (threads, connections, etc.) are properly released - // This prevents resource leaks from underlying client libraries (OPC UA Milo, database drivers, etc.) - try { - log.info("Destroying adapter with id '{}' to release all resources", getId()); - adapter.destroy(); - } catch (final Exception destroyException) { - log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); - } - - if (throwable == null) { - log.info("Stopped adapter with id {}", adapter.getId()); - } else { - log.error("Error stopping adapter with id {}", adapter.getId(), throwable); - } - stopFutureRef.set(null); - }); - - // Replace the placeholder with the actual future and complete the placeholder to unblock any waiters - stopFutureRef.set(stopFuture); - stopFuture.whenComplete((result, throwable) -> { - if (throwable != null) { - placeholderFuture.completeExceptionally(throwable); - } else { - placeholderFuture.complete(result); - } - }); - - return placeholderFuture; + // Signal FSM to stop (calls onStopping() internally) + stopAdapter(); + + // Clean up listeners to prevent memory leaks + cleanupConnectionStatusListener(); + + // Remove consumers - must be done within async context + consumers.forEach(tagManager::removeConsumer); + + stopPolling(protocolAdapterPollingService); + stopWriting(protocolAdapterWritingService); + + try { + adapter.stop(input, output); + } catch (final Throwable throwable) { + output.getOutputFuture().completeExceptionally(throwable); + } + return output.getOutputFuture(); + }, adapterExecutor) // Use named executor for better monitoring + .thenCompose(Function.identity()).whenComplete((result, throwable) -> { + // Always call destroy() to ensure all resources are properly released + // This prevents resource leaks from underlying client libraries + try { + log.info("Destroying adapter with id '{}' to release all resources", getId()); + adapter.destroy(); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); + } + + if (throwable == null) { + log.info("Stopped adapter with id {}", adapter.getId()); + } else { + log.error("Error stopping adapter with id {}", adapter.getId(), throwable); + } + + // Clear the current operation when complete + operationLock.lock(); + try { + currentStopFuture = null; + } finally { + operationLock.unlock(); + } + }); + + // 5. Store the future before returning - ensures visibility to other threads + currentStopFuture = stopFuture; + return stopFuture; + + } finally { + operationLock.unlock(); } } diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java index 04e7619830..9685ba6d48 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java @@ -232,7 +232,7 @@ void test_stopWritingAdapterSucceeded_eventsFired() throws Exception { // Start the adapter first to transition FSM state to STARTED protocolAdapterManager.startAsync(adapterWrapper).get(); - protocolAdapterManager.stopAsync(adapterWrapper, false).get(); + protocolAdapterManager.stopAsync(adapterWrapper).get(); assertThat(adapterWrapper.getRuntimeStatus()).isEqualTo(ProtocolAdapterState.RuntimeStatus.STOPPED); } @@ -261,7 +261,7 @@ void test_stopWritingAdapterFailed_eventsFired() throws Exception { // Start the adapter first to transition FSM state to STARTED protocolAdapterManager.startAsync(adapterWrapper).get(); - assertThatThrownBy(() -> protocolAdapterManager.stopAsync(adapterWrapper, false).get()) + assertThatThrownBy(() -> protocolAdapterManager.stopAsync(adapterWrapper).get()) .rootCause() .isInstanceOf(RuntimeException.class) .hasMessage("failed"); diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java index 3182541304..29d26677b2 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java @@ -443,7 +443,7 @@ void test_concurrentStartAsync_properSerialization() throws Exception { runConcurrentOperations(SMALL_THREAD_COUNT, () -> { try { - requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices)).get(); + requireNonNull(wrapper).startAsync(requireNonNull(mockModuleServices)).get(); } catch (final Exception e) { // Expected - concurrent operations may fail } @@ -457,10 +457,10 @@ void test_concurrentStartAsync_properSerialization() throws Exception { @Test @Timeout(10) void test_concurrentStopAsync_properSerialization() throws Exception { - requireNonNull(wrapper).startAsync(false, requireNonNull(mockModuleServices)); + requireNonNull(wrapper).startAsync(requireNonNull(mockModuleServices)); Thread.sleep(100); - runConcurrentOperations(SMALL_THREAD_COUNT, () -> requireNonNull(wrapper).stopAsync(false)); + runConcurrentOperations(SMALL_THREAD_COUNT, () -> requireNonNull(wrapper).stopAsync()); final var state = requireNonNull(wrapper).currentState(); assertNotNull(state); From 80eb3ecca7f7dc9851c3ca9388c1d09998922304 Mon Sep 17 00:00:00 2001 From: marregui Date: Mon, 20 Oct 2025 21:03:03 +0200 Subject: [PATCH 17/50] improve thread model by using a shared executor. --- .../common/executors/ioc/ExecutorsModule.java | 72 +++++++++++++------ .../protocols/ProtocolAdapterManager.java | 8 ++- .../protocols/ProtocolAdapterWrapper.java | 18 ++--- .../protocols/ProtocolAdapterManagerTest.java | 35 +++++++-- ...ProtocolAdapterWrapperConcurrencyTest.java | 5 +- 5 files changed, 93 insertions(+), 45 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index 462778c5b7..d631204a22 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -17,53 +17,79 @@ import dagger.Module; import dagger.Provides; - import jakarta.inject.Singleton; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; -/** - * @author Simon L Johnson - */ @Module public abstract class ExecutorsModule { - static final String GROUP_NAME = "hivemq-edge-group"; - static final String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; - static final String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; - private static final ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); + private static final Logger log = LoggerFactory.getLogger(ExecutorsModule.class); + + static final @NotNull String GROUP_NAME = "hivemq-edge-group"; + static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; + static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; + private static final @NotNull ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); @Provides @Singleton - static ScheduledExecutorService scheduledExecutor() { - return Executors.newScheduledThreadPool(4, - new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); + static @NotNull ScheduledExecutorService scheduledExecutor() { + final ScheduledExecutorService executor = + Executors.newScheduledThreadPool(4, new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); + registerShutdownHook(executor, SCHEDULED_WORKER_GROUP_NAME); + return executor; } @Provides @Singleton - static ExecutorService executorService() { - return Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); + static @NotNull ExecutorService executorService() { + final ExecutorService executor = + Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); + registerShutdownHook(executor, CACHED_WORKER_GROUP_NAME); + return executor; + } + + private static void registerShutdownHook(final @NotNull ExecutorService executor, final @NotNull String name) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.debug("Shutting down executor service: {}", name); + executor.shutdown(); + try { + if (!executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) { + log.warn("Executor service {} did not terminate in time, forcing shutdown", name); + executor.shutdownNow(); + } + } catch (final InterruptedException e) { + log.warn("Interrupted while waiting for executor service {} to terminate", name); + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + }, "shutdown-hook-" + name)); } static class HiveMQEdgeThreadFactory implements ThreadFactory { - private final String factoryName; - private final ThreadGroup group; - private volatile int counter = 0; + private final @NotNull String factoryName; + private final @NotNull ThreadGroup group; + private final @NotNull AtomicInteger counter = new AtomicInteger(0); - public HiveMQEdgeThreadFactory(final String factoryName) { + public HiveMQEdgeThreadFactory(final @NotNull String factoryName) { this.factoryName = factoryName; - this.group = new ThreadGroup(coreGroup, factoryName); + this.group = new ThreadGroup(coreGroup, factoryName); } @Override - public Thread newThread(final Runnable r) { - synchronized (group) { - Thread thread = new Thread(coreGroup, r, String.format(factoryName + "-%d", counter++)); - return thread; - } + public @NotNull Thread newThread(final @NotNull Runnable r) { + final Thread thread = new Thread(group, r, factoryName + "-" + counter.getAndIncrement()); + thread.setDaemon(true); + thread.setUncaughtExceptionHandler((t, e) -> + log.error("Uncaught exception in thread {}", t.getName(), e)); + return thread; } } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 9eddb2c7e6..e8ee62a214 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -84,6 +84,7 @@ public class ProtocolAdapterManager { private final @NotNull TagManager tagManager; private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; private final @NotNull ExecutorService executorService; + private final @NotNull ExecutorService sharedAdapterExecutor; @Inject public ProtocolAdapterManager( @@ -99,7 +100,8 @@ public ProtocolAdapterManager( final @NotNull ProtocolAdapterFactoryManager protocolAdapterFactoryManager, final @NotNull NorthboundConsumerFactory northboundConsumerFactory, final @NotNull TagManager tagManager, - final @NotNull ProtocolAdapterExtractor protocolAdapterConfig) { + final @NotNull ProtocolAdapterExtractor protocolAdapterConfig, + final @NotNull ExecutorService sharedAdapterExecutor) { this.metricRegistry = metricRegistry; this.moduleServices = moduleServices; this.remoteService = remoteService; @@ -113,6 +115,7 @@ public ProtocolAdapterManager( this.northboundConsumerFactory = northboundConsumerFactory; this.tagManager = tagManager; this.protocolAdapterConfig = protocolAdapterConfig; + this.sharedAdapterExecutor = sharedAdapterExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); this.executorService = Executors.newSingleThreadExecutor(); Runtime.getRuntime().addShutdownHook(new Thread(executorService::shutdown)); @@ -343,7 +346,8 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte factory.getInformation(), state, northboundConsumerFactory, - tagManager); + tagManager, + sharedAdapterExecutor); }); }); } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 02ded3c3dc..0204b4782f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -75,7 +75,7 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private final @NotNull TagManager tagManager; private final @NotNull List consumers; private final @NotNull ReentrantLock operationLock; - private final @NotNull ExecutorService adapterExecutor; + private final @NotNull ExecutorService sharedAdapterExecutor; protected volatile @Nullable Long lastStartAttemptTime; private @Nullable CompletableFuture currentStartFuture; private @Nullable CompletableFuture currentStopFuture; @@ -91,7 +91,8 @@ public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterInformation adapterInformation, final @NotNull ProtocolAdapterStateImpl protocolAdapterState, final @NotNull NorthboundConsumerFactory northboundConsumerFactory, - final @NotNull TagManager tagManager) { + final @NotNull TagManager tagManager, + final @NotNull ExecutorService sharedAdapterExecutor) { super(config.getAdapterId()); this.protocolAdapterWritingService = protocolAdapterWritingService; this.protocolAdapterPollingService = protocolAdapterPollingService; @@ -105,14 +106,7 @@ public ProtocolAdapterWrapper( this.tagManager = tagManager; this.consumers = new CopyOnWriteArrayList<>(); this.operationLock = new ReentrantLock(); - - // Create a named executor for this adapter to help with debugging and monitoring - this.adapterExecutor = Executors.newCachedThreadPool(runnable -> { - final Thread thread = new Thread(runnable); - thread.setName("adapter-" + adapter.getId() + "-worker"); - thread.setDaemon(true); - return thread; - }); + this.sharedAdapterExecutor = sharedAdapterExecutor; if (log.isDebugEnabled()) { registerStateTransitionListener(state -> log.debug( @@ -203,7 +197,7 @@ public boolean startSouthbound() { output.getStartFuture().completeExceptionally(throwable); } return output.getStartFuture(); - }, adapterExecutor) // Use named executor for better monitoring + }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead .thenCompose(Function.identity()).handle((ignored, error) -> { if (error != null) { log.error("Error starting adapter", error); @@ -368,7 +362,7 @@ private void cleanupConnectionStatusListener() { output.getOutputFuture().completeExceptionally(throwable); } return output.getOutputFuture(); - }, adapterExecutor) // Use named executor for better monitoring + }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead .thenCompose(Function.identity()).whenComplete((result, throwable) -> { // Always call destroy() to ensure all resources are properly released // This prevents resource leaks from underlying client libraries diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java index 9685ba6d48..3c9c463400 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java @@ -43,10 +43,14 @@ import com.hivemq.protocols.northbound.NorthboundConsumerFactory; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -76,9 +80,11 @@ class ProtocolAdapterManagerTest { private final @NotNull ProtocolAdapterConfigConverter protocolAdapterConfigConverter = mock(); private @NotNull ProtocolAdapterManager protocolAdapterManager; + private @NotNull ExecutorService testExecutor; @BeforeEach void setUp() { + testExecutor = Executors.newCachedThreadPool(); protocolAdapterManager = new ProtocolAdapterManager( metricRegistry, moduleServices, @@ -92,7 +98,16 @@ void setUp() { protocolAdapterFactoryManager, northboundConsumerFactory, tagManager, - protocolAdapterExtractor); + protocolAdapterExtractor, + testExecutor); + } + + @AfterEach + void tearDown() throws InterruptedException { + if (testExecutor != null && !testExecutor.isShutdown()) { + testExecutor.shutdown(); + testExecutor.awaitTermination(5, TimeUnit.SECONDS); + } } @Test @@ -114,7 +129,8 @@ void test_startWritingAdapterSucceeded_eventsFired() throws Exception { mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); protocolAdapterManager.startAsync(adapterWrapper).get(); @@ -139,7 +155,8 @@ void test_startWritingNotEnabled_writingNotStarted() throws Exception { mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); protocolAdapterManager.startAsync(adapterWrapper).get(); @@ -169,7 +186,8 @@ void test_startWriting_adapterFailedStart_resourcesCleanedUp() throws Exception{ mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); protocolAdapterManager.startAsync(adapterWrapper).get(); @@ -200,7 +218,8 @@ void test_startWriting_eventServiceFailedStart_resourcesCleanedUp() throws Excep mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); protocolAdapterManager.startAsync(adapterWrapper).get(); @@ -227,7 +246,8 @@ void test_stopWritingAdapterSucceeded_eventsFired() throws Exception { mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); // Start the adapter first to transition FSM state to STARTED protocolAdapterManager.startAsync(adapterWrapper).get(); @@ -256,7 +276,8 @@ void test_stopWritingAdapterFailed_eventsFired() throws Exception { mock(), adapterState, northboundConsumerFactory, - tagManager); + tagManager, + testExecutor); // Start the adapter first to transition FSM state to STARTED protocolAdapterManager.startAsync(adapterWrapper).get(); diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java index 29d26677b2..e20ef2652f 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java @@ -171,6 +171,8 @@ private void runReaderWriterPattern( @BeforeEach void setUp() { + executor = Executors.newCachedThreadPool(); + final ProtocolAdapter mockAdapter = mock(ProtocolAdapter.class); when(mockAdapter.getId()).thenReturn("test-adapter"); when(mockAdapter.getProtocolAdapterInformation()).thenReturn(mock(ProtocolAdapterInformation.class)); @@ -217,7 +219,8 @@ void setUp() { adapterInformation, adapterState, consumerFactory, - tagManager); + tagManager, + executor); } @AfterEach From caf7104ace20f8086552e664610a6eaf1ae6a148 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 07:28:45 +0200 Subject: [PATCH 18/50] improve tests, remove flakiness --- .../api/model/adapters/ProtocolAdapter.java | 26 ++++++----- .../adapters/ProtocolAdapterCategory.java | 30 ++++++------ .../hivemq/api/model/components/Module.java | 46 +++++++++++-------- .../impl/ProtocolAdapterApiUtils.java | 21 +++++---- .../impl/ProtocolAdapterStateImpl.java | 2 +- .../protocols/ProtocolAdapterWrapper.java | 16 +++---- 6 files changed, 72 insertions(+), 69 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java index a1584665fd..4865144da8 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java @@ -26,6 +26,8 @@ import java.util.Objects; import java.util.Set; +import static java.util.Objects.requireNonNullElse; + /** * The API representation of a Protocol Adapter type. */ @@ -54,7 +56,7 @@ public class ProtocolAdapter { private final @NotNull String logoUrl; @JsonProperty("provisioningUrl") @Schema(description = "The provisioning url of the adapter") - private final @NotNull String provisioningUrl; + private final @Nullable String provisioningUrl; @JsonProperty("author") @Schema(description = "The author of the adapter") private final @NotNull String author; @@ -63,7 +65,7 @@ public class ProtocolAdapter { private final @NotNull Boolean installed; @JsonProperty("category") @Schema(description = "The category of the adapter") - private final @NotNull ProtocolAdapterCategory category; + private final @Nullable ProtocolAdapterCategory category; @JsonProperty("tags") @Schema(description = "The search tags associated with this adapter") private final @NotNull List tags; @@ -72,10 +74,10 @@ public class ProtocolAdapter { private final @NotNull Set capabilities; @JsonProperty("configSchema") @Schema(description = "JSONSchema in the 'https://json-schema.org/draft/2020-12/schema' format, which describes the configuration requirements for the adapter.") - private final @NotNull JsonNode configSchema; + private final @Nullable JsonNode configSchema; @JsonProperty("uiSchema") @Schema(description = "UISchema (see https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema/), which describes the UI rendering of the configuration for the adapter.") - private final @NotNull JsonNode uiSchema; + private final @Nullable JsonNode uiSchema; public ProtocolAdapter( @JsonProperty("id") final @NotNull String id, @@ -90,9 +92,9 @@ public ProtocolAdapter( @JsonProperty("installed") final @Nullable Boolean installed, @JsonProperty("capabilities") final @NotNull Set capabilities, @JsonProperty("category") final @Nullable ProtocolAdapterCategory category, - @JsonProperty("tags") final @Nullable List tags, - @JsonProperty("configSchema") final @NotNull JsonNode configSchema, - @JsonProperty("uiSchema") final @NotNull JsonNode uiSchema) { + @JsonProperty("tags") final @NotNull List tags, + @JsonProperty("configSchema") final @Nullable JsonNode configSchema, + @JsonProperty("uiSchema") final @Nullable JsonNode uiSchema) { this.id = id; this.protocol = protocol; this.name = name; @@ -103,7 +105,7 @@ public ProtocolAdapter( this.provisioningUrl = provisioningUrl; this.author = author; this.capabilities = capabilities; - this.installed = installed; + this.installed = requireNonNullElse(installed, Boolean.FALSE); this.category = category; this.tags = tags; this.configSchema = configSchema; @@ -138,7 +140,7 @@ public ProtocolAdapter( return logoUrl; } - public @NotNull String getProvisioningUrl() { + public @Nullable String getProvisioningUrl() { return provisioningUrl; } @@ -146,7 +148,7 @@ public ProtocolAdapter( return author; } - public @NotNull JsonNode getConfigSchema() { + public @Nullable JsonNode getConfigSchema() { return configSchema; } @@ -162,11 +164,11 @@ public ProtocolAdapter( return tags; } - public @NotNull ProtocolAdapterCategory getCategory() { + public @Nullable ProtocolAdapterCategory getCategory() { return category; } - public @NotNull JsonNode getUiSchema() { + public @Nullable JsonNode getUiSchema() { return uiSchema; } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java index d99ad75415..e94871e977 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java @@ -17,15 +17,15 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.hivemq.edge.HiveMQEdgeConstants; +import io.swagger.v3.oas.annotations.media.Schema; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import io.swagger.v3.oas.annotations.media.Schema; + +import static java.util.Objects.requireNonNullElse; /** * A category is a unique entity and represents a curated grouping of a protocol adapter. A protocol adapter * maybe in 1 category. - * - * @author Simon L Johnson */ public class ProtocolAdapterCategory { @@ -34,7 +34,7 @@ public class ProtocolAdapterCategory { description = "The unique name of the category to be used in API communication.", format = "string", minLength = 1, - required = true, + requiredMode = Schema.RequiredMode.REQUIRED, maxLength = HiveMQEdgeConstants.MAX_NAME_LEN, pattern = HiveMQEdgeConstants.NAME_REGEX) private final @NotNull String name; @@ -44,19 +44,15 @@ public class ProtocolAdapterCategory { description = "The display name of the category to be used in HCIs.", format = "string", minLength = 1, - required = true) + requiredMode = Schema.RequiredMode.REQUIRED) private final @NotNull String displayName; @JsonProperty("description") - @Schema(name = "description", - description = "The description associated with the category.", - format = "string") + @Schema(name = "description", description = "The description associated with the category.", format = "string") private final @NotNull String description; @JsonProperty("image") - @Schema(name = "image", - description = "The image associated with the category.", - format = "string") + @Schema(name = "image", description = "The image associated with the category.", format = "string") private final @NotNull String image; public ProtocolAdapterCategory( @@ -66,23 +62,23 @@ public ProtocolAdapterCategory( @JsonProperty("image") final @Nullable String image) { this.name = name; this.displayName = displayName; - this.description = description; - this.image = image; + this.description = requireNonNullElse(description, ""); + this.image = requireNonNullElse(image, ""); } - public String getName() { + public @NotNull String getName() { return name; } - public String getDisplayName() { + public @NotNull String getDisplayName() { return displayName; } - public String getDescription() { + public @NotNull String getDescription() { return description; } - public String getImage() { + public @NotNull String getImage() { return image; } } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/components/Module.java b/hivemq-edge/src/main/java/com/hivemq/api/model/components/Module.java index 37aa8cba59..4f2493fec6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/components/Module.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/components/Module.java @@ -18,16 +18,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import io.swagger.v3.oas.annotations.media.Schema; import java.util.Objects; /** * Bean to transport module details across the API - * - * @author Simon L Johnson */ @JsonIgnoreProperties(ignoreUnknown = true) public class Module { @@ -148,10 +146,7 @@ public Module( @Override public boolean equals(final @Nullable Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Module extension = (Module) o; - return Objects.equals(id, extension.id); + return this == o || (o instanceof final Module that && Objects.equals(id, that.id)); } @Override @@ -161,17 +156,30 @@ public int hashCode() { @Override public @NotNull String toString() { - final StringBuilder sb = new StringBuilder("Module{"); - sb.append("id='").append(id).append('\''); - sb.append(", version='").append(version).append('\''); - sb.append(", name='").append(name).append('\''); - sb.append(", description='").append(description).append('\''); - sb.append(", author='").append(author).append('\''); - sb.append(", priority=").append(priority); - sb.append(", installed=").append(installed); - sb.append(", documentationLink=").append(documentationLink); - sb.append(", provisioningLink=").append(provisioningLink); - sb.append('}'); - return sb.toString(); + return "Module{" + + "id='" + + id + + '\'' + + ", version='" + + version + + '\'' + + ", name='" + + name + + '\'' + + ", description='" + + description + + '\'' + + ", author='" + + author + + '\'' + + ", priority=" + + priority + + ", installed=" + + installed + + ", documentationLink=" + + documentationLink + + ", provisioningLink=" + + provisioningLink + + '}'; } } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java index c015123317..f1210a31b0 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java @@ -39,6 +39,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -101,7 +102,7 @@ public class ProtocolAdapterApiUtils { .map(ProtocolAdapter.Capability::from) .collect(Collectors.toSet()), convertApiCategory(info.getCategory()), - info.getTags() != null ? info.getTags().stream().map(Enum::toString).toList() : null, + info.getTags() != null ? info.getTags().stream().map(Enum::toString).toList() : List.of(), new ProtocolAdapterSchemaManager(objectMapper, adapterManager.writingEnabled() ? info.configurationClassNorthAndSouthbound() : @@ -149,31 +150,31 @@ public class ProtocolAdapterApiUtils { module.getId(), module.getName(), requireNonNullElse(module.getDescription(), ""), - module.getDocumentationLink() != null ? module.getDocumentationLink().getUrl() : null, + module.getDocumentationLink() != null ? module.getDocumentationLink().getUrl() : "", module.getVersion(), getLogoUrl(module, configurationService), module.getProvisioningLink() != null ? module.getProvisioningLink().getUrl() : null, module.getAuthor(), - false, + Boolean.FALSE, Set.of(), null, - null, + List.of(), null, null); } - private static @Nullable String getLogoUrl( + private static @NotNull String getLogoUrl( final @NotNull Module module, final @NotNull ConfigurationService configurationService) { - String logoUrl = null; if (module.getLogoUrl() != null) { - logoUrl = module.getLogoUrl().getUrl(); + final String logoUrl = module.getLogoUrl().getUrl(); if (logoUrl != null) { - logoUrl = logoUrl.startsWith("/") ? "/module" + logoUrl : logoUrl; - logoUrl = applyAbsoluteServerAddressInDeveloperMode(logoUrl, configurationService); + return applyAbsoluteServerAddressInDeveloperMode( + logoUrl.startsWith("/") ? "/module" + logoUrl : logoUrl, + configurationService); } } - return logoUrl; + return ""; } private static @NotNull String getLogoUrl( diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index a1960537d6..ffb96a4816 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -35,7 +35,7 @@ public class ProtocolAdapterStateImpl implements ProtocolAdapterState { protected @NotNull AtomicReference connectionStatus = new AtomicReference<>(ConnectionStatus.DISCONNECTED); protected @Nullable String lastErrorMessage; - private final AtomicReference> connectionStatusListener = new AtomicReference<>(); + private final @NotNull AtomicReference> connectionStatusListener = new AtomicReference<>(); public ProtocolAdapterStateImpl(final @NotNull EventService eventService, final @NotNull String adapterId, diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 0204b4782f..b0fb15891d 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -50,7 +50,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; @@ -206,15 +205,12 @@ public boolean startSouthbound() { // Callers should check getRuntimeStatus() to determine if start succeeded return null; } else { - return attemptStartingConsumers(moduleServices.eventService()).map( - startException -> { - log.error("Failed to start adapter with id {}", - adapter.getId(), - startException); - stopAfterFailedStart(); - // Return null - cleanup done, adapter in STOPPED state - return (Void) null; - }).orElse(null); + return attemptStartingConsumers(moduleServices.eventService()).map(startException -> { + log.error("Failed to start adapter with id {}", adapter.getId(), startException); + stopAfterFailedStart(); + // Return null - cleanup done, adapter in STOPPED state + return (Void) null; + }).orElse(null); } }).whenComplete((result, throwable) -> { // Clear the current operation when complete From dc64f7c4583368a988aac73e269080e8ecde70e6 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 08:00:44 +0200 Subject: [PATCH 19/50] improve start/close rest resource for protocol adapter --- .../impl/ProtocolAdaptersResourceImpl.java | 68 +++++++++++-------- .../common/executors/ioc/ExecutorsModule.java | 30 ++++---- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java index e3f6dcf16c..7b2ce3eb4f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java @@ -325,21 +325,37 @@ public int getDepth() { @SuppressWarnings("unchecked") @Override public @NotNull Response updateAdapter(final @NotNull String adapterId, final @NotNull Adapter adapter) { - return systemInformation.isConfigWriteable() ? - configExtractor.getAdapterByAdapterId(adapterId).map(oldInstance -> { - final ProtocolAdapterEntity newConfig = new ProtocolAdapterEntity(oldInstance.getAdapterId(), - oldInstance.getProtocolId(), - oldInstance.getConfigVersion(), - (Map) adapter.getConfig(), - oldInstance.getNorthboundMappings(), - oldInstance.getSouthboundMappings(), - oldInstance.getTags()); - if (!configExtractor.updateAdapter(newConfig)) { - return adapterCannotBeUpdatedError(adapterId); - } - return Response.ok().build(); - }).orElseGet(adapterNotFoundError(adapterId)) : - errorResponse(new ConfigWritingDisabled()); + if (!systemInformation.isConfigWriteable()) { + return errorResponse(new ConfigWritingDisabled()); + } + + // Validate adapter configuration before updating + final ApiErrorMessages errorMessages = ApiErrorUtils.createErrorContainer(); + validateAdapterSchema(errorMessages, adapter); + if (hasRequestErrors(errorMessages)) { + return errorResponse(new AdapterFailedSchemaValidationError(errorMessages.toErrorList())); + } + + return configExtractor.getAdapterByAdapterId(adapterId).map(oldInstance -> { + try { + final ProtocolAdapterEntity newConfig = new ProtocolAdapterEntity(oldInstance.getAdapterId(), + oldInstance.getProtocolId(), + oldInstance.getConfigVersion(), + (Map) adapter.getConfig(), + oldInstance.getNorthboundMappings(), + oldInstance.getSouthboundMappings(), + oldInstance.getTags()); + if (!configExtractor.updateAdapter(newConfig)) { + return adapterCannotBeUpdatedError(adapterId); + } + return Response.ok().build(); + } catch (final @NotNull IllegalArgumentException e) { + if (e.getCause() instanceof final UnrecognizedPropertyException pe) { + addValidationError(errorMessages, pe.getPropertyName(), "Unknown field on adapter configuration"); + } + return errorResponse(new AdapterFailedSchemaValidationError(errorMessages.toErrorList())); + } + }).orElseGet(adapterNotFoundError(adapterId)); } @Override @@ -388,12 +404,12 @@ public int getDepth() { } }); case RESTART -> protocolAdapterManager.stopAsync(adapterId) - .thenRun(() -> protocolAdapterManager.startAsync(adapterId)) + .thenCompose(ignored -> protocolAdapterManager.startAsync(adapterId)) .whenComplete((result, throwable) -> { if (throwable != null) { log.error("Failed to restart adapter '{}'.", adapterId, throwable); } else { - log.trace("Adapter '{}' was restarted successfully.", adapterId); + log.info("Adapter '{}' was restarted successfully.", adapterId); } }); } @@ -894,17 +910,15 @@ private void validateAdapterSchema( } private @NotNull Adapter toAdapter(final @NotNull ProtocolAdapterWrapper value) { - final Thread currentThread = Thread.currentThread(); - final ClassLoader ctxClassLoader = currentThread.getContextClassLoader(); - final Map config; - try { - currentThread.setContextClassLoader(value.getAdapterFactory().getClass().getClassLoader()); - config = value.getAdapterFactory().unconvertConfigObject(objectMapper, value.getConfigObject()); - config.put("id", value.getId()); - } finally { - currentThread.setContextClassLoader(ctxClassLoader); - } final String adapterId = value.getId(); + final Map config = runWithContextLoader( + value.getAdapterFactory().getClass().getClassLoader(), + () -> { + final Map cfg = value.getAdapterFactory() + .unconvertConfigObject(objectMapper, value.getConfigObject()); + cfg.put("id", value.getId()); + return cfg; + }); return new Adapter(adapterId).type(value.getAdapterInformation().getProtocolId()) .config(objectMapper.valueToTree(config)) .status(getAdapterStatusInternal(adapterId)); diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index d631204a22..9e300bbbb7 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -31,11 +31,11 @@ @Module public abstract class ExecutorsModule { - private static final Logger log = LoggerFactory.getLogger(ExecutorsModule.class); + private static final @NotNull Logger log = LoggerFactory.getLogger(ExecutorsModule.class); - static final @NotNull String GROUP_NAME = "hivemq-edge-group"; - static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; - static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; + private static final @NotNull String GROUP_NAME = "hivemq-edge-group"; + private static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; + private static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; private static final @NotNull ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); @Provides @@ -50,15 +50,17 @@ public abstract class ExecutorsModule { @Provides @Singleton static @NotNull ExecutorService executorService() { - final ExecutorService executor = - Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); - registerShutdownHook(executor, CACHED_WORKER_GROUP_NAME); - return executor; + return registerShutdownHook(Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)), + CACHED_WORKER_GROUP_NAME); } - private static void registerShutdownHook(final @NotNull ExecutorService executor, final @NotNull String name) { + private static @NotNull ExecutorService registerShutdownHook( + final @NotNull ExecutorService executor, + final @NotNull String name) { Runtime.getRuntime().addShutdownHook(new Thread(() -> { - log.debug("Shutting down executor service: {}", name); + if (log.isDebugEnabled()) { + log.debug("Shutting down executor service: {}", name); + } executor.shutdown(); try { if (!executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) { @@ -66,14 +68,15 @@ private static void registerShutdownHook(final @NotNull ExecutorService executor executor.shutdownNow(); } } catch (final InterruptedException e) { - log.warn("Interrupted while waiting for executor service {} to terminate", name); Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for executor service {} to terminate", name); executor.shutdownNow(); } }, "shutdown-hook-" + name)); + return executor; } - static class HiveMQEdgeThreadFactory implements ThreadFactory { + private static class HiveMQEdgeThreadFactory implements ThreadFactory { private final @NotNull String factoryName; private final @NotNull ThreadGroup group; private final @NotNull AtomicInteger counter = new AtomicInteger(0); @@ -87,8 +90,7 @@ public HiveMQEdgeThreadFactory(final @NotNull String factoryName) { public @NotNull Thread newThread(final @NotNull Runnable r) { final Thread thread = new Thread(group, r, factoryName + "-" + counter.getAndIncrement()); thread.setDaemon(true); - thread.setUncaughtExceptionHandler((t, e) -> - log.error("Uncaught exception in thread {}", t.getName(), e)); + thread.setUncaughtExceptionHandler((t, e) -> log.error("Uncaught exception in thread {}", t.getName(), e)); return thread; } } From 1f24e083b66bae6d5eac280237252e693bc8c400 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 08:19:00 +0200 Subject: [PATCH 20/50] improve thread safety of ProtocolAdapterStateImpl --- .../impl/ProtocolAdapterStateImpl.java | 66 +++++++++++-------- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 18 ++--- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index ffb96a4816..becb006c73 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -20,38 +20,42 @@ import com.hivemq.adapter.sdk.api.events.model.Payload; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import com.hivemq.edge.modules.api.events.model.EventImpl; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.apache.commons.lang3.exception.ExceptionUtils; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; public class ProtocolAdapterStateImpl implements ProtocolAdapterState { + protected final @NotNull AtomicReference runtimeStatus; + protected final @NotNull AtomicReference connectionStatus; + protected final @NotNull AtomicReference<@Nullable String> lastErrorMessage; private final @NotNull EventService eventService; private final @NotNull String adapterId; private final @NotNull String protocolId; - protected @NotNull AtomicReference runtimeStatus = new AtomicReference<>(RuntimeStatus.STOPPED); - protected @NotNull AtomicReference connectionStatus = - new AtomicReference<>(ConnectionStatus.DISCONNECTED); - protected @Nullable String lastErrorMessage; - private final @NotNull AtomicReference> connectionStatusListener = new AtomicReference<>(); + private final @NotNull AtomicReference> connectionStatusListener; - public ProtocolAdapterStateImpl(final @NotNull EventService eventService, - final @NotNull String adapterId, - final @NotNull String protocolId) { + public ProtocolAdapterStateImpl( + final @NotNull EventService eventService, + final @NotNull String adapterId, + final @NotNull String protocolId) { this.eventService = eventService; this.adapterId = adapterId; this.protocolId = protocolId; + this.runtimeStatus = new AtomicReference<>(RuntimeStatus.STOPPED); + this.connectionStatus = new AtomicReference<>(ConnectionStatus.DISCONNECTED); + this.lastErrorMessage = new AtomicReference<>(null); + this.connectionStatusListener = new AtomicReference<>(); } @Override public boolean setConnectionStatus(final @NotNull ConnectionStatus connectionStatus) { Preconditions.checkNotNull(connectionStatus); final var changed = this.connectionStatus.getAndSet(connectionStatus) != connectionStatus; - if(changed) { + if (changed) { final var listener = connectionStatusListener.get(); - if(listener != null) { + if (listener != null) { listener.accept(connectionStatus); } } @@ -68,11 +72,8 @@ public boolean setConnectionStatus(final @NotNull ConnectionStatus connectionSta * and the errorMessage to that supplied. */ @Override - public void setErrorConnectionStatus( - final @Nullable Throwable t, - final @Nullable String errorMessage) { - final boolean changed = setConnectionStatus(ConnectionStatus.ERROR); - reportErrorMessage( t, errorMessage, changed); + public void setErrorConnectionStatus(final @Nullable Throwable t, final @Nullable String errorMessage) { + reportErrorMessage(t, errorMessage, setConnectionStatus(ConnectionStatus.ERROR)); } /** @@ -86,33 +87,40 @@ public void reportErrorMessage( final @Nullable Throwable throwable, final @Nullable String errorMessage, final boolean sendEvent) { - this.lastErrorMessage = errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage; + final String msg = errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage; + this.lastErrorMessage.set(msg); if (sendEvent) { - eventService.createAdapterEvent(adapterId, protocolId) + final var eventBuilder = eventService.createAdapterEvent(adapterId, protocolId) .withSeverity(EventImpl.SEVERITY.ERROR) - .withMessage(String.format("Adapter '%s' encountered an error.", adapterId)) - .withPayload(Payload.ContentType.PLAIN_TEXT, ExceptionUtils.getStackTrace(throwable)) - .fire(); + .withMessage(String.format("Adapter '%s' encountered an error.", adapterId)); + if (throwable != null) { + eventBuilder.withPayload(Payload.ContentType.PLAIN_TEXT, ExceptionUtils.getStackTrace(throwable)); + } else if (errorMessage != null) { + eventBuilder.withPayload(Payload.ContentType.PLAIN_TEXT, errorMessage); + } + eventBuilder.fire(); } } @Override - public void setRuntimeStatus(final @NotNull RuntimeStatus runtimeStatus) { - this.runtimeStatus.set(runtimeStatus); + public @NotNull RuntimeStatus getRuntimeStatus() { + return this.runtimeStatus.get(); } @Override - public @NotNull RuntimeStatus getRuntimeStatus() { - return this.runtimeStatus.get(); + public void setRuntimeStatus(final @NotNull RuntimeStatus runtimeStatus) { + this.runtimeStatus.set(runtimeStatus); } @Override public @Nullable String getLastErrorMessage() { - return lastErrorMessage; + return lastErrorMessage.get(); } - public void setConnectionStatusListener(final @NotNull Consumer connectionStatusListener) { - this.connectionStatusListener.set(connectionStatusListener); - connectionStatusListener.accept(connectionStatus.get()); + public void setConnectionStatusListener(final @NotNull Consumer listener) { + // Capture current status before setting listener to reduce race window + final ConnectionStatus currentStatus = connectionStatus.get(); + connectionStatusListener.set(listener); + listener.accept(currentStatus); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 9239525023..9ca4b8dc5c 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -69,18 +69,21 @@ public enum AdapterStateEnum { AdapterStateEnum.STOPPING, Set.of(AdapterStateEnum.STOPPED) ); - private final AtomicReference northboundState = new AtomicReference<>(StateEnum.DISCONNECTED); - private final AtomicReference southboundState = new AtomicReference<>(StateEnum.DISCONNECTED); - private final AtomicReference adapterState = new AtomicReference<>(AdapterStateEnum.STOPPED); - - private final List> stateTransitionListeners = new CopyOnWriteArrayList<>(); + private final @NotNull AtomicReference northboundState; + private final @NotNull AtomicReference southboundState; + private final @NotNull AtomicReference adapterState; + private final @NotNull List> stateTransitionListeners; public record State(AdapterStateEnum state, StateEnum northbound, StateEnum southbound) { } - private final String adapterId; + private final @NotNull String adapterId; public ProtocolAdapterFSM(final @NotNull String adapterId) { this.adapterId = adapterId; + this.northboundState = new AtomicReference<>(StateEnum.DISCONNECTED); + this.southboundState = new AtomicReference<>(StateEnum.DISCONNECTED); + this.adapterState = new AtomicReference<>(AdapterStateEnum.STOPPED); + this.stateTransitionListeners = new CopyOnWriteArrayList<>(); } public abstract boolean onStarting(); @@ -172,7 +175,6 @@ public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) final var transitionResult = switch (connectionStatus) { case CONNECTED -> transitionNorthboundState(StateEnum.CONNECTED) && startSouthbound(); - case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); case ERROR -> transitionNorthboundState(StateEnum.ERROR); @@ -243,7 +245,7 @@ public void unregisterStateTransitionListener(final @NotNull Consumer sta stateTransitionListeners.remove(stateTransitionListener); } - public State currentState() { + public @NotNull State currentState() { return new State(adapterState.get(), northboundState.get(), southboundState.get()); } From 6e039800a78478f5f1d776ed1efadb084909950d Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 10:15:36 +0200 Subject: [PATCH 21/50] remove race condition on stop --- .../edge/adapters/modbus/ModbusProtocolAdapter.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java b/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java index 68fd3e3a4e..2b7dc8f1b9 100644 --- a/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java +++ b/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java @@ -139,7 +139,7 @@ public void start( @Override public void stop(final @NotNull ProtocolAdapterStopInput input, final @NotNull ProtocolAdapterStopOutput output) { - if (startRequested.get() && stopRequested.compareAndSet(false, true)) { + if (stopRequested.compareAndSet(false, true)) { log.info("Stopping Modbus protocol adapter {}", adapterId); publishChangedDataOnlyHandler.clear(); try { @@ -166,6 +166,12 @@ public void stop(final @NotNull ProtocolAdapterStopInput input, final @NotNull P } catch (final InterruptedException | ExecutionException e) { log.error("Unable to stop the connection to the Modbus server", e); } + } else { + // stop() called when already stopped or stop in progress + // This can happen when stopping after a failed start + // Just complete successfully - adapter is already stopped + log.debug("Stop called for Modbus adapter {} but adapter is already stopped or stopping", adapterId); + output.stoppedSuccessfully(); } } From d9ec4d633af1bf3ca16c1fb7330f46f55bfc59e1 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 11:31:37 +0200 Subject: [PATCH 22/50] propagate connection errors --- .../hivemq/protocols/ProtocolAdapterWrapper.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index b0fb15891d..bd73b99d8a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -197,20 +197,17 @@ public boolean startSouthbound() { } return output.getStartFuture(); }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead - .thenCompose(Function.identity()).handle((ignored, error) -> { + .thenCompose(Function.identity()).whenComplete((ignored, error) -> { if (error != null) { log.error("Error starting adapter", error); stopAfterFailedStart(); - // Return null - cleanup done, adapter in STOPPED state - // Callers should check getRuntimeStatus() to determine if start succeeded - return null; } else { - return attemptStartingConsumers(moduleServices.eventService()).map(startException -> { + attemptStartingConsumers(moduleServices.eventService()).ifPresent(startException -> { log.error("Failed to start adapter with id {}", adapter.getId(), startException); stopAfterFailedStart(); - // Return null - cleanup done, adapter in STOPPED state - return (Void) null; - }).orElse(null); + // Propagate the exception - this will fail the future + throw new RuntimeException("Failed to start consumers", startException); + }); } }).whenComplete((result, throwable) -> { // Clear the current operation when complete From 64e439b182d1d7b8c08da093e83d106300e5f121 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 12:19:27 +0200 Subject: [PATCH 23/50] stop adapters orderly on shutdown of EDGE --- .../common/executors/ioc/ExecutorsModule.java | 14 ++- .../protocols/ProtocolAdapterManager.java | 109 +++++++++++++++++- .../protocols/ProtocolAdapterWrapper.java | 19 ++- .../etherip/EipPollingProtocolAdapter.java | 64 +++++++--- .../PublishChangedDataOnlyHandler.java | 4 +- 5 files changed, 187 insertions(+), 23 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index 9e300bbbb7..24d6007601 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -61,11 +61,21 @@ public abstract class ExecutorsModule { if (log.isDebugEnabled()) { log.debug("Shutting down executor service: {}", name); } - executor.shutdown(); + // Only initiate shutdown if not already shutting down + // This allows ProtocolAdapterManager to shut down executors first + if (!executor.isShutdown()) { + executor.shutdown(); + } try { - if (!executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) { + // Reduced timeout since ProtocolAdapterManager should have already + // initiated shutdown for adapters + if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { log.warn("Executor service {} did not terminate in time, forcing shutdown", name); executor.shutdownNow(); + // Give a final grace period after forced shutdown + if (!executor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)) { + log.error("Executor service {} still has running tasks after forced shutdown", name); + } } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index e8ee62a214..784028b3c9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -57,6 +57,9 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -85,6 +88,7 @@ public class ProtocolAdapterManager { private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; private final @NotNull ExecutorService executorService; private final @NotNull ExecutorService sharedAdapterExecutor; + private final @NotNull AtomicBoolean shutdownInitiated; @Inject public ProtocolAdapterManager( @@ -118,7 +122,18 @@ public ProtocolAdapterManager( this.sharedAdapterExecutor = sharedAdapterExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); this.executorService = Executors.newSingleThreadExecutor(); - Runtime.getRuntime().addShutdownHook(new Thread(executorService::shutdown)); + this.shutdownInitiated = new AtomicBoolean(false); + + // Register coordinated shutdown hook that stops adapters BEFORE executors shutdown + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (shutdownInitiated.compareAndSet(false, true)) { + log.info("Initiating coordinated shutdown of Protocol Adapter Manager"); + stopAllAdaptersOnShutdown(); + shutdownExecutorsGracefully(); + log.info("Protocol Adapter Manager shutdown completed"); + } + }, "protocol-adapter-manager-shutdown")); + protocolAdapterWritingService.addWritingChangedCallback(() -> protocolAdapterFactoryManager.writingEnabledChanged( protocolAdapterWritingService.writingEnabled())); } @@ -519,4 +534,96 @@ public boolean writingEnabled() { configConverter.convertTagDefinitionToJsonNode(tag.getDefinition()))) .toList()); } + + /** + * Stop all adapters during shutdown in a coordinated manner. + * This method is called by the shutdown hook BEFORE executors are shut down, + * ensuring adapters can complete their stop operations cleanly. + */ + private void stopAllAdaptersOnShutdown() { + final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); + + if (adaptersToStop.isEmpty()) { + log.debug("No adapters to stop during shutdown"); + return; + } + + log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); + final List> stopFutures = new ArrayList<>(); + + // Initiate stop for all adapters + for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { + try { + log.debug("Initiating stop for adapter '{}'", wrapper.getId()); + final CompletableFuture stopFuture = wrapper.stopAsync(); + stopFutures.add(stopFuture); + } catch (final Exception e) { + log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); + } + } + + // Wait for all adapters to stop, with timeout + final CompletableFuture allStops = CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); + + try { + // Give adapters 20 seconds to stop gracefully + allStops.get(20, TimeUnit.SECONDS); + log.info("All adapters stopped successfully during shutdown"); + } catch (final TimeoutException e) { + log.warn("Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); + // Log which adapters failed to stop + for (int i = 0; i < stopFutures.size(); i++) { + if (!stopFutures.get(i).isDone()) { + log.warn("Adapter '{}' did not complete stop operation within timeout", adaptersToStop.get(i).getId()); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while waiting for adapters to stop during shutdown", e); + } catch (final ExecutionException e) { + log.error("Error occurred while stopping adapters during shutdown", e.getCause()); + } + } + + /** + * Shutdown executors gracefully after adapters have stopped. + * This ensures a clean shutdown sequence. + */ + private void shutdownExecutorsGracefully() { + log.debug("Shutting down protocol adapter manager executors"); + + // Shutdown the single-threaded executor used for adapter refresh + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("Executor service did not terminate in time, forcing shutdown"); + executorService.shutdownNow(); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for executor service to terminate"); + executorService.shutdownNow(); + } + + // Shutdown the shared adapter executor + // Note: This may also be shut down by ExecutorsModule shutdown hook, + // but calling shutdown() multiple times is safe (idempotent) + sharedAdapterExecutor.shutdown(); + try { + if (!sharedAdapterExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("Shared adapter executor did not terminate in time, forcing shutdown"); + sharedAdapterExecutor.shutdownNow(); + // Wait a bit more after forced shutdown + if (!sharedAdapterExecutor.awaitTermination(2, TimeUnit.SECONDS)) { + log.error("Shared adapter executor still has running tasks after forced shutdown"); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for shared adapter executor to terminate"); + sharedAdapterExecutor.shutdownNow(); + } + + log.debug("Protocol adapter manager executors shutdown completed"); + } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index bd73b99d8a..79dd406e22 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -336,38 +336,54 @@ private void cleanupConnectionStatusListener() { final var input = new ProtocolAdapterStopInputImpl(); final var output = new ProtocolAdapterStopOutputImpl(); + log.debug("Adapter '{}': Creating stop operation future", getId()); + final var stopFuture = CompletableFuture.supplyAsync(() -> { + log.debug("Adapter '{}': Stop operation executing in thread '{}'", getId(), Thread.currentThread().getName()); + // Signal FSM to stop (calls onStopping() internally) + log.debug("Adapter '{}': Stopping adapter FSM", getId()); stopAdapter(); // Clean up listeners to prevent memory leaks + log.debug("Adapter '{}': Cleaning up connection status listener", getId()); cleanupConnectionStatusListener(); // Remove consumers - must be done within async context + log.debug("Adapter '{}': Removing {} consumers", getId(), consumers.size()); consumers.forEach(tagManager::removeConsumer); + log.debug("Adapter '{}': Stopping polling", getId()); stopPolling(protocolAdapterPollingService); + + log.debug("Adapter '{}': Stopping writing", getId()); stopWriting(protocolAdapterWritingService); try { + log.debug("Adapter '{}': Calling adapter.stop()", getId()); adapter.stop(input, output); } catch (final Throwable throwable) { + log.error("Adapter '{}': Exception during adapter.stop()", getId(), throwable); output.getOutputFuture().completeExceptionally(throwable); } + log.debug("Adapter '{}': Waiting for stop output future", getId()); return output.getOutputFuture(); }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead .thenCompose(Function.identity()).whenComplete((result, throwable) -> { + log.debug("Adapter '{}': Stop operation completed, starting cleanup", getId()); + // Always call destroy() to ensure all resources are properly released // This prevents resource leaks from underlying client libraries try { log.info("Destroying adapter with id '{}' to release all resources", getId()); adapter.destroy(); + log.debug("Adapter '{}': destroy() completed successfully", getId()); } catch (final Exception destroyException) { log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); } if (throwable == null) { - log.info("Stopped adapter with id {}", adapter.getId()); + log.info("Stopped adapter with id '{}' successfully", adapter.getId()); } else { log.error("Error stopping adapter with id {}", adapter.getId(), throwable); } @@ -376,6 +392,7 @@ private void cleanupConnectionStatusListener() { operationLock.lock(); try { currentStopFuture = null; + log.debug("Adapter '{}': Cleared currentStopFuture reference", getId()); } finally { operationLock.unlock(); } diff --git a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java index 20f3f4f0e6..59104979ad 100644 --- a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java @@ -38,6 +38,8 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -50,7 +52,8 @@ public class EipPollingProtocolAdapter implements BatchPollingProtocolAdapter { private final @NotNull ProtocolAdapterState protocolAdapterState; protected final @NotNull AdapterFactories adapterFactories; private final @NotNull String adapterId; - private volatile @Nullable EtherNetIP etherNetIP; + private final @NotNull Lock connectionLock; + private @Nullable EtherNetIP etherNetIP; // GuardedBy connectionLock private final @NotNull PublishChangedDataOnlyHandler lastSamples = new PublishChangedDataOnlyHandler(); private final @NotNull DataPointFactory dataPointFactory; @@ -69,6 +72,7 @@ public EipPollingProtocolAdapter( .collect(Collectors.toMap(tag -> tag.getDefinition().getAddress(), tag -> tag)); this.protocolAdapterState = input.getProtocolAdapterState(); this.adapterFactories = input.adapterFactories(); + this.connectionLock = new ReentrantLock(); } @Override @@ -80,14 +84,24 @@ public EipPollingProtocolAdapter( public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { // any setup which should be done before the adapter starts polling comes here. + connectionLock.lock(); try { - final EtherNetIP etherNetIP = new EtherNetIP(adapterConfig.getHost(), adapterConfig.getSlot()); - etherNetIP.connectTcp(); - this.etherNetIP = etherNetIP; - output.startedSuccessfully(); + if (etherNetIP != null) { + log.warn("Adapter {} is already started, ignoring start request", adapterId); + output.startedSuccessfully(); + return; + } + + final EtherNetIP newConnection = new EtherNetIP(adapterConfig.getHost(), adapterConfig.getSlot()); + newConnection.connectTcp(); + this.etherNetIP = newConnection; protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.CONNECTED); + output.startedSuccessfully(); } catch (final Exception e) { + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); output.failStart(e, null); + } finally { + connectionLock.unlock(); } } @@ -95,20 +109,28 @@ public void start( public void stop( final @NotNull ProtocolAdapterStopInput protocolAdapterStopInput, final @NotNull ProtocolAdapterStopOutput protocolAdapterStopOutput) { + connectionLock.lock(); try { final EtherNetIP etherNetIPTemp = etherNetIP; etherNetIP = null; + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); + if (etherNetIPTemp != null) { - etherNetIPTemp.close(); - protocolAdapterStopOutput.stoppedSuccessfully(); - log.info("Stopped"); + try { + etherNetIPTemp.close(); + log.info("Stopped adapter {}", adapterId); + } catch (final Exception e) { + log.warn("Error closing EtherNetIP connection for adapter {}", adapterId, e); + } } else { - protocolAdapterStopOutput.stoppedSuccessfully(); - log.info("Stopped without an open connection"); + log.info("Stopped adapter {} without an open connection", adapterId); } + protocolAdapterStopOutput.stoppedSuccessfully(); } catch (final Exception e) { protocolAdapterStopOutput.failStop(e, "Unable to stop Ethernet IP connection"); - log.error("Unable to stop", e); + log.error("Unable to stop adapter {}", adapterId, e); + } finally { + connectionLock.unlock(); } } @@ -121,10 +143,16 @@ public void stop( @Override public void poll( final @NotNull BatchPollingInput pollingInput, final @NotNull BatchPollingOutput pollingOutput) { - final var client = etherNetIP; - if (client == null) { - pollingOutput.fail("Polling failed because adapter wasn't started."); - return; + final EtherNetIP client; + connectionLock.lock(); + try { + client = etherNetIP; + if (client == null) { + pollingOutput.fail("Polling failed because adapter wasn't started."); + return; + } + } finally { + connectionLock.unlock(); } final var tagAddresses = tags.values().stream().map(v -> v.getDefinition().getAddress()).toArray(String[]::new); @@ -148,14 +176,14 @@ public void poll( pollingOutput.finish(); } catch (final CipException e) { if (e.getStatusCode() == 0x04) { - log.warn("A Tag doesn't exist on device.", e); + log.warn("A Tag doesn't exist on device for adapter {}", adapterId, e); pollingOutput.fail(e, "Tag doesn't exist on device"); } else { - log.warn("Problem accessing tag on device.", e); + log.warn("Problem accessing tag on device for adapter {}", adapterId, e); pollingOutput.fail(e, "Problem accessing tag on device."); } } catch (final Exception e) { - log.warn("An exception occurred while reading tags '{}'.", tagAddresses, e); + log.warn("An exception occurred while reading tags '{}' for adapter {}", tagAddresses, adapterId, e); pollingOutput.fail(e, "An exception occurred while reading tags '" + tagAddresses + "'."); } } diff --git a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/PublishChangedDataOnlyHandler.java b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/PublishChangedDataOnlyHandler.java index 24b4987ccf..4bfa6548eb 100644 --- a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/PublishChangedDataOnlyHandler.java +++ b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/PublishChangedDataOnlyHandler.java @@ -39,7 +39,9 @@ public boolean replaceIfValueIsNew(final @NotNull String tagName, final @NotNull } }); - return newValue != computedValue; + // Return true if the value was actually replaced (i.e., computedValue is the new value) + // When values are equal, compute() returns the old value, so references will differ + return computedValue == newValue; } public void clear() { From 5df1d7ff5149637791be80f8bd0fa3a33e9a3195 Mon Sep 17 00:00:00 2001 From: marregui Date: Tue, 21 Oct 2025 14:19:49 +0200 Subject: [PATCH 24/50] coordinate adapter shutdown --- .../common/executors/ioc/ExecutorsModule.java | 74 ++++++----- .../protocols/ProtocolAdapterManager.java | 73 ++++++----- .../protocols/ProtocolAdapterWrapper.java | 115 +++++++++++++----- .../protocols/ProtocolAdapterManagerTest.java | 25 +++- 4 files changed, 185 insertions(+), 102 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index 24d6007601..123a516350 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -35,55 +35,65 @@ public abstract class ExecutorsModule { private static final @NotNull String GROUP_NAME = "hivemq-edge-group"; private static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; + private static final int SCHEDULED_WORKER_GROUP_THREAD_COUNT = 4; private static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; private static final @NotNull ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); @Provides @Singleton static @NotNull ScheduledExecutorService scheduledExecutor() { - final ScheduledExecutorService executor = - Executors.newScheduledThreadPool(4, new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); - registerShutdownHook(executor, SCHEDULED_WORKER_GROUP_NAME); + final var executor = Executors.newScheduledThreadPool(SCHEDULED_WORKER_GROUP_THREAD_COUNT, + new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); + // Shutdown hook removed - ProtocolAdapterManager now handles coordinated shutdown return executor; } @Provides @Singleton static @NotNull ExecutorService executorService() { - return registerShutdownHook(Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)), - CACHED_WORKER_GROUP_NAME); + // Shutdown hook removed - ProtocolAdapterManager now handles coordinated shutdown + return Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); } - private static @NotNull ExecutorService registerShutdownHook( + /** + * Utility method for shutting down an executor service gracefully. + * This is called by ProtocolAdapterManager's shutdown hook to ensure + * executors are shut down AFTER adapters have stopped. + * + * @param executor the executor to shutdown + * @param name the name of the executor for logging + * @param timeoutSeconds how long to wait for termination + */ + public static void shutdownExecutor( final @NotNull ExecutorService executor, - final @NotNull String name) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (log.isDebugEnabled()) { - log.debug("Shutting down executor service: {}", name); - } - // Only initiate shutdown if not already shutting down - // This allows ProtocolAdapterManager to shut down executors first - if (!executor.isShutdown()) { - executor.shutdown(); - } - try { - // Reduced timeout since ProtocolAdapterManager should have already - // initiated shutdown for adapters - if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { - log.warn("Executor service {} did not terminate in time, forcing shutdown", name); - executor.shutdownNow(); - // Give a final grace period after forced shutdown - if (!executor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)) { - log.error("Executor service {} still has running tasks after forced shutdown", name); - } - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while waiting for executor service {} to terminate", name); + final @NotNull String name, + final int timeoutSeconds) { + if (log.isDebugEnabled()) { + log.debug("Shutting down executor service: {}", name); + } + + if (!executor.isShutdown()) { + executor.shutdown(); + } + + try { + if (!executor.awaitTermination(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)) { + log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); executor.shutdownNow(); + // Give a final grace period after forced shutdown + if (!executor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)) { + log.error("Executor service {} still has running tasks after forced shutdown", name); + } + } else { + if (log.isDebugEnabled()) { + log.debug("Executor service {} shut down successfully", name); + } } - }, "shutdown-hook-" + name)); - return executor; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for executor service {} to terminate", name); + executor.shutdownNow(); + } } private static class HiveMQEdgeThreadFactory implements ThreadFactory { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 784028b3c9..5ff91ccdab 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -57,6 +57,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -88,6 +89,7 @@ public class ProtocolAdapterManager { private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; private final @NotNull ExecutorService executorService; private final @NotNull ExecutorService sharedAdapterExecutor; + private final @NotNull ScheduledExecutorService scheduledExecutor; private final @NotNull AtomicBoolean shutdownInitiated; @Inject @@ -105,7 +107,8 @@ public ProtocolAdapterManager( final @NotNull NorthboundConsumerFactory northboundConsumerFactory, final @NotNull TagManager tagManager, final @NotNull ProtocolAdapterExtractor protocolAdapterConfig, - final @NotNull ExecutorService sharedAdapterExecutor) { + final @NotNull ExecutorService sharedAdapterExecutor, + final @NotNull ScheduledExecutorService scheduledExecutor) { this.metricRegistry = metricRegistry; this.moduleServices = moduleServices; this.remoteService = remoteService; @@ -120,6 +123,7 @@ public ProtocolAdapterManager( this.tagManager = tagManager; this.protocolAdapterConfig = protocolAdapterConfig; this.sharedAdapterExecutor = sharedAdapterExecutor; + this.scheduledExecutor = scheduledExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); this.executorService = Executors.newSingleThreadExecutor(); this.shutdownInitiated = new AtomicBoolean(false); @@ -587,43 +591,38 @@ private void stopAllAdaptersOnShutdown() { /** * Shutdown executors gracefully after adapters have stopped. - * This ensures a clean shutdown sequence. + * This ensures a clean shutdown sequence where adapters stop BEFORE + * the executors they depend on are shut down. + *

+ * Shutdown order: + * 1. Protocol adapter manager's internal executor (used for refresh operations) + * 2. Shared adapter executor (used by all adapters for lifecycle operations) + * 3. Scheduled executor (used for scheduled tasks) */ private void shutdownExecutorsGracefully() { - log.debug("Shutting down protocol adapter manager executors"); - - // Shutdown the single-threaded executor used for adapter refresh - executorService.shutdown(); - try { - if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { - log.warn("Executor service did not terminate in time, forcing shutdown"); - executorService.shutdownNow(); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while waiting for executor service to terminate"); - executorService.shutdownNow(); - } - - // Shutdown the shared adapter executor - // Note: This may also be shut down by ExecutorsModule shutdown hook, - // but calling shutdown() multiple times is safe (idempotent) - sharedAdapterExecutor.shutdown(); - try { - if (!sharedAdapterExecutor.awaitTermination(10, TimeUnit.SECONDS)) { - log.warn("Shared adapter executor did not terminate in time, forcing shutdown"); - sharedAdapterExecutor.shutdownNow(); - // Wait a bit more after forced shutdown - if (!sharedAdapterExecutor.awaitTermination(2, TimeUnit.SECONDS)) { - log.error("Shared adapter executor still has running tasks after forced shutdown"); - } - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while waiting for shared adapter executor to terminate"); - sharedAdapterExecutor.shutdownNow(); - } - - log.debug("Protocol adapter manager executors shutdown completed"); + log.info("Shutting down protocol adapter manager executors"); + + // 1. Shutdown the single-threaded executor used for adapter refresh + com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + executorService, + "protocol-adapter-manager-executor", + 5); + + // 2. Shutdown the shared adapter executor + // This is the critical executor that was causing the race condition. + // By shutting it down here AFTER all adapters have stopped, we ensure + // no adapter stop operations are rejected with RejectedExecutionException + com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + sharedAdapterExecutor, + "hivemq-edge-cached-group", + 10); + + // 3. Shutdown the scheduled executor + com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + scheduledExecutor, + "hivemq-edge-scheduled-group", + 5); + + log.info("Protocol adapter manager executors shutdown completed"); } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 79dd406e22..7322390b1f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -22,6 +22,8 @@ import com.hivemq.adapter.sdk.api.discovery.ProtocolAdapterDiscoveryInput; import com.hivemq.adapter.sdk.api.discovery.ProtocolAdapterDiscoveryOutput; import com.hivemq.adapter.sdk.api.events.EventService; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopInput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopOutput; import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; import com.hivemq.adapter.sdk.api.polling.PollingProtocolAdapter; import com.hivemq.adapter.sdk.api.polling.batch.BatchPollingProtocolAdapter; @@ -338,38 +340,48 @@ private void cleanupConnectionStatusListener() { log.debug("Adapter '{}': Creating stop operation future", getId()); - final var stopFuture = CompletableFuture.supplyAsync(() -> { - log.debug("Adapter '{}': Stop operation executing in thread '{}'", getId(), Thread.currentThread().getName()); - - // Signal FSM to stop (calls onStopping() internally) - log.debug("Adapter '{}': Stopping adapter FSM", getId()); - stopAdapter(); - - // Clean up listeners to prevent memory leaks - log.debug("Adapter '{}': Cleaning up connection status listener", getId()); - cleanupConnectionStatusListener(); - - // Remove consumers - must be done within async context - log.debug("Adapter '{}': Removing {} consumers", getId(), consumers.size()); - consumers.forEach(tagManager::removeConsumer); - - log.debug("Adapter '{}': Stopping polling", getId()); - stopPolling(protocolAdapterPollingService); + // Defensive check: if executor is shutdown, execute stop synchronously + // This can happen during JVM shutdown if there's a race between shutdown hooks + if (sharedAdapterExecutor.isShutdown()) { + log.warn("Adapter '{}': Executor is shutdown, executing stop operation synchronously in current thread", getId()); + try { + // Execute stop logic directly in calling thread + final CompletableFuture syncFuture = performStopOperation(input, output) + .whenComplete((result, throwable) -> { + log.debug("Adapter '{}': Synchronous stop operation completed, starting cleanup", getId()); + + // Always call destroy() to ensure all resources are properly released + try { + log.info("Destroying adapter with id '{}' to release all resources", getId()); + adapter.destroy(); + log.debug("Adapter '{}': destroy() completed successfully", getId()); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); + } + + if (throwable == null) { + log.info("Stopped adapter with id '{}' successfully", adapter.getId()); + } else { + log.error("Error stopping adapter with id {}", adapter.getId(), throwable); + } + + // Clear reference to stop future + log.debug("Adapter '{}': Cleared currentStopFuture reference", getId()); + currentStopFuture = null; + }); - log.debug("Adapter '{}': Stopping writing", getId()); - stopWriting(protocolAdapterWritingService); + currentStopFuture = syncFuture; + return syncFuture; + } catch (final Exception e) { + log.error("Adapter '{}': Exception during synchronous stop", getId(), e); + return CompletableFuture.failedFuture(e); + } + } - try { - log.debug("Adapter '{}': Calling adapter.stop()", getId()); - adapter.stop(input, output); - } catch (final Throwable throwable) { - log.error("Adapter '{}': Exception during adapter.stop()", getId(), throwable); - output.getOutputFuture().completeExceptionally(throwable); - } - log.debug("Adapter '{}': Waiting for stop output future", getId()); - return output.getOutputFuture(); - }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead - .thenCompose(Function.identity()).whenComplete((result, throwable) -> { + final var stopFuture = CompletableFuture.supplyAsync(() -> performStopOperation(input, output), + sharedAdapterExecutor) // Use shared executor to reduce thread overhead + .thenCompose(Function.identity()) + .whenComplete((result, throwable) -> { log.debug("Adapter '{}': Stop operation completed, starting cleanup", getId()); // Always call destroy() to ensure all resources are properly released @@ -566,4 +578,47 @@ private void createAndSubscribeTagConsumer() { consumers.add(northboundTagConsumer); }); } + + /** + * Performs the actual stop operation for the adapter. + * Extracted into a separate method so it can be called both asynchronously + * (normal case) and synchronously (when executor is shutdown during JVM shutdown). + * + * @param input the stop input + * @param output the stop output + * @return the completion future from the adapter's stop operation + */ + private @NotNull CompletableFuture performStopOperation( + final @NotNull ProtocolAdapterStopInput input, + final @NotNull ProtocolAdapterStopOutput output) { + log.debug("Adapter '{}': Stop operation executing in thread '{}'", getId(), Thread.currentThread().getName()); + + // Signal FSM to stop (calls onStopping() internally) + log.debug("Adapter '{}': Stopping adapter FSM", getId()); + stopAdapter(); + + // Clean up listeners to prevent memory leaks + log.debug("Adapter '{}': Cleaning up connection status listener", getId()); + cleanupConnectionStatusListener(); + + // Remove consumers + log.debug("Adapter '{}': Removing {} consumers", getId(), consumers.size()); + consumers.forEach(tagManager::removeConsumer); + + log.debug("Adapter '{}': Stopping polling", getId()); + stopPolling(protocolAdapterPollingService); + + log.debug("Adapter '{}': Stopping writing", getId()); + stopWriting(protocolAdapterWritingService); + + try { + log.debug("Adapter '{}': Calling adapter.stop()", getId()); + adapter.stop(input, output); + } catch (final Throwable throwable) { + log.error("Adapter '{}': Exception during adapter.stop()", getId(), throwable); + ((ProtocolAdapterStopOutputImpl) output).getOutputFuture().completeExceptionally(throwable); + } + log.debug("Adapter '{}': Waiting for stop output future", getId()); + return ((ProtocolAdapterStopOutputImpl) output).getOutputFuture(); + } } diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java index 3c9c463400..6297278a5b 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java @@ -48,8 +48,10 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -81,10 +83,12 @@ class ProtocolAdapterManagerTest { private @NotNull ProtocolAdapterManager protocolAdapterManager; private @NotNull ExecutorService testExecutor; + private @NotNull ScheduledExecutorService testScheduledExecutor; @BeforeEach void setUp() { testExecutor = Executors.newCachedThreadPool(); + testScheduledExecutor = Executors.newScheduledThreadPool(2); protocolAdapterManager = new ProtocolAdapterManager( metricRegistry, moduleServices, @@ -99,7 +103,8 @@ void setUp() { northboundConsumerFactory, tagManager, protocolAdapterExtractor, - testExecutor); + testExecutor, + testScheduledExecutor); } @AfterEach @@ -108,6 +113,10 @@ void tearDown() throws InterruptedException { testExecutor.shutdown(); testExecutor.awaitTermination(5, TimeUnit.SECONDS); } + if (testScheduledExecutor != null && !testScheduledExecutor.isShutdown()) { + testScheduledExecutor.shutdown(); + testScheduledExecutor.awaitTermination(5, TimeUnit.SECONDS); + } } @Test @@ -189,8 +198,13 @@ void test_startWriting_adapterFailedStart_resourcesCleanedUp() throws Exception{ tagManager, testExecutor); - protocolAdapterManager.startAsync(adapterWrapper).get(); + // Start will fail, but we expect cleanup to happen + assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("failed"); + // Even though start failed, cleanup should have occurred assertThat(adapterWrapper.getRuntimeStatus()).isEqualTo(ProtocolAdapterState.RuntimeStatus.STOPPED); verify(remoteService).fireUsageEvent(any()); verify(protocolAdapterWritingService).stopWriting(eq((WritingProtocolAdapter) adapterWrapper.getAdapter()), @@ -221,8 +235,13 @@ void test_startWriting_eventServiceFailedStart_resourcesCleanedUp() throws Excep tagManager, testExecutor); - protocolAdapterManager.startAsync(adapterWrapper).get(); + // Start will fail, but we expect cleanup to happen + assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(RuntimeException.class) + .hasMessageContaining("failed"); + // Even though start failed, cleanup should have occurred assertThat(adapterWrapper.getRuntimeStatus()).isEqualTo(ProtocolAdapterState.RuntimeStatus.STOPPED); verify(protocolAdapterWritingService).stopWriting(eq((WritingProtocolAdapter) adapterWrapper.getAdapter()), any()); From 7f7ff31ee6f7cb48595bc7deeee33857fdf0d51c Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 08:33:18 +0200 Subject: [PATCH 25/50] fix flakes in sql relater adapters --- .../api/model/adapters/ProtocolAdapter.java | 26 +++++++++---------- .../adapters/ProtocolAdapterCategory.java | 14 +++++----- .../impl/ProtocolAdapterApiUtils.java | 15 +++++------ .../databases/DatabaseConnection.java | 13 ++++++++-- .../DatabasesPollingProtocolAdapter.java | 8 +++++- .../adapters/http/HttpProtocolAdapter.java | 15 ++++++++--- 6 files changed, 54 insertions(+), 37 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java index 4865144da8..40f0ae5bb8 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapter.java @@ -26,8 +26,6 @@ import java.util.Objects; import java.util.Set; -import static java.util.Objects.requireNonNullElse; - /** * The API representation of a Protocol Adapter type. */ @@ -44,16 +42,16 @@ public class ProtocolAdapter { private final @NotNull String name; @JsonProperty("description") @Schema(description = "The description") - private final @NotNull String description; + private final @Nullable String description; @JsonProperty("url") @Schema(description = "The url of the adapter") - private final @NotNull String url; + private final @Nullable String url; @JsonProperty("version") @Schema(description = "The installed version of the adapter") private final @NotNull String version; @JsonProperty("logoUrl") @Schema(description = "The logo of the adapter") - private final @NotNull String logoUrl; + private final @Nullable String logoUrl; @JsonProperty("provisioningUrl") @Schema(description = "The provisioning url of the adapter") private final @Nullable String provisioningUrl; @@ -62,7 +60,7 @@ public class ProtocolAdapter { private final @NotNull String author; @JsonProperty("installed") @Schema(description = "Is the adapter installed?") - private final @NotNull Boolean installed; + private final @Nullable Boolean installed; @JsonProperty("category") @Schema(description = "The category of the adapter") private final @Nullable ProtocolAdapterCategory category; @@ -83,10 +81,10 @@ public ProtocolAdapter( @JsonProperty("id") final @NotNull String id, @JsonProperty("protocol") final @NotNull String protocol, @JsonProperty("name") final @NotNull String name, - @JsonProperty("description") final @NotNull String description, - @JsonProperty("url") final @NotNull String url, + @JsonProperty("description") final @Nullable String description, + @JsonProperty("url") final @Nullable String url, @JsonProperty("version") final @NotNull String version, - @JsonProperty("logoUrl") final @NotNull String logoUrl, + @JsonProperty("logoUrl") final @Nullable String logoUrl, @JsonProperty("provisioningUrl") final @Nullable String provisioningUrl, @JsonProperty("author") final @NotNull String author, @JsonProperty("installed") final @Nullable Boolean installed, @@ -105,7 +103,7 @@ public ProtocolAdapter( this.provisioningUrl = provisioningUrl; this.author = author; this.capabilities = capabilities; - this.installed = requireNonNullElse(installed, Boolean.FALSE); + this.installed = installed; this.category = category; this.tags = tags; this.configSchema = configSchema; @@ -124,11 +122,11 @@ public ProtocolAdapter( return name; } - public @NotNull String getDescription() { + public @Nullable String getDescription() { return description; } - public @NotNull String getUrl() { + public @Nullable String getUrl() { return url; } @@ -136,7 +134,7 @@ public ProtocolAdapter( return version; } - public @NotNull String getLogoUrl() { + public @Nullable String getLogoUrl() { return logoUrl; } @@ -156,7 +154,7 @@ public ProtocolAdapter( return capabilities; } - public @NotNull Boolean getInstalled() { + public @Nullable Boolean getInstalled() { return installed; } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java index e94871e977..131dff87bf 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/model/adapters/ProtocolAdapterCategory.java @@ -21,8 +21,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import static java.util.Objects.requireNonNullElse; - /** * A category is a unique entity and represents a curated grouping of a protocol adapter. A protocol adapter * maybe in 1 category. @@ -49,11 +47,11 @@ public class ProtocolAdapterCategory { @JsonProperty("description") @Schema(name = "description", description = "The description associated with the category.", format = "string") - private final @NotNull String description; + private final @Nullable String description; @JsonProperty("image") @Schema(name = "image", description = "The image associated with the category.", format = "string") - private final @NotNull String image; + private final @Nullable String image; public ProtocolAdapterCategory( @JsonProperty("name") final @NotNull String name, @@ -62,8 +60,8 @@ public ProtocolAdapterCategory( @JsonProperty("image") final @Nullable String image) { this.name = name; this.displayName = displayName; - this.description = requireNonNullElse(description, ""); - this.image = requireNonNullElse(image, ""); + this.description = description; + this.image = image; } public @NotNull String getName() { @@ -74,11 +72,11 @@ public ProtocolAdapterCategory( return displayName; } - public @NotNull String getDescription() { + public @Nullable String getDescription() { return description; } - public @NotNull String getImage() { + public @Nullable String getImage() { return image; } } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java index f1210a31b0..c56206b2a0 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java @@ -43,8 +43,6 @@ import java.util.Set; import java.util.stream.Collectors; -import static java.util.Objects.requireNonNullElse; - /** * Utilities that handle the display, sort and filter logic relating to protocol adapters. */ @@ -149,8 +147,8 @@ public class ProtocolAdapterApiUtils { return new ProtocolAdapter(module.getId(), module.getId(), module.getName(), - requireNonNullElse(module.getDescription(), ""), - module.getDocumentationLink() != null ? module.getDocumentationLink().getUrl() : "", + module.getDescription(), + module.getDocumentationLink() != null ? module.getDocumentationLink().getUrl() : null, module.getVersion(), getLogoUrl(module, configurationService), module.getProvisioningLink() != null ? module.getProvisioningLink().getUrl() : null, @@ -163,7 +161,7 @@ public class ProtocolAdapterApiUtils { null); } - private static @NotNull String getLogoUrl( + private static @Nullable String getLogoUrl( final @NotNull Module module, final @NotNull ConfigurationService configurationService) { if (module.getLogoUrl() != null) { @@ -174,10 +172,10 @@ public class ProtocolAdapterApiUtils { configurationService); } } - return ""; + return null; } - private static @NotNull String getLogoUrl( + private static @Nullable String getLogoUrl( final @NotNull ProtocolAdapterInformation info, final @NotNull ConfigurationService configurationService, final @Nullable String xOriginalURI) { @@ -199,12 +197,13 @@ public class ProtocolAdapterApiUtils { } } logoUrl = applyAbsoluteServerAddressInDeveloperMode(logoUrl, configurationService); + return logoUrl; } else { // although it is marked as not null it is input from outside (possible customer adapter), // so we should trust but validate and at least log. log.warn("Logo url for adapter '{}' was null. ", info.getDisplayName()); + return null; } - return logoUrl; } @VisibleForTesting diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java index c601f37206..201d2819d8 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java @@ -96,8 +96,17 @@ public void connect() { } public void close() { - if (ds != null) { - ds.close(); + if (ds != null && !ds.isClosed()) { + log.debug("Closing HikariCP datasource"); + try { + // Hard shutdown of HikariCP to ensure threads are terminated + ds.close(); + log.debug("HikariCP datasource closed successfully"); + } catch (final Exception e) { + log.error("Error closing HikariCP datasource", e); + } finally { + ds = null; // Clear reference to allow GC + } } } } diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java index a77486625b..74ffb18e6f 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java @@ -151,6 +151,12 @@ public void stop( protocolAdapterStopOutput.stoppedSuccessfully(); } + @Override + public void destroy() { + log.debug("Destroying database adapter with id '{}'", adapterId); + // Ensure connection pool is fully closed to prevent thread leaks + databaseConnection.close(); + } @Override public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { @@ -163,7 +169,7 @@ public void poll(final @NotNull BatchPollingInput pollingInput, final @NotNull B log.debug("Handling tags for the adapter"); tags.forEach(tag -> loadDataFromDB(pollingOutput, (DatabasesAdapterTag) tag)); - protocolAdapterState.setConnectionStatus(STATELESS); + // Don't manually set connection status - FSM manages this automatically pollingOutput.finish(); } diff --git a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java index 45df1d5a4c..8a99cecb2c 100644 --- a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java +++ b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java @@ -115,7 +115,7 @@ public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { try { - protocolAdapterState.setConnectionStatus(STATELESS); + // Don't manually set connection status - FSM manages this automatically if (httpClient == null) { final HttpClient.Builder builder = HttpClient.newBuilder(); builder.version(HttpClient.Version.HTTP_1_1) @@ -138,6 +138,13 @@ public void stop(final @NotNull ProtocolAdapterStopInput input, final @NotNull P output.stoppedSuccessfully(); } + @Override + public void destroy() { + log.debug("Destroying HTTP adapter with id '{}'", adapterId); + // Clear the HTTP client reference to allow GC to clean up the internal executor + httpClient = null; + } + @Override public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { return adapterInformation; @@ -164,11 +171,11 @@ public void poll( try { for (final CompletableFuture future : pollingFutures) { final var data = future.get(); - if (data.isSuccessStatusCode()) { - protocolAdapterState.setConnectionStatus(STATELESS); - } else { + // Update connection status to ERROR if HTTP request failed + if (!data.isSuccessStatusCode()) { protocolAdapterState.setConnectionStatus(ERROR); } + // FSM manages STATELESS/CONNECTED status automatically if (data.isSuccessStatusCode() || !adapterConfig.getHttpToMqttConfig().isHttpPublishSuccessStatusCodeOnly()) { data.getDataPoints().forEach(pollingOutput::addDataPoint); From df2051ac63822c7d346a1411d6c0f3fb4f88d6bb Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 13:45:15 +0200 Subject: [PATCH 26/50] fix more flakes --- .../common/executors/ioc/ExecutorsModule.java | 25 ++------- .../impl/ProtocolAdapterStateImpl.java | 19 +++---- .../embedded/internal/EmbeddedHiveMQImpl.java | 14 +++++ .../AbstractSubscriptionSampler.java | 6 +- .../protocols/ProtocolAdapterManager.java | 55 ++++++++++--------- .../etherip/EipPollingProtocolAdapter.java | 8 +-- .../adapters/http/HttpProtocolAdapter.java | 5 -- 7 files changed, 60 insertions(+), 72 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index 123a516350..7f2099c5c0 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -34,43 +34,31 @@ public abstract class ExecutorsModule { private static final @NotNull Logger log = LoggerFactory.getLogger(ExecutorsModule.class); private static final @NotNull String GROUP_NAME = "hivemq-edge-group"; - private static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; + public static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; private static final int SCHEDULED_WORKER_GROUP_THREAD_COUNT = 4; - private static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; + public static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; private static final @NotNull ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); @Provides @Singleton static @NotNull ScheduledExecutorService scheduledExecutor() { - final var executor = Executors.newScheduledThreadPool(SCHEDULED_WORKER_GROUP_THREAD_COUNT, + // ProtocolAdapterManager handles coordinated shutdown + return Executors.newScheduledThreadPool(SCHEDULED_WORKER_GROUP_THREAD_COUNT, new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); - // Shutdown hook removed - ProtocolAdapterManager now handles coordinated shutdown - return executor; } @Provides @Singleton static @NotNull ExecutorService executorService() { - // Shutdown hook removed - ProtocolAdapterManager now handles coordinated shutdown + // ProtocolAdapterManager handles coordinated shutdown return Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); } - /** - * Utility method for shutting down an executor service gracefully. - * This is called by ProtocolAdapterManager's shutdown hook to ensure - * executors are shut down AFTER adapters have stopped. - * - * @param executor the executor to shutdown - * @param name the name of the executor for logging - * @param timeoutSeconds how long to wait for termination - */ public static void shutdownExecutor( final @NotNull ExecutorService executor, final @NotNull String name, final int timeoutSeconds) { - if (log.isDebugEnabled()) { - log.debug("Shutting down executor service: {}", name); - } + log.debug("Shutting down executor service: {}", name); if (!executor.isShutdown()) { executor.shutdown(); @@ -80,7 +68,6 @@ public static void shutdownExecutor( if (!executor.awaitTermination(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)) { log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); executor.shutdownNow(); - // Give a final grace period after forced shutdown if (!executor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)) { log.error("Executor service {} still has running tasks after forced shutdown", name); } diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index becb006c73..27e9a5af82 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -76,19 +76,15 @@ public void setErrorConnectionStatus(final @Nullable Throwable t, final @Nullabl reportErrorMessage(t, errorMessage, setConnectionStatus(ConnectionStatus.ERROR)); } - /** - * Sets the last error message associated with the adapter runtime. This is can be sent through the API to - * give an indication of the status of an adapter runtime. - * - * @param errorMessage - */ @Override public void reportErrorMessage( final @Nullable Throwable throwable, final @Nullable String errorMessage, final boolean sendEvent) { - final String msg = errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage; - this.lastErrorMessage.set(msg); + // Sets the last error message associated with the adapter runtime. + // This is can be sent through the API to give an indication of the + // status of an adapter runtime. + lastErrorMessage.set(errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage); if (sendEvent) { final var eventBuilder = eventService.createAdapterEvent(adapterId, protocolId) .withSeverity(EventImpl.SEVERITY.ERROR) @@ -104,12 +100,12 @@ public void reportErrorMessage( @Override public @NotNull RuntimeStatus getRuntimeStatus() { - return this.runtimeStatus.get(); + return runtimeStatus.get(); } @Override - public void setRuntimeStatus(final @NotNull RuntimeStatus runtimeStatus) { - this.runtimeStatus.set(runtimeStatus); + public void setRuntimeStatus(final @NotNull RuntimeStatus status) { + runtimeStatus.set(status); } @Override @@ -118,7 +114,6 @@ public void setRuntimeStatus(final @NotNull RuntimeStatus runtimeStatus) { } public void setConnectionStatusListener(final @NotNull Consumer listener) { - // Capture current status before setting listener to reduce race window final ConnectionStatus currentStatus = connectionStatus.get(); connectionStatusListener.set(listener); listener.accept(currentStatus); diff --git a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java index 09cda3fe67..8f3717e97f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java @@ -212,6 +212,20 @@ private void performStop( final long startTime = System.currentTimeMillis(); try { + // Shutdown protocol adapter manager and its executors BEFORE stopping HiveMQ + // This ensures clean shutdown of all executor thread pools + if (hiveMQServer != null && hiveMQServer.getInjector() != null) { + try { + final com.hivemq.protocols.ProtocolAdapterManager protocolAdapterManager = + hiveMQServer.getInjector().protocolAdapterManager(); + if (protocolAdapterManager != null) { + protocolAdapterManager.shutdown(); + } + } catch (final Exception ex) { + log.warn("Exception during protocol adapter manager shutdown", ex); + } + } + hiveMQServer.stop(); } catch (final Exception ex) { if (desiredState == State.CLOSED) { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java index 3bda46412e..3cc9fd2aa6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java @@ -33,7 +33,6 @@ public abstract class AbstractSubscriptionSampler implements ProtocolAdapterPollingSampler { - private final long initialDelay; private final long period; private final int maxErrorsBeforeRemoval; @@ -53,8 +52,8 @@ public abstract class AbstractSubscriptionSampler implements ProtocolAdapterPoll public AbstractSubscriptionSampler( final @NotNull ProtocolAdapterWrapper protocolAdapter, final @NotNull EventService eventService) { this.protocolAdapter = protocolAdapter; + this.eventService = eventService; this.adapterId = protocolAdapter.getId(); - if (protocolAdapter.getAdapter() instanceof final PollingProtocolAdapter adapter) { this.initialDelay = Math.max(adapter.getPollingIntervalMillis(), 100); this.period = Math.max(adapter.getPollingIntervalMillis(), 10); @@ -66,7 +65,6 @@ public AbstractSubscriptionSampler( } else { throw new IllegalArgumentException("Adapter must be a polling or batch polling protocol adapter"); } - this.eventService = eventService; this.uuid = UUID.randomUUID(); this.created = new Date(); } @@ -173,6 +171,4 @@ public void setScheduledFuture(final @NotNull ScheduledFuture future) { public int hashCode() { return Objects.hash(uuid); } - - } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 5ff91ccdab..1da89752a8 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -26,6 +26,7 @@ import com.hivemq.adapter.sdk.api.services.ProtocolAdapterMetricsService; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import com.hivemq.adapter.sdk.api.tag.Tag; +import com.hivemq.common.executors.ioc.ExecutorsModule; import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; import com.hivemq.configuration.reader.ProtocolAdapterExtractor; import com.hivemq.edge.HiveMQEdgeRemoteService; @@ -129,14 +130,7 @@ public ProtocolAdapterManager( this.shutdownInitiated = new AtomicBoolean(false); // Register coordinated shutdown hook that stops adapters BEFORE executors shutdown - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - if (shutdownInitiated.compareAndSet(false, true)) { - log.info("Initiating coordinated shutdown of Protocol Adapter Manager"); - stopAllAdaptersOnShutdown(); - shutdownExecutorsGracefully(); - log.info("Protocol Adapter Manager shutdown completed"); - } - }, "protocol-adapter-manager-shutdown")); + Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown, "protocol-adapter-manager-shutdown")); protocolAdapterWritingService.addWritingChangedCallback(() -> protocolAdapterFactoryManager.writingEnabledChanged( protocolAdapterWritingService.writingEnabled())); @@ -539,9 +533,30 @@ public boolean writingEnabled() { .toList()); } + /** + * Performs a coordinated shutdown of all adapters and executors. + * This method ensures a clean shutdown sequence: + * 1. Stops all protocol adapters + * 2. Shuts down executor thread pools + *

+ * This method is idempotent - it can be called multiple times safely. + * It is automatically called via shutdown hook when the JVM exits, + * but can also be called explicitly (e.g., during test cleanup). + */ + public void shutdown() { + if (shutdownInitiated.compareAndSet(false, true)) { + log.info("Initiating coordinated shutdown of Protocol Adapter Manager"); + stopAllAdaptersOnShutdown(); + shutdownExecutorsGracefully(); + log.info("Protocol Adapter Manager shutdown completed"); + } else { + log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); + } + } + /** * Stop all adapters during shutdown in a coordinated manner. - * This method is called by the shutdown hook BEFORE executors are shut down, + * This method is called by shutdown() BEFORE executors are shut down, * ensuring adapters can complete their stop operations cleanly. */ private void stopAllAdaptersOnShutdown() { @@ -590,10 +605,6 @@ private void stopAllAdaptersOnShutdown() { } /** - * Shutdown executors gracefully after adapters have stopped. - * This ensures a clean shutdown sequence where adapters stop BEFORE - * the executors they depend on are shut down. - *

* Shutdown order: * 1. Protocol adapter manager's internal executor (used for refresh operations) * 2. Shared adapter executor (used by all adapters for lifecycle operations) @@ -602,25 +613,17 @@ private void stopAllAdaptersOnShutdown() { private void shutdownExecutorsGracefully() { log.info("Shutting down protocol adapter manager executors"); - // 1. Shutdown the single-threaded executor used for adapter refresh - com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + ExecutorsModule.shutdownExecutor( executorService, "protocol-adapter-manager-executor", 5); - - // 2. Shutdown the shared adapter executor - // This is the critical executor that was causing the race condition. - // By shutting it down here AFTER all adapters have stopped, we ensure - // no adapter stop operations are rejected with RejectedExecutionException - com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + ExecutorsModule.shutdownExecutor( sharedAdapterExecutor, - "hivemq-edge-cached-group", + ExecutorsModule.CACHED_WORKER_GROUP_NAME, 10); - - // 3. Shutdown the scheduled executor - com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor( + ExecutorsModule.shutdownExecutor( scheduledExecutor, - "hivemq-edge-scheduled-group", + ExecutorsModule.SCHEDULED_WORKER_GROUP_NAME, 5); log.info("Protocol adapter manager executors shutdown completed"); diff --git a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java index 59104979ad..68496ac773 100644 --- a/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-etherip/src/main/java/com/hivemq/edge/adapters/etherip/EipPollingProtocolAdapter.java @@ -54,7 +54,7 @@ public class EipPollingProtocolAdapter implements BatchPollingProtocolAdapter { private final @NotNull String adapterId; private final @NotNull Lock connectionLock; private @Nullable EtherNetIP etherNetIP; // GuardedBy connectionLock - private final @NotNull PublishChangedDataOnlyHandler lastSamples = new PublishChangedDataOnlyHandler(); + private final @NotNull PublishChangedDataOnlyHandler lastSamples; private final @NotNull DataPointFactory dataPointFactory; private final @NotNull Map tags; @@ -73,6 +73,7 @@ public EipPollingProtocolAdapter( this.protocolAdapterState = input.getProtocolAdapterState(); this.adapterFactories = input.adapterFactories(); this.connectionLock = new ReentrantLock(); + this.lastSamples = new PublishChangedDataOnlyHandler(); } @Override @@ -83,7 +84,6 @@ public EipPollingProtocolAdapter( @Override public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { - // any setup which should be done before the adapter starts polling comes here. connectionLock.lock(); try { if (etherNetIP != null) { @@ -94,7 +94,7 @@ public void start( final EtherNetIP newConnection = new EtherNetIP(adapterConfig.getHost(), adapterConfig.getSlot()); newConnection.connectTcp(); - this.etherNetIP = newConnection; + etherNetIP = newConnection; protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.CONNECTED); output.startedSuccessfully(); } catch (final Exception e) { @@ -114,7 +114,6 @@ public void stop( final EtherNetIP etherNetIPTemp = etherNetIP; etherNetIP = null; protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); - if (etherNetIPTemp != null) { try { etherNetIPTemp.close(); @@ -197,5 +196,4 @@ public int getPollingIntervalMillis() { public int getMaxPollingErrorsBeforeRemoval() { return adapterConfig.getEipToMqttConfig().getMaxPollingErrorsBeforeRemoval(); } - } diff --git a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java index 8a99cecb2c..baa31018b7 100644 --- a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java +++ b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java @@ -63,13 +63,9 @@ import java.util.concurrent.ExecutionException; import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.ERROR; -import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.STATELESS; import static com.hivemq.edge.adapters.http.config.HttpSpecificAdapterConfig.JSON_MIME_TYPE; import static com.hivemq.edge.adapters.http.config.HttpSpecificAdapterConfig.PLAIN_MIME_TYPE; -/** - * @author HiveMQ Adapter Generator - */ public class HttpProtocolAdapter implements BatchPollingProtocolAdapter { private static final @NotNull Logger log = LoggerFactory.getLogger(HttpProtocolAdapter.class); @@ -359,5 +355,4 @@ public void checkServerTrusted( throw new RuntimeException(e); } } - } From 658c93f04a713e3004fdc69355bb56b2acce4d73 Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 14:05:03 +0200 Subject: [PATCH 27/50] fix more flakes, this time in the shutdown sequence --- .../java/com/hivemq/bridge/BridgeService.java | 112 +++++++++--------- .../persistence/ScheduledCleanUpService.java | 7 +- 2 files changed, 59 insertions(+), 60 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java index 9cdb072ce2..25b4809218 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java +++ b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java @@ -41,6 +41,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; import java.util.function.Function; import java.util.stream.Collectors; @@ -97,7 +98,6 @@ public synchronized void updateBridges(final @NotNull List bridges) toUpdate.removeAll(toRemove); - final long start = System.currentTimeMillis(); if (log.isDebugEnabled()) { log.debug("Updating {} active bridges connections from {} configured connections", @@ -111,7 +111,7 @@ public synchronized void updateBridges(final @NotNull List bridges) toRemove.forEach(bridgeId -> { final var active = activeBridgeNamesToClient.remove(bridgeId); allKnownBridgeConfigs.remove(bridgeId); - if(active != null) { + if (active != null) { log.info("Removing bridge {}", bridgeId); internalStopBridge(active, true, List.of()); } else { @@ -122,15 +122,14 @@ public synchronized void updateBridges(final @NotNull List bridges) toUpdate.forEach(bridgeId -> { final var active = activeBridgeNamesToClient.get(bridgeId); final var newBridge = bridgeIdToConfig.get(bridgeId); - if(active != null) { - if(active.bridge().equals(newBridge)) { + if (active != null) { + if (active.bridge().equals(newBridge)) { log.debug("Not restarting bridge {} because config is unchanged", bridgeId); } else { log.info("Restarting bridge {} because config has changed", bridgeId); allKnownBridgeConfigs.put(bridgeId, newBridge); internalStopBridge(active, true, List.of()); - activeBridgeNamesToClient.put( - bridgeId, + activeBridgeNamesToClient.put(bridgeId, new MqttBridgeAndClient(newBridge, internalStartBridge(newBridge))); } } @@ -140,9 +139,7 @@ public synchronized void updateBridges(final @NotNull List bridges) final var newBridge = bridgeIdToConfig.get(bridgeId); log.info("Adding bridge {}", bridgeId); allKnownBridgeConfigs.put(bridgeId, newBridge); - activeBridgeNamesToClient.put( - bridgeId, - new MqttBridgeAndClient(newBridge, internalStartBridge(newBridge))); + activeBridgeNamesToClient.put(bridgeId, new MqttBridgeAndClient(newBridge, internalStartBridge(newBridge))); }); if (log.isTraceEnabled()) { @@ -157,7 +154,7 @@ public synchronized void updateBridges(final @NotNull List bridges) public synchronized boolean isConnected(final @NotNull String bridgeName) { final var mqttBridgeAndClient = activeBridgeNamesToClient.get(bridgeName); - if(mqttBridgeAndClient != null) { + if (mqttBridgeAndClient != null) { return mqttBridgeAndClient.mqttClient().isConnected(); } return false; @@ -185,19 +182,17 @@ public synchronized void stopBridge( } public synchronized boolean restartBridge( - final @NotNull String bridgeId, final @Nullable MqttBridge newBridgeConfig) { + final @NotNull String bridgeId, + final @Nullable MqttBridge newBridgeConfig) { final var bridgeToClient = activeBridgeNamesToClient.get(bridgeId); if (bridgeToClient != null) { log.info("Restarting bridge '{}'", bridgeId); final List unchangedForwarders = newForwarderIds(newBridgeConfig); stopBridge(bridgeId, true, unchangedForwarders); - final var mqttBridgeAndClient = new MqttBridgeAndClient( - newBridgeConfig, + final var mqttBridgeAndClient = new MqttBridgeAndClient(newBridgeConfig, internalStartBridge(newBridgeConfig != null ? newBridgeConfig : bridgeToClient.bridge())); - activeBridgeNamesToClient.put( - bridgeId, - mqttBridgeAndClient); - if(newBridgeConfig != null) { + activeBridgeNamesToClient.put(bridgeId, mqttBridgeAndClient); + if (newBridgeConfig != null) { allKnownBridgeConfigs.put(bridgeId, newBridgeConfig); } return true; @@ -211,12 +206,8 @@ public synchronized boolean startBridge(final @NotNull String bridgId) { final var bridge = allKnownBridgeConfigs.get(bridgId); if (bridge != null && !activeBridgeNamesToClient.containsKey(bridgId)) { log.info("Starting bridge '{}'", bridgId); - final var mqttBridgeAndClient = new MqttBridgeAndClient( - bridge, - internalStartBridge(bridge)); - activeBridgeNamesToClient.put( - bridgId, - mqttBridgeAndClient); + final var mqttBridgeAndClient = new MqttBridgeAndClient(bridge, internalStartBridge(bridge)); + activeBridgeNamesToClient.put(bridgId, mqttBridgeAndClient); return true; } else { log.debug("Not starting bridge '{}' since it was already started", bridgId); @@ -224,9 +215,10 @@ public synchronized boolean startBridge(final @NotNull String bridgId) { } } - private synchronized void internalStopBridge(final @NotNull MqttBridgeAndClient bridgeAndClient, - final boolean clearQueue, - final @NotNull List retainQueueForForwarders) { + private synchronized void internalStopBridge( + final @NotNull MqttBridgeAndClient bridgeAndClient, + final boolean clearQueue, + final @NotNull List retainQueueForForwarders) { final var start = System.currentTimeMillis(); final var bridgeId = bridgeAndClient.bridge().getId(); final var client = bridgeAndClient.mqttClient(); @@ -247,40 +239,45 @@ private synchronized void internalStopBridge(final @NotNull MqttBridgeAndClient } } - private BridgeMqttClient internalStartBridgeMqttClient(final @NotNull MqttBridge bridge, final @NotNull BridgeMqttClient bridgeMqttClient) { + private BridgeMqttClient internalStartBridgeMqttClient( + final @NotNull MqttBridge bridge, + final @NotNull BridgeMqttClient bridgeMqttClient) { final var start = System.currentTimeMillis(); final var bridgeId = bridge.getId(); final ListenableFuture future = bridgeMqttClient.start(); - Futures.addCallback(future, new FutureCallback<>() { - public void onSuccess(@Nullable final Void result) { - log.info("Bridge '{}' to remote broker {}:{} started in {}ms.", - bridge.getId(), - bridge.getHost(), - bridge.getPort(), - (System.currentTimeMillis() - start)); - bridgeNameToLastError.remove(bridge.getId()); - final HiveMQEdgeRemoteEvent startedEvent = - new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_STARTED); - startedEvent.addUserData("cloudBridge", - String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); - startedEvent.addUserData("name", bridgeId); - remoteService.fireUsageEvent(startedEvent); - Checkpoints.checkpoint("mqtt-bridge-connected"); - } + try { + Futures.addCallback(future, new FutureCallback<>() { + public void onSuccess(@Nullable final Void result) { + log.info("Bridge '{}' to remote broker {}:{} started in {}ms.", + bridge.getId(), + bridge.getHost(), + bridge.getPort(), + (System.currentTimeMillis() - start)); + bridgeNameToLastError.remove(bridge.getId()); + final HiveMQEdgeRemoteEvent startedEvent = + new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_STARTED); + startedEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); + startedEvent.addUserData("name", bridgeId); + remoteService.fireUsageEvent(startedEvent); + Checkpoints.checkpoint("mqtt-bridge-connected"); + } - @Override - public void onFailure(final @NotNull Throwable t) { - log.error("Unable oo start bridge '{}'.", bridge.getId(), t); - bridgeNameToLastError.put(bridge.getId(), t); - final HiveMQEdgeRemoteEvent errorEvent = - new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_ERROR); - errorEvent.addUserData("cloudBridge", - String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); - errorEvent.addUserData("cause", t.getMessage()); - errorEvent.addUserData("name", bridgeId); - remoteService.fireUsageEvent(errorEvent); - } - }, executorService); + @Override + public void onFailure(final @NotNull Throwable t) { + log.error("Unable oo start bridge '{}'.", bridge.getId(), t); + bridgeNameToLastError.put(bridge.getId(), t); + final HiveMQEdgeRemoteEvent errorEvent = + new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_ERROR); + errorEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); + errorEvent.addUserData("cause", t.getMessage()); + errorEvent.addUserData("name", bridgeId); + remoteService.fireUsageEvent(errorEvent); + } + }, executorService); + } catch (final RejectedExecutionException e) { + // Executor is shutting down - callback will not run, which is acceptable during shutdown + log.debug("Bridge callback rejected during shutdown for bridge '{}'", bridgeId, e); + } return bridgeMqttClient; } @@ -334,5 +331,6 @@ public void run() { } } - public record MqttBridgeAndClient(MqttBridge bridge, BridgeMqttClient mqttClient) {} + public record MqttBridgeAndClient(MqttBridge bridge, BridgeMqttClient mqttClient) { + } } diff --git a/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java b/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java index def255b775..cb405af3e6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java +++ b/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java @@ -50,8 +50,6 @@ * This service is used to remove full remove tombstones that are older than a certain amount of time * It is also used to check if the time to live of publishes, retained messages or client session is expired and mark * those that are expired as tombstones - * - * @author Lukas Brandl */ @Singleton public class ScheduledCleanUpService { @@ -210,7 +208,10 @@ public void onFailure(final Throwable throwable) { // Note that the "cancelled" CleanUpTask is expected to continue running because the implementation // currently doesn't react to a set thread interrupt flag. But we expect this to be a rare case and want // to ensure the progress of other cleanup procedures despite the potential additional load. - Futures.withTimeout(future, cleanUpTaskTimeoutSec, TimeUnit.SECONDS, scheduledExecutorService); + // Only schedule timeout task if executor is not shutting down to avoid RejectedExecutionException + if (!scheduledExecutorService.isShutdown()) { + Futures.withTimeout(future, cleanUpTaskTimeoutSec, TimeUnit.SECONDS, scheduledExecutorService); + } } catch (final Throwable throwable) { log.error("Exception in clean up job ", throwable); scheduledCleanUpService.scheduleCleanUpTask(); From 6a80148286427f3cf4bb776976f179abee9a2c57 Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 16:10:36 +0200 Subject: [PATCH 28/50] this was a mistake, revert --- .../java/com/hivemq/bridge/BridgeService.java | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java index 25b4809218..35225210f1 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java +++ b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java @@ -245,39 +245,34 @@ private BridgeMqttClient internalStartBridgeMqttClient( final var start = System.currentTimeMillis(); final var bridgeId = bridge.getId(); final ListenableFuture future = bridgeMqttClient.start(); - try { - Futures.addCallback(future, new FutureCallback<>() { - public void onSuccess(@Nullable final Void result) { - log.info("Bridge '{}' to remote broker {}:{} started in {}ms.", - bridge.getId(), - bridge.getHost(), - bridge.getPort(), - (System.currentTimeMillis() - start)); - bridgeNameToLastError.remove(bridge.getId()); - final HiveMQEdgeRemoteEvent startedEvent = - new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_STARTED); - startedEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); - startedEvent.addUserData("name", bridgeId); - remoteService.fireUsageEvent(startedEvent); - Checkpoints.checkpoint("mqtt-bridge-connected"); - } + Futures.addCallback(future, new FutureCallback<>() { + public void onSuccess(@Nullable final Void result) { + log.info("Bridge '{}' to remote broker {}:{} started in {}ms.", + bridge.getId(), + bridge.getHost(), + bridge.getPort(), + (System.currentTimeMillis() - start)); + bridgeNameToLastError.remove(bridge.getId()); + final HiveMQEdgeRemoteEvent startedEvent = + new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_STARTED); + startedEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); + startedEvent.addUserData("name", bridgeId); + remoteService.fireUsageEvent(startedEvent); + Checkpoints.checkpoint("mqtt-bridge-connected"); + } - @Override - public void onFailure(final @NotNull Throwable t) { - log.error("Unable oo start bridge '{}'.", bridge.getId(), t); - bridgeNameToLastError.put(bridge.getId(), t); - final HiveMQEdgeRemoteEvent errorEvent = - new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_ERROR); - errorEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); - errorEvent.addUserData("cause", t.getMessage()); - errorEvent.addUserData("name", bridgeId); - remoteService.fireUsageEvent(errorEvent); - } - }, executorService); - } catch (final RejectedExecutionException e) { - // Executor is shutting down - callback will not run, which is acceptable during shutdown - log.debug("Bridge callback rejected during shutdown for bridge '{}'", bridgeId, e); - } + @Override + public void onFailure(final @NotNull Throwable t) { + log.error("Unable oo start bridge '{}'.", bridge.getId(), t); + bridgeNameToLastError.put(bridge.getId(), t); + final HiveMQEdgeRemoteEvent errorEvent = + new HiveMQEdgeRemoteEvent(HiveMQEdgeRemoteEvent.EVENT_TYPE.BRIDGE_ERROR); + errorEvent.addUserData("cloudBridge", String.valueOf(bridge.getHost().endsWith("hivemq.cloud"))); + errorEvent.addUserData("cause", t.getMessage()); + errorEvent.addUserData("name", bridgeId); + remoteService.fireUsageEvent(errorEvent); + } + }, executorService); return bridgeMqttClient; } From 17022749d73ed0895c8b8e47cdde73b18ede1be7 Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 16:40:10 +0200 Subject: [PATCH 29/50] cleaner shutdown in tests only --- .../com/hivemq/bootstrap/ioc/Injector.java | 7 ++- .../common/executors/ioc/ExecutorsModule.java | 7 +-- .../embedded/internal/EmbeddedHiveMQImpl.java | 59 +++++++++++-------- .../protocols/ProtocolAdapterManager.java | 59 +++++-------------- 4 files changed, 61 insertions(+), 71 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java index b4357d6497..64a6e30bcc 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java +++ b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java @@ -60,6 +60,8 @@ import jakarta.inject.Singleton; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; @SuppressWarnings({"NullabilityAnnotations", "UnusedReturnValue"}) @Component(modules = { @@ -121,7 +123,10 @@ public interface Injector { // UnsServiceModule uns(); -// ExecutorsModule executors(); + // Executor accessors for coordinated shutdown + ExecutorService executorService(); + + ScheduledExecutorService scheduledExecutor(); @Component.Builder interface Builder { diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index 7f2099c5c0..a483b32ec3 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -31,18 +31,18 @@ @Module public abstract class ExecutorsModule { + public static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; + public static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; + private static final @NotNull Logger log = LoggerFactory.getLogger(ExecutorsModule.class); private static final @NotNull String GROUP_NAME = "hivemq-edge-group"; - public static final @NotNull String SCHEDULED_WORKER_GROUP_NAME = "hivemq-edge-scheduled-group"; private static final int SCHEDULED_WORKER_GROUP_THREAD_COUNT = 4; - public static final @NotNull String CACHED_WORKER_GROUP_NAME = "hivemq-edge-cached-group"; private static final @NotNull ThreadGroup coreGroup = new ThreadGroup(GROUP_NAME); @Provides @Singleton static @NotNull ScheduledExecutorService scheduledExecutor() { - // ProtocolAdapterManager handles coordinated shutdown return Executors.newScheduledThreadPool(SCHEDULED_WORKER_GROUP_THREAD_COUNT, new HiveMQEdgeThreadFactory(SCHEDULED_WORKER_GROUP_NAME)); } @@ -50,7 +50,6 @@ public abstract class ExecutorsModule { @Provides @Singleton static @NotNull ExecutorService executorService() { - // ProtocolAdapterManager handles coordinated shutdown return Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); } diff --git a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java index 8f3717e97f..ea9f36eeb1 100644 --- a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java @@ -20,6 +20,7 @@ import com.google.common.annotations.VisibleForTesting; import com.hivemq.HiveMQEdgeMain; import com.hivemq.bootstrap.ioc.Injector; +import com.hivemq.common.executors.ioc.ExecutorsModule; import com.hivemq.configuration.ConfigurationBootstrap; import com.hivemq.configuration.info.SystemInformationImpl; import com.hivemq.configuration.service.ConfigurationService; @@ -27,9 +28,10 @@ import com.hivemq.edge.modules.ModuleLoader; import com.hivemq.embedded.EmbeddedExtension; import com.hivemq.embedded.EmbeddedHiveMQ; +import com.hivemq.protocols.ProtocolAdapterManager; +import com.hivemq.util.ThreadFactoryUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import com.hivemq.util.ThreadFactoryUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,22 +45,17 @@ import java.util.concurrent.Future; import java.util.function.Function; -/** - * @author Georg Held - */ class EmbeddedHiveMQImpl implements EmbeddedHiveMQ { private static final @NotNull Logger log = LoggerFactory.getLogger(EmbeddedHiveMQImpl.class); - - private final @NotNull SystemInformationImpl systemInformation; - private final @NotNull MetricRegistry metricRegistry; @VisibleForTesting final @NotNull ExecutorService stateChangeExecutor; + private final @NotNull SystemInformationImpl systemInformation; + private final @NotNull MetricRegistry metricRegistry; private final @Nullable EmbeddedExtension embeddedExtension; + private final @NotNull Function moduleLoaderFactory; private @Nullable ConfigurationService configurationService; private @Nullable HiveMQEdgeMain hiveMQServer; - private final @NotNull Function moduleLoaderFactory; - private @NotNull State currentState = State.STOPPED; private @NotNull State desiredState = State.STOPPED; private @Nullable Exception failedException; @@ -120,13 +117,6 @@ public void close() throws ExecutionException, InterruptedException { shutDownFuture.get(); } - private enum State { - RUNNING, - STOPPED, - FAILED, - CLOSED - } - private void stateChange() { final List> localStartFutures; final List> localStopFutures; @@ -169,9 +159,8 @@ private void stateChange() { final ModuleLoader moduleLoader = moduleLoaderFactory.apply(systemInformation); moduleLoader.loadModules(); - hiveMQServer = new HiveMQEdgeMain(systemInformation, - metricRegistry, - configurationService, moduleLoader); + hiveMQServer = + new HiveMQEdgeMain(systemInformation, metricRegistry, configurationService, moduleLoader); hiveMQServer.bootstrap(); hiveMQServer.start(embeddedExtension); @@ -212,11 +201,11 @@ private void performStop( final long startTime = System.currentTimeMillis(); try { - // Shutdown protocol adapter manager and its executors BEFORE stopping HiveMQ - // This ensures clean shutdown of all executor thread pools + // Step 1: Shutdown protocol adapters BEFORE stopping HiveMQ + // This allows adapters to complete their stop operations cleanly if (hiveMQServer != null && hiveMQServer.getInjector() != null) { try { - final com.hivemq.protocols.ProtocolAdapterManager protocolAdapterManager = + final ProtocolAdapterManager protocolAdapterManager = hiveMQServer.getInjector().protocolAdapterManager(); if (protocolAdapterManager != null) { protocolAdapterManager.shutdown(); @@ -226,7 +215,23 @@ private void performStop( } } + // Step 2: Stop HiveMQ, running all shutdown hooks (including BridgeService) hiveMQServer.stop(); + + // Step 3: NOW shut down executors after all shutdown hooks have completed + // This prevents race conditions where callbacks try to execute on terminated executors + if (hiveMQServer != null && hiveMQServer.getInjector() != null) { + try { + ExecutorsModule.shutdownExecutor(hiveMQServer.getInjector().executorService(), + ExecutorsModule.CACHED_WORKER_GROUP_NAME, + 10); + ExecutorsModule.shutdownExecutor(hiveMQServer.getInjector().scheduledExecutor(), + ExecutorsModule.SCHEDULED_WORKER_GROUP_NAME, + 5); + } catch (final Exception ex) { + log.warn("Exception during executor shutdown", ex); + } + } } catch (final Exception ex) { if (desiredState == State.CLOSED) { log.error("Exception during running shutdown hook.", ex); @@ -257,7 +262,8 @@ private void failFutureLists( } private void failFutureList( - final @NotNull Exception exception, final @NotNull List> futures) { + final @NotNull Exception exception, + final @NotNull List> futures) { for (final CompletableFuture future : futures) { future.completeExceptionally(exception); } @@ -307,6 +313,13 @@ private void succeedFutureList(final @NotNull List> futu return hiveMQServer != null ? hiveMQServer.getInjector() : null; } + private enum State { + RUNNING, + STOPPED, + FAILED, + CLOSED + } + private static class AbortedStateChangeException extends Exception { public AbortedStateChangeException(final @NotNull String message) { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 1da89752a8..8f5dafa878 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -72,7 +72,7 @@ @SuppressWarnings("unchecked") @Singleton public class ProtocolAdapterManager { - private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterManager.class); + private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterManager.class); private final @NotNull Map protocolAdapters; private final @NotNull MetricRegistry metricRegistry; @@ -90,7 +90,6 @@ public class ProtocolAdapterManager { private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; private final @NotNull ExecutorService executorService; private final @NotNull ExecutorService sharedAdapterExecutor; - private final @NotNull ScheduledExecutorService scheduledExecutor; private final @NotNull AtomicBoolean shutdownInitiated; @Inject @@ -108,8 +107,7 @@ public ProtocolAdapterManager( final @NotNull NorthboundConsumerFactory northboundConsumerFactory, final @NotNull TagManager tagManager, final @NotNull ProtocolAdapterExtractor protocolAdapterConfig, - final @NotNull ExecutorService sharedAdapterExecutor, - final @NotNull ScheduledExecutorService scheduledExecutor) { + final @NotNull ExecutorService sharedAdapterExecutor) { this.metricRegistry = metricRegistry; this.moduleServices = moduleServices; this.remoteService = remoteService; @@ -124,7 +122,6 @@ public ProtocolAdapterManager( this.tagManager = tagManager; this.protocolAdapterConfig = protocolAdapterConfig; this.sharedAdapterExecutor = sharedAdapterExecutor; - this.scheduledExecutor = scheduledExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); this.executorService = Executors.newSingleThreadExecutor(); this.shutdownInitiated = new AtomicBoolean(false); @@ -327,8 +324,9 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte final ProtocolAdapterMetricsService metricsService = new ProtocolAdapterMetricsServiceImpl(configProtocolId, config.getAdapterId(), metricRegistry); - final ProtocolAdapterStateImpl state = - new ProtocolAdapterStateImpl(moduleServices.eventService(), config.getAdapterId(), configProtocolId); + final ProtocolAdapterStateImpl state = new ProtocolAdapterStateImpl(moduleServices.eventService(), + config.getAdapterId(), + configProtocolId); final ModuleServicesPerModuleImpl perModule = new ModuleServicesPerModuleImpl(moduleServices.adapterPublishService(), @@ -534,20 +532,18 @@ public boolean writingEnabled() { } /** - * Performs a coordinated shutdown of all adapters and executors. - * This method ensures a clean shutdown sequence: - * 1. Stops all protocol adapters - * 2. Shuts down executor thread pools + * Initiates shutdown of the Protocol Adapter Manager. + *

+ * This method stops all running protocol adapters gracefully. + * Executor shutdown is handled separately by EmbeddedHiveMQ after all shutdown hooks complete, + * ensuring no race conditions occur between async operations and executor termination. *

* This method is idempotent - it can be called multiple times safely. - * It is automatically called via shutdown hook when the JVM exits, - * but can also be called explicitly (e.g., during test cleanup). */ public void shutdown() { if (shutdownInitiated.compareAndSet(false, true)) { - log.info("Initiating coordinated shutdown of Protocol Adapter Manager"); + log.info("Initiating shutdown of Protocol Adapter Manager"); stopAllAdaptersOnShutdown(); - shutdownExecutorsGracefully(); log.info("Protocol Adapter Manager shutdown completed"); } else { log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); @@ -556,8 +552,7 @@ public void shutdown() { /** * Stop all adapters during shutdown in a coordinated manner. - * This method is called by shutdown() BEFORE executors are shut down, - * ensuring adapters can complete their stop operations cleanly. + * Adapters are given time to complete their stop operations gracefully. */ private void stopAllAdaptersOnShutdown() { final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); @@ -589,11 +584,13 @@ private void stopAllAdaptersOnShutdown() { allStops.get(20, TimeUnit.SECONDS); log.info("All adapters stopped successfully during shutdown"); } catch (final TimeoutException e) { - log.warn("Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); + log.warn( + "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); // Log which adapters failed to stop for (int i = 0; i < stopFutures.size(); i++) { if (!stopFutures.get(i).isDone()) { - log.warn("Adapter '{}' did not complete stop operation within timeout", adaptersToStop.get(i).getId()); + log.warn("Adapter '{}' did not complete stop operation within timeout", + adaptersToStop.get(i).getId()); } } } catch (final InterruptedException e) { @@ -604,28 +601,4 @@ private void stopAllAdaptersOnShutdown() { } } - /** - * Shutdown order: - * 1. Protocol adapter manager's internal executor (used for refresh operations) - * 2. Shared adapter executor (used by all adapters for lifecycle operations) - * 3. Scheduled executor (used for scheduled tasks) - */ - private void shutdownExecutorsGracefully() { - log.info("Shutting down protocol adapter manager executors"); - - ExecutorsModule.shutdownExecutor( - executorService, - "protocol-adapter-manager-executor", - 5); - ExecutorsModule.shutdownExecutor( - sharedAdapterExecutor, - ExecutorsModule.CACHED_WORKER_GROUP_NAME, - 10); - ExecutorsModule.shutdownExecutor( - scheduledExecutor, - ExecutorsModule.SCHEDULED_WORKER_GROUP_NAME, - 5); - - log.info("Protocol adapter manager executors shutdown completed"); - } } From 81f7f338131602b4244ad4d65a16ac36a6dfc7d7 Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 22 Oct 2025 16:54:15 +0200 Subject: [PATCH 30/50] cosmetic changes --- .../java/com/hivemq/bridge/BridgeService.java | 1 - .../protocols/ProtocolAdapterManager.java | 102 +++++++----------- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java index 35225210f1..32d143e1c6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java +++ b/hivemq-edge/src/main/java/com/hivemq/bridge/BridgeService.java @@ -41,7 +41,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; import java.util.function.Function; import java.util.stream.Collectors; diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 8f5dafa878..8f5da17506 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -26,7 +26,6 @@ import com.hivemq.adapter.sdk.api.services.ProtocolAdapterMetricsService; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import com.hivemq.adapter.sdk.api.tag.Tag; -import com.hivemq.common.executors.ioc.ExecutorsModule; import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; import com.hivemq.configuration.reader.ProtocolAdapterExtractor; import com.hivemq.edge.HiveMQEdgeRemoteService; @@ -58,7 +57,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -414,7 +412,6 @@ private void fireEvent( @NotNull CompletableFuture stopAsync(final @NotNull ProtocolAdapterWrapper wrapper) { Preconditions.checkNotNull(wrapper); log.info("Stopping protocol-adapter '{}'.", wrapper.getId()); - return wrapper.stopAsync().whenComplete((result, throwable) -> { final Event.SEVERITY severity; final String message; @@ -531,74 +528,53 @@ public boolean writingEnabled() { .toList()); } - /** - * Initiates shutdown of the Protocol Adapter Manager. - *

- * This method stops all running protocol adapters gracefully. - * Executor shutdown is handled separately by EmbeddedHiveMQ after all shutdown hooks complete, - * ensuring no race conditions occur between async operations and executor termination. - *

- * This method is idempotent - it can be called multiple times safely. - */ public void shutdown() { if (shutdownInitiated.compareAndSet(false, true)) { log.info("Initiating shutdown of Protocol Adapter Manager"); - stopAllAdaptersOnShutdown(); - log.info("Protocol Adapter Manager shutdown completed"); - } else { - log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); - } - } - - /** - * Stop all adapters during shutdown in a coordinated manner. - * Adapters are given time to complete their stop operations gracefully. - */ - private void stopAllAdaptersOnShutdown() { - final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); - - if (adaptersToStop.isEmpty()) { - log.debug("No adapters to stop during shutdown"); - return; - } - - log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); - final List> stopFutures = new ArrayList<>(); - - // Initiate stop for all adapters - for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { - try { - log.debug("Initiating stop for adapter '{}'", wrapper.getId()); - final CompletableFuture stopFuture = wrapper.stopAsync(); - stopFutures.add(stopFuture); - } catch (final Exception e) { - log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); + final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); + if (adaptersToStop.isEmpty()) { + log.debug("No adapters to stop during shutdown"); + return; } - } - // Wait for all adapters to stop, with timeout - final CompletableFuture allStops = CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); + // Initiate stop for all adapters + log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); + final List> stopFutures = new ArrayList<>(); + for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { + try { + log.debug("Initiating stop for adapter '{}'", wrapper.getId()); + stopFutures.add(wrapper.stopAsync()); + } catch (final Exception e) { + log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); + } + } - try { - // Give adapters 20 seconds to stop gracefully - allStops.get(20, TimeUnit.SECONDS); - log.info("All adapters stopped successfully during shutdown"); - } catch (final TimeoutException e) { - log.warn( - "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); - // Log which adapters failed to stop - for (int i = 0; i < stopFutures.size(); i++) { - if (!stopFutures.get(i).isDone()) { - log.warn("Adapter '{}' did not complete stop operation within timeout", - adaptersToStop.get(i).getId()); + // Wait for all adapters to stop, with timeout + final CompletableFuture allStops = + CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); + try { + // Give adapters 20 seconds to stop gracefully + allStops.get(20, TimeUnit.SECONDS); + log.info("All adapters stopped successfully during shutdown"); + } catch (final TimeoutException e) { + log.warn( + "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); + // Log which adapters failed to stop + for (int i = 0; i < stopFutures.size(); i++) { + if (!stopFutures.get(i).isDone()) { + log.warn("Adapter '{}' did not complete stop operation within timeout", + adaptersToStop.get(i).getId()); + } } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while waiting for adapters to stop during shutdown", e); + } catch (final ExecutionException e) { + log.error("Error occurred while stopping adapters during shutdown", e.getCause()); } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Interrupted while waiting for adapters to stop during shutdown", e); - } catch (final ExecutionException e) { - log.error("Error occurred while stopping adapters during shutdown", e.getCause()); + log.info("Protocol Adapter Manager shutdown completed"); + } else { + log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); } } - } From 07f8a3d527cb713c7272c076bcf5b58a00e3eb28 Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 23 Oct 2025 10:04:03 +0200 Subject: [PATCH 31/50] fix shutdown sequence, improve code by simplifying --- .../impl/ProtocolAdapterApiUtils.java | 4 +- .../common/executors/ioc/ExecutorsModule.java | 11 +- .../hivemq/common/shutdown/ShutdownHooks.java | 3 - .../com/hivemq/fsm/ProtocolAdapterFSM.java | 4 +- .../domain/DomainTagAddResult.java | 5 +- .../protocols/ProtocolAdapterManager.java | 427 ++++++++-------- .../protocols/ProtocolAdapterWrapper.java | 461 ++++++------------ 7 files changed, 364 insertions(+), 551 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java index c56206b2a0..aca6df8e52 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdapterApiUtils.java @@ -96,13 +96,13 @@ public class ProtocolAdapterApiUtils { true, info.getCapabilities() .stream() - .filter(cap -> cap != ProtocolAdapterCapability.WRITE || adapterManager.writingEnabled()) + .filter(cap -> cap != ProtocolAdapterCapability.WRITE || adapterManager.isWritingEnabled()) .map(ProtocolAdapter.Capability::from) .collect(Collectors.toSet()), convertApiCategory(info.getCategory()), info.getTags() != null ? info.getTags().stream().map(Enum::toString).toList() : List.of(), new ProtocolAdapterSchemaManager(objectMapper, - adapterManager.writingEnabled() ? + adapterManager.isWritingEnabled() ? info.configurationClassNorthAndSouthbound() : info.configurationClassNorthbound()).generateSchemaNode(), getUiSchemaForAdapter(objectMapper, info)); diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index a483b32ec3..a185d4f562 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -26,6 +26,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @Module @@ -58,22 +59,18 @@ public static void shutdownExecutor( final @NotNull String name, final int timeoutSeconds) { log.debug("Shutting down executor service: {}", name); - if (!executor.isShutdown()) { executor.shutdown(); } - try { - if (!executor.awaitTermination(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)) { + if (!executor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); executor.shutdownNow(); - if (!executor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)) { + if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { log.error("Executor service {} still has running tasks after forced shutdown", name); } } else { - if (log.isDebugEnabled()) { - log.debug("Executor service {} shut down successfully", name); - } + log.debug("Executor service {} shut down successfully", name); } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/hivemq-edge/src/main/java/com/hivemq/common/shutdown/ShutdownHooks.java b/hivemq-edge/src/main/java/com/hivemq/common/shutdown/ShutdownHooks.java index 8ea920b18b..9c3829e625 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/shutdown/ShutdownHooks.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/shutdown/ShutdownHooks.java @@ -36,8 +36,6 @@ *

* If you add a shutdown hook, the shutdown hook is added to the registry. Please note that the * synchronous shutdown hook is not executed by itself when HiveMQ is shutting down. - * - * @author Dominik Obermaier */ public class ShutdownHooks { @@ -48,7 +46,6 @@ public class ShutdownHooks { public ShutdownHooks() { shuttingDown = new AtomicBoolean(false); - synchronousHooks = MultimapBuilder.SortedSetMultimapBuilder .treeKeys(Ordering.natural().reverse()) //High priorities first .arrayListValues() diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 9ca4b8dc5c..73a76d5c55 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -71,10 +71,10 @@ public enum AdapterStateEnum { private final @NotNull AtomicReference northboundState; private final @NotNull AtomicReference southboundState; - private final @NotNull AtomicReference adapterState; + protected final @NotNull AtomicReference adapterState; private final @NotNull List> stateTransitionListeners; - public record State(AdapterStateEnum state, StateEnum northbound, StateEnum southbound) { } + public record State(AdapterStateEnum adapter, StateEnum northbound, StateEnum southbound) { } private final @NotNull String adapterId; diff --git a/hivemq-edge/src/main/java/com/hivemq/persistence/domain/DomainTagAddResult.java b/hivemq-edge/src/main/java/com/hivemq/persistence/domain/DomainTagAddResult.java index 8a0b10e334..d088bf877b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/persistence/domain/DomainTagAddResult.java +++ b/hivemq-edge/src/main/java/com/hivemq/persistence/domain/DomainTagAddResult.java @@ -65,8 +65,7 @@ public DomainTagAddResult( public enum DomainTagPutStatus { SUCCESS(), ALREADY_EXISTS(), - ADAPTER_MISSING() + ADAPTER_MISSING(), + ADAPTER_FAILED_TO_START(), } - - } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 8f5da17506..38a8584999 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -25,7 +25,8 @@ import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; import com.hivemq.adapter.sdk.api.services.ProtocolAdapterMetricsService; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; -import com.hivemq.adapter.sdk.api.tag.Tag; +import com.hivemq.common.shutdown.HiveMQShutdownHook; +import com.hivemq.common.shutdown.ShutdownHooks; import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; import com.hivemq.configuration.reader.ProtocolAdapterExtractor; import com.hivemq.edge.HiveMQEdgeRemoteService; @@ -53,10 +54,8 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -64,8 +63,11 @@ import java.util.function.Supplier; import java.util.stream.Collectors; +import static com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor; +import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_FAILED_TO_START; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_MISSING; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ALREADY_EXISTS; +import static java.util.concurrent.CompletableFuture.failedFuture; @SuppressWarnings("unchecked") @Singleton @@ -79,14 +81,14 @@ public class ProtocolAdapterManager { private final @NotNull EventService eventService; private final @NotNull ProtocolAdapterConfigConverter configConverter; private final @NotNull VersionProvider versionProvider; - private final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService; + private final @NotNull ProtocolAdapterPollingService pollingService; private final @NotNull ProtocolAdapterMetrics protocolAdapterMetrics; - private final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService; - private final @NotNull ProtocolAdapterFactoryManager protocolAdapterFactoryManager; + private final @NotNull InternalProtocolAdapterWritingService writingService; + private final @NotNull ProtocolAdapterFactoryManager factoryManager; private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; private final @NotNull TagManager tagManager; - private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; - private final @NotNull ExecutorService executorService; + private final @NotNull ProtocolAdapterExtractor config; + private final @NotNull ExecutorService singleThreadRefreshExecutor; private final @NotNull ExecutorService sharedAdapterExecutor; private final @NotNull AtomicBoolean shutdownInitiated; @@ -98,61 +100,56 @@ public ProtocolAdapterManager( final @NotNull EventService eventService, final @NotNull ProtocolAdapterConfigConverter configConverter, final @NotNull VersionProvider versionProvider, - final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService, + final @NotNull ProtocolAdapterPollingService pollingService, final @NotNull ProtocolAdapterMetrics protocolAdapterMetrics, - final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService, - final @NotNull ProtocolAdapterFactoryManager protocolAdapterFactoryManager, + final @NotNull InternalProtocolAdapterWritingService writingService, + final @NotNull ProtocolAdapterFactoryManager factoryManager, final @NotNull NorthboundConsumerFactory northboundConsumerFactory, final @NotNull TagManager tagManager, - final @NotNull ProtocolAdapterExtractor protocolAdapterConfig, - final @NotNull ExecutorService sharedAdapterExecutor) { + final @NotNull ProtocolAdapterExtractor config, + final @NotNull ExecutorService sharedAdapterExecutor, + final @NotNull ShutdownHooks shutdownHooks) { this.metricRegistry = metricRegistry; this.moduleServices = moduleServices; this.remoteService = remoteService; this.eventService = eventService; this.configConverter = configConverter; this.versionProvider = versionProvider; - this.protocolAdapterPollingService = protocolAdapterPollingService; + this.pollingService = pollingService; this.protocolAdapterMetrics = protocolAdapterMetrics; - this.protocolAdapterWritingService = protocolAdapterWritingService; - this.protocolAdapterFactoryManager = protocolAdapterFactoryManager; + this.writingService = writingService; + this.factoryManager = factoryManager; this.northboundConsumerFactory = northboundConsumerFactory; this.tagManager = tagManager; - this.protocolAdapterConfig = protocolAdapterConfig; + this.config = config; this.sharedAdapterExecutor = sharedAdapterExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); - this.executorService = Executors.newSingleThreadExecutor(); + this.singleThreadRefreshExecutor = Executors.newSingleThreadExecutor(); this.shutdownInitiated = new AtomicBoolean(false); + shutdownHooks.add(new HiveMQShutdownHook() { + @Override + public @NotNull String name() { + return "protocol-adapter-manager-shutdown"; + } - // Register coordinated shutdown hook that stops adapters BEFORE executors shutdown - Runtime.getRuntime().addShutdownHook(new Thread(this::shutdown, "protocol-adapter-manager-shutdown")); - - protocolAdapterWritingService.addWritingChangedCallback(() -> protocolAdapterFactoryManager.writingEnabledChanged( - protocolAdapterWritingService.writingEnabled())); - } - - // API, must be threadsafe - - private static void syncFuture(final @NotNull Future future) { - try { - future.get(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Interrupted while async execution: ", e.getCause()); - } catch (final ExecutionException e) { - log.error("Exception happened while async execution: ", e.getCause()); - } + @Override + public void run() { + shutdown(); + } + }); + this.writingService.addWritingChangedCallback(() -> factoryManager.writingEnabledChanged(writingService.writingEnabled())); } public static @NotNull T runWithContextLoader( - final @NotNull ClassLoader contextLoader, - final @NotNull Supplier wrapperSupplier) { - final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + final @NotNull ClassLoader ctxLoader, + final @NotNull Supplier snippet) { + final Thread t = Thread.currentThread(); + final ClassLoader currentCtx = t.getContextClassLoader(); try { - Thread.currentThread().setContextClassLoader(contextLoader); - return wrapperSupplier.get(); + t.setContextClassLoader(ctxLoader); + return snippet.get(); } finally { - Thread.currentThread().setContextClassLoader(contextClassLoader); + t.setContextClassLoader(currentCtx); } } @@ -160,11 +157,11 @@ public void start() { if (log.isDebugEnabled()) { log.debug("Starting adapters"); } - protocolAdapterConfig.registerConsumer(this::refresh); + config.registerConsumer(this::refresh); } public void refresh(final @NotNull List configs) { - executorService.submit(() -> { + singleThreadRefreshExecutor.submit(() -> { log.info("Refreshing adapters"); final Map protocolAdapterConfigs = configs.stream() @@ -175,16 +172,13 @@ public void refresh(final @NotNull List configs) { final List adaptersToBeDeleted = new ArrayList<>(protocolAdapters.keySet()); adaptersToBeDeleted.removeAll(loadListOfAdapterNames); - final List adaptersToBeCreated = new ArrayList<>(loadListOfAdapterNames); adaptersToBeCreated.removeAll(protocolAdapters.keySet()); - final List adaptersToBeUpdated = new ArrayList<>(protocolAdapters.keySet()); adaptersToBeUpdated.removeAll(adaptersToBeCreated); adaptersToBeUpdated.removeAll(adaptersToBeDeleted); final List failedAdapters = new ArrayList<>(); - adaptersToBeDeleted.forEach(name -> { try { if (log.isDebugEnabled()) { @@ -195,12 +189,11 @@ public void refresh(final @NotNull List configs) { Thread.currentThread().interrupt(); failedAdapters.add(name); log.error("Interrupted while deleting adapter {}", name, e); - } catch (final ExecutionException e) { + } catch (final Throwable e) { failedAdapters.add(name); log.error("Failed deleting adapter {}", name, e); } }); - adaptersToBeCreated.forEach(name -> { try { if (log.isDebugEnabled()) { @@ -212,12 +205,11 @@ public void refresh(final @NotNull List configs) { Thread.currentThread().interrupt(); failedAdapters.add(name); log.error("Interrupted while adding adapter {}", name, e); - } catch (final ExecutionException e) { + } catch (final Throwable e) { failedAdapters.add(name); log.error("Failed adding adapter {}", name, e); } }); - adaptersToBeUpdated.forEach(name -> { try { final var wrapper = protocolAdapters.get(name); @@ -246,7 +238,7 @@ public void refresh(final @NotNull List configs) { Thread.currentThread().interrupt(); failedAdapters.add(name); log.error("Interrupted while updating adapter {}", name, e); - } catch (final ExecutionException e) { + } catch (final Throwable e) { failedAdapters.add(name); log.error("Failed updating adapter {}", name, e); } @@ -266,34 +258,166 @@ public void refresh(final @NotNull List configs) { }); } - //INTERNAL - public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapterType) { Preconditions.checkNotNull(protocolAdapterType); - return protocolAdapterFactoryManager.get(protocolAdapterType).isPresent(); + return factoryManager.get(protocolAdapterType).isPresent(); + } + + public @NotNull CompletableFuture startAsync(final @NotNull String adapterId) { + Preconditions.checkNotNull(adapterId); + return getProtocolAdapterWrapperByAdapterId(adapterId).map(this::startAsync) + .orElseGet(() -> failedFuture(new ProtocolAdapterException("Adapter '" + adapterId + "'not found."))); + } + + public @NotNull CompletableFuture stopAsync(final @NotNull String adapterId) { + Preconditions.checkNotNull(adapterId); + return getProtocolAdapterWrapperByAdapterId(adapterId).map(this::stopAsync) + .orElseGet(() -> failedFuture(new ProtocolAdapterException("Adapter '" + adapterId + "'not found."))); + } + + public @NotNull Optional getProtocolAdapterWrapperByAdapterId(final @NotNull String adapterId) { + Preconditions.checkNotNull(adapterId); + return Optional.ofNullable(protocolAdapters.get(adapterId)); + } + + public @NotNull Optional getAdapterTypeById(final @NotNull String typeId) { + Preconditions.checkNotNull(typeId); + return Optional.ofNullable(getAllAvailableAdapterTypes().get(typeId)); + } + + public @NotNull Map getAllAvailableAdapterTypes() { + return factoryManager.getAllAvailableAdapterTypes(); + } + + public @NotNull Map getProtocolAdapters() { + return Map.copyOf(protocolAdapters); } - public @NotNull CompletableFuture startAsync(final @NotNull String protocolAdapterId) { - Preconditions.checkNotNull(protocolAdapterId); - return getProtocolAdapterWrapperByAdapterId(protocolAdapterId).map(this::startAsync) - .orElseGet(() -> CompletableFuture.failedFuture(new ProtocolAdapterException("Adapter '" + - protocolAdapterId + - "'not found."))); + public boolean isWritingEnabled() { + return writingService.writingEnabled(); } - public @NotNull CompletableFuture stopAsync(final @NotNull String protocolAdapterId) { - Preconditions.checkNotNull(protocolAdapterId); - return getProtocolAdapterWrapperByAdapterId(protocolAdapterId).map(this::stopAsync) - .orElseGet(() -> CompletableFuture.failedFuture(new ProtocolAdapterException("Adapter '" + - protocolAdapterId + - "'not found."))); + public @NotNull DomainTagAddResult addDomainTag( + final @NotNull String adapterId, + final @NotNull DomainTag domainTag) { + return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { + final var tags = new ArrayList<>(wrapper.getTags()); + final boolean alreadyExists = tags.stream().anyMatch(t -> t.getName().equals(domainTag.getTagName())); + if (alreadyExists) { + return DomainTagAddResult.failed(ALREADY_EXISTS, adapterId); + } + deleteAdapterInternal(wrapper.getId()); + try { + tags.add(configConverter.domainTagToTag(wrapper.getProtocolAdapterInformation().getProtocolId(), + domainTag)); + startAsync(createAdapterInternal(new ProtocolAdapterConfig(wrapper.getId(), + wrapper.getAdapterInformation().getProtocolId(), + wrapper.getAdapterInformation().getCurrentConfigVersion(), + wrapper.getConfigObject(), + wrapper.getSouthboundMappings(), + wrapper.getNorthboundMappings(), + tags), versionProvider.getVersion())).get(); + return DomainTagAddResult.success(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while async execution: ", e.getCause()); + } catch (final Throwable e) { + log.error("Exception happened while async execution: ", e.getCause()); + } + return DomainTagAddResult.failed(ADAPTER_FAILED_TO_START, adapterId); + }).orElse(DomainTagAddResult.failed(ADAPTER_MISSING, adapterId)); + } + + public @NotNull List getDomainTags() { + return protocolAdapters.values() + .stream() + .flatMap(wrapper -> wrapper.getTags() + .stream() + .map(tag -> new DomainTag(tag.getName(), + wrapper.getId(), + tag.getDescription(), + configConverter.convertTagDefinitionToJsonNode(tag.getDefinition())))) + .toList(); + } + + public @NotNull Optional getDomainTagByName(final @NotNull String tagName) { + return protocolAdapters.values() + .stream() + .flatMap(wrapper -> wrapper.getTags() + .stream() + .filter(t -> t.getName().equals(tagName)) + .map(tag -> new DomainTag(tag.getName(), + wrapper.getId(), + tag.getDescription(), + configConverter.convertTagDefinitionToJsonNode(tag.getDefinition())))) + .findFirst(); + } + + public @NotNull Optional> getTagsForAdapter(final @NotNull String adapterId) { + return getProtocolAdapterWrapperByAdapterId(adapterId).map(adapterToConfig -> adapterToConfig.getTags() + .stream() + .map(tag -> new DomainTag(tag.getName(), + adapterToConfig.getId(), + tag.getDescription(), + configConverter.convertTagDefinitionToJsonNode(tag.getDefinition()))) + .toList()); + } + + public void shutdown() { + if (shutdownInitiated.compareAndSet(false, true)) { + shutdownExecutor(singleThreadRefreshExecutor, "protocol-adapter-manager-refresh", 5); + + log.info("Initiating shutdown of Protocol Adapter Manager"); + final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); + if (adaptersToStop.isEmpty()) { + log.debug("No adapters to stop during shutdown"); + return; + } + + // Initiate stop for all adapters + log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); + final List> stopFutures = new ArrayList<>(); + for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { + try { + log.debug("Initiating stop for adapter '{}'", wrapper.getId()); + stopFutures.add(wrapper.stopAsync()); + } catch (final Exception e) { + log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); + } + } + + // Wait for all adapters to stop, with timeout + final CompletableFuture allStops = + CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); + try { + allStops.get(20, TimeUnit.SECONDS); + log.info("All adapters stopped successfully during shutdown"); + } catch (final TimeoutException e) { + log.warn( + "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); + // Log which adapters failed to stop + for (int i = 0; i < stopFutures.size(); i++) { + if (!stopFutures.get(i).isDone()) { + log.warn("Adapter '{}' did not complete stop operation within timeout", + adaptersToStop.get(i).getId()); + } + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Interrupted while waiting for adapters to stop during shutdown", e); + } catch (final Throwable e) { + log.error("Error occurred while stopping adapters during shutdown", e.getCause()); + } + log.info("Protocol Adapter Manager shutdown completed"); + } else { + log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); + } } private @NotNull ProtocolAdapterWrapper createAdapterInternal( final @NotNull ProtocolAdapterConfig config, final @NotNull String version) { return protocolAdapters.computeIfAbsent(config.getAdapterId(), ignored -> { - final String configProtocolId = config.getProtocolId(); // legacy handling, hardcoded here, to not add legacy stuff into the adapter-sdk final String adapterType = switch (configProtocolId) { @@ -303,12 +427,11 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte default -> configProtocolId; }; - final Optional> maybeFactory = protocolAdapterFactoryManager.get(adapterType); + final Optional> maybeFactory = factoryManager.get(adapterType); if (maybeFactory.isEmpty()) { throw new IllegalArgumentException("Protocol adapter for config " + adapterType + " not found."); } final ProtocolAdapterFactory factory = maybeFactory.get(); - log.info("Found configuration for adapter {} / {}", config.getAdapterId(), adapterType); config.missingTags().ifPresent(missing -> { throw new IllegalArgumentException("Tags used in mappings but not configured in adapter " + @@ -318,20 +441,16 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte }); return runWithContextLoader(factory.getClass().getClassLoader(), () -> { - final ProtocolAdapterMetricsService metricsService = new ProtocolAdapterMetricsServiceImpl(configProtocolId, config.getAdapterId(), metricRegistry); - final ProtocolAdapterStateImpl state = new ProtocolAdapterStateImpl(moduleServices.eventService(), config.getAdapterId(), configProtocolId); - final ModuleServicesPerModuleImpl perModule = new ModuleServicesPerModuleImpl(moduleServices.adapterPublishService(), eventService, - protocolAdapterWritingService, + writingService, tagManager); - final ProtocolAdapter protocolAdapter = factory.createAdapter(factory.getInformation(), new ProtocolAdapterInputImpl(configProtocolId, config.getAdapterId(), @@ -344,11 +463,10 @@ public boolean protocolAdapterFactoryExists(final @NotNull String protocolAdapte metricsService)); // hen-egg problem. Rather solve this here as have not final fields in the adapter. perModule.setAdapter(protocolAdapter); - protocolAdapterMetrics.increaseProtocolAdapterMetric(configProtocolId); return new ProtocolAdapterWrapper(metricsService, - protocolAdapterWritingService, - protocolAdapterPollingService, + writingService, + pollingService, config, protocolAdapter, factory, @@ -382,13 +500,13 @@ private void deleteAdapterInternal(final @NotNull String adapterId) { return wrapper.startAsync(moduleServices).whenComplete((result, throwable) -> { if (throwable == null) { log.info("Protocol-adapter '{}' started successfully.", wid); - fireEvent(wrapper, + fireStartEvent(wrapper, HiveMQEdgeRemoteEvent.EVENT_TYPE.ADAPTER_STARTED, Event.SEVERITY.INFO, "Adapter '" + wid + "' started OK."); } else { log.warn("Protocol-adapter '{}' could not be started, reason: {}", wid, "unknown"); - fireEvent(wrapper, + fireStartEvent(wrapper, HiveMQEdgeRemoteEvent.EVENT_TYPE.ADAPTER_ERROR, Event.SEVERITY.CRITICAL, "Error starting adapter '" + wid + "'."); @@ -396,7 +514,7 @@ private void deleteAdapterInternal(final @NotNull String adapterId) { }); } - private void fireEvent( + private void fireStartEvent( final @NotNull ProtocolAdapterWrapper wrapper, final @NotNull HiveMQEdgeRemoteEvent.EVENT_TYPE eventType, final @NotNull Event.SEVERITY severity, @@ -430,151 +548,4 @@ private void fireEvent( eventService.createAdapterEvent(wid, protocolId).withSeverity(severity).withMessage(message).fire(); }); } - - private void updateAdapter(final ProtocolAdapterConfig protocolAdapterConfig) { - deleteAdapterInternal(protocolAdapterConfig.getAdapterId()); - syncFuture(startAsync(createAdapterInternal(protocolAdapterConfig, versionProvider.getVersion()))); - } - - private boolean updateAdapterTags(final @NotNull String adapterId, final @NotNull List tags) { - Preconditions.checkNotNull(adapterId); - return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { - final var protocolId = wrapper.getAdapterInformation().getProtocolId(); - final var protocolAdapterConfig = new ProtocolAdapterConfig(wrapper.getId(), - protocolId, - wrapper.getAdapterInformation().getCurrentConfigVersion(), - wrapper.getConfigObject(), - wrapper.getSouthboundMappings(), - wrapper.getNorthboundMappings(), - tags); - updateAdapter(protocolAdapterConfig); - return true; - }).orElse(false); - } - - public @NotNull Optional getProtocolAdapterWrapperByAdapterId(final @NotNull String id) { - Preconditions.checkNotNull(id); - return Optional.ofNullable(protocolAdapters.get(id)); - } - - public @NotNull Optional getAdapterTypeById(final @NotNull String typeId) { - Preconditions.checkNotNull(typeId); - final ProtocolAdapterInformation information = getAllAvailableAdapterTypes().get(typeId); - return Optional.ofNullable(information); - } - - public @NotNull Map getAllAvailableAdapterTypes() { - return protocolAdapterFactoryManager.getAllAvailableAdapterTypes(); - } - - public @NotNull Map getProtocolAdapters() { - return Map.copyOf(protocolAdapters); - } - - public boolean writingEnabled() { - return protocolAdapterWritingService.writingEnabled(); - } - - public @NotNull DomainTagAddResult addDomainTag( - final @NotNull String adapterId, - final @NotNull DomainTag domainTag) { - return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { - final var tags = new ArrayList<>(wrapper.getTags()); - final boolean alreadyExists = tags.stream().anyMatch(t -> t.getName().equals(domainTag.getTagName())); - if (!alreadyExists) { - tags.add(configConverter.domainTagToTag(wrapper.getProtocolAdapterInformation().getProtocolId(), - domainTag)); - - updateAdapterTags(adapterId, tags); - return DomainTagAddResult.success(); - } else { - return DomainTagAddResult.failed(ALREADY_EXISTS, adapterId); - } - }).orElse(DomainTagAddResult.failed(ADAPTER_MISSING, adapterId)); - } - - public @NotNull List getDomainTags() { - return protocolAdapters.values() - .stream() - .flatMap(wrapper -> wrapper.getTags() - .stream() - .map(tag -> new DomainTag(tag.getName(), - wrapper.getId(), - tag.getDescription(), - configConverter.convertTagDefinitionToJsonNode(tag.getDefinition())))) - .toList(); - } - - public @NotNull Optional getDomainTagByName(final @NotNull String tagName) { - return protocolAdapters.values() - .stream() - .flatMap(wrapper -> wrapper.getTags() - .stream() - .filter(t -> t.getName().equals(tagName)) - .map(tag -> new DomainTag(tag.getName(), - wrapper.getId(), - tag.getDescription(), - configConverter.convertTagDefinitionToJsonNode(tag.getDefinition())))) - .findFirst(); - } - - public @NotNull Optional> getTagsForAdapter(final @NotNull String adapterId) { - return getProtocolAdapterWrapperByAdapterId(adapterId).map(adapterToConfig -> adapterToConfig.getTags() - .stream() - .map(tag -> new DomainTag(tag.getName(), - adapterToConfig.getId(), - tag.getDescription(), - configConverter.convertTagDefinitionToJsonNode(tag.getDefinition()))) - .toList()); - } - - public void shutdown() { - if (shutdownInitiated.compareAndSet(false, true)) { - log.info("Initiating shutdown of Protocol Adapter Manager"); - final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); - if (adaptersToStop.isEmpty()) { - log.debug("No adapters to stop during shutdown"); - return; - } - - // Initiate stop for all adapters - log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); - final List> stopFutures = new ArrayList<>(); - for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { - try { - log.debug("Initiating stop for adapter '{}'", wrapper.getId()); - stopFutures.add(wrapper.stopAsync()); - } catch (final Exception e) { - log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); - } - } - - // Wait for all adapters to stop, with timeout - final CompletableFuture allStops = - CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); - try { - // Give adapters 20 seconds to stop gracefully - allStops.get(20, TimeUnit.SECONDS); - log.info("All adapters stopped successfully during shutdown"); - } catch (final TimeoutException e) { - log.warn( - "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); - // Log which adapters failed to stop - for (int i = 0; i < stopFutures.size(); i++) { - if (!stopFutures.get(i).isDone()) { - log.warn("Adapter '{}' did not complete stop operation within timeout", - adaptersToStop.get(i).getId()); - } - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Interrupted while waiting for adapters to stop during shutdown", e); - } catch (final ExecutionException e) { - log.error("Error occurred while stopping adapters during shutdown", e.getCause()); - } - log.info("Protocol Adapter Manager shutdown completed"); - } else { - log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); - } - } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 7322390b1f..bce5fdbe62 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -22,8 +22,6 @@ import com.hivemq.adapter.sdk.api.discovery.ProtocolAdapterDiscoveryInput; import com.hivemq.adapter.sdk.api.discovery.ProtocolAdapterDiscoveryOutput; import com.hivemq.adapter.sdk.api.events.EventService; -import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopInput; -import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopOutput; import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; import com.hivemq.adapter.sdk.api.polling.PollingProtocolAdapter; import com.hivemq.adapter.sdk.api.polling.batch.BatchPollingProtocolAdapter; @@ -57,20 +55,25 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private static final Logger log = LoggerFactory.getLogger(ProtocolAdapterWrapper.class); private static final long STOP_TIMEOUT_SECONDS = 30; + private static final @NotNull Consumer CONNECTION_STATUS_NOOP_CONSUMER = + status -> { + // Noop - adapter is stopping/stopped + }; private final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService; private final @NotNull ProtocolAdapter adapter; private final @NotNull ProtocolAdapterFactory adapterFactory; private final @NotNull ProtocolAdapterInformation adapterInformation; private final @NotNull ProtocolAdapterStateImpl protocolAdapterState; - private final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService; - private final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService; + private final @NotNull InternalProtocolAdapterWritingService writingService; + private final @NotNull ProtocolAdapterPollingService pollingService; private final @NotNull ProtocolAdapterConfig config; private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; private final @NotNull TagManager tagManager; @@ -84,8 +87,8 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService, - final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService, - final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService, + final @NotNull InternalProtocolAdapterWritingService writingService, + final @NotNull ProtocolAdapterPollingService pollingService, final @NotNull ProtocolAdapterConfig config, final @NotNull ProtocolAdapter adapter, final @NotNull ProtocolAdapterFactory adapterFactory, @@ -95,8 +98,8 @@ public ProtocolAdapterWrapper( final @NotNull TagManager tagManager, final @NotNull ExecutorService sharedAdapterExecutor) { super(config.getAdapterId()); - this.protocolAdapterWritingService = protocolAdapterWritingService; - this.protocolAdapterPollingService = protocolAdapterPollingService; + this.writingService = writingService; + this.pollingService = pollingService; this.protocolAdapterMetricsService = protocolAdapterMetricsService; this.adapter = adapter; this.adapterFactory = adapterFactory; @@ -111,9 +114,9 @@ public ProtocolAdapterWrapper( if (log.isDebugEnabled()) { registerStateTransitionListener(state -> log.debug( - "Adapter {} FSM state transition: adapter={}, northbound={}, southbound={}", + "Adapter {} FSM adapter transition: adapter={}, northbound={}, southbound={}", adapter.getId(), - state.state(), + state.adapter(), state.northbound(), state.southbound())); } @@ -141,8 +144,13 @@ public boolean startSouthbound() { transitionSouthboundState(StateEnum.NOT_SUPPORTED); return true; } - - final boolean started = startWriting(protocolAdapterWritingService); + log.debug("Start writing for protocol adapter with id '{}'", getId()); + final boolean started = writingService.startWriting((WritingProtocolAdapter) adapter, + protocolAdapterMetricsService, + config.getSouthboundMappings() + .stream() + .map(InternalWritingContextImpl::new) + .collect(Collectors.toList())); if (started) { log.info("Southbound started for adapter {}", adapter.getId()); transitionSouthboundState(StateEnum.CONNECTED); @@ -153,267 +161,95 @@ public boolean startSouthbound() { return started; } - public @NotNull CompletableFuture startAsync( - final @NotNull ModuleServices moduleServices) { - - // Use lock to ensure exclusive access during state checks and future creation - // This prevents race conditions between concurrent start/stop calls + public @NotNull CompletableFuture startAsync(final @NotNull ModuleServices moduleServices) { operationLock.lock(); try { - // 1. Check if start already in progress - return existing future + // validate state if (currentStartFuture != null && !currentStartFuture.isDone()) { log.info("Start operation already in progress for adapter '{}'", getId()); return currentStartFuture; } - - // 2. Check if adapter is already started - idempotent operation - final var currentState = currentState(); - if (currentState.state() == AdapterStateEnum.STARTED) { + if (adapterState.get() == AdapterStateEnum.STARTED) { log.info("Adapter '{}' is already started, returning success", getId()); return CompletableFuture.completedFuture(null); } - - // 3. Check if stop operation is in progress - prevent overlap if (currentStopFuture != null && !currentStopFuture.isDone()) { log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + - getId() + + adapter.getId() + "' while stop operation is in progress")); } - // 4. All checks passed - record start attempt time - initStartAttempt(); - - // 5. Create and execute the start operation - final var output = new ProtocolAdapterStartOutputImpl(); - final var input = new ProtocolAdapterStartInputImpl(moduleServices); - - final CompletableFuture startFuture = CompletableFuture.supplyAsync(() -> { - // Signal FSM to start (calls onStarting() internally) - startAdapter(); - - try { - adapter.start(input, output); - } catch (final Throwable throwable) { - output.getStartFuture().completeExceptionally(throwable); - } - return output.getStartFuture(); - }, sharedAdapterExecutor) // Use shared executor to reduce thread overhead - .thenCompose(Function.identity()).whenComplete((ignored, error) -> { - if (error != null) { - log.error("Error starting adapter", error); - stopAfterFailedStart(); - } else { - attemptStartingConsumers(moduleServices.eventService()).ifPresent(startException -> { + lastStartAttemptTime = System.currentTimeMillis(); + currentStartFuture = + CompletableFuture.supplyAsync(startProtocolAdapter(moduleServices), sharedAdapterExecutor) + .thenCompose(Function.identity()) + .thenRun(() -> startConsumers(moduleServices.eventService()).ifPresent(startException -> { log.error("Failed to start adapter with id {}", adapter.getId(), startException); - stopAfterFailedStart(); - // Propagate the exception - this will fail the future + stopProtocolAdapterOnFailedStart(); throw new RuntimeException("Failed to start consumers", startException); + })) + .whenComplete((result, throwable) -> { + if (throwable != null) { + log.error("Error starting adapter", throwable); + stopProtocolAdapterOnFailedStart(); + } + operationLock.lock(); + try { + currentStartFuture = null; + } finally { + operationLock.unlock(); + } }); - } - }).whenComplete((result, throwable) -> { - // Clear the current operation when complete - operationLock.lock(); - try { - currentStartFuture = null; - } finally { - operationLock.unlock(); - } - }); - - // 6. Store the future before returning - ensures visibility to other threads - currentStartFuture = startFuture; - return startFuture; - + return currentStartFuture; } finally { operationLock.unlock(); } } - private void stopAfterFailedStart() { - log.warn("Stopping adapter with id {} after a failed start", adapter.getId()); - final var stopInput = new ProtocolAdapterStopInputImpl(); - final var stopOutput = new ProtocolAdapterStopOutputImpl(); - - // Transition FSM state back to STOPPED - stopAdapter(); - - // Clean up listeners to prevent memory leaks - cleanupConnectionStatusListener(); - - stopPolling(protocolAdapterPollingService); - stopWriting(protocolAdapterWritingService); - - try { - adapter.stop(stopInput, stopOutput); - } catch (final Throwable throwable) { - log.error("Stopping adapter after a start error failed", throwable); - } - - // Wait for stop to complete, but with a timeout to prevent indefinite blocking - try { - stopOutput.getOutputFuture().get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (final TimeoutException e) { - log.error("Timeout waiting for adapter {} to stop after failed start", adapter.getId()); - } catch (final Throwable throwable) { - log.error("Stopping adapter after a start error failed", throwable); - } - - // Always destroy to clean up resources after failed start - try { - log.info("Destroying adapter with id '{}' after failed start to release all resources", getId()); - adapter.destroy(); - } catch (final Exception destroyException) { - log.error("Error destroying adapter with id {} after failed start", adapter.getId(), destroyException); - } - } - - private @NotNull Optional attemptStartingConsumers( - final @NotNull EventService eventService) { - try { - // Adapter started successfully, now start the consumers - createAndSubscribeTagConsumer(); - startPolling(protocolAdapterPollingService, eventService); - - // Create and register connection status listener - // FSM's accept() method handles: - // 1. Transitioning northbound state - // 2. Triggering startSouthbound() when CONNECTED (only for writing adapters) - connectionStatusListener = status -> { - accept(status); - // For non-writing adapters that are only polling, southbound is not applicable - // but we still need to track northbound connection status - }; - protocolAdapterState.setConnectionStatusListener(connectionStatusListener); - - } catch (final Throwable e) { - log.error("Protocol adapter start failed", e); - return Optional.of(e); - } - return Optional.empty(); - } - - /** - * Cleanup the connection status listener to prevent memory leaks. - * Should be called during stop operations. - */ - private void cleanupConnectionStatusListener() { - if (connectionStatusListener != null) { - // Replace with no-op listener instead of null (API doesn't accept null) - protocolAdapterState.setConnectionStatusListener(status -> { - // No-op - adapter is stopping/stopped - }); - connectionStatusListener = null; - } - } - public @NotNull CompletableFuture stopAsync() { - - // Use lock to ensure exclusive access during state checks and future creation - // This prevents race conditions between concurrent start/stop calls operationLock.lock(); try { - // 1. Check if stop already in progress - return existing future + // validate state if (currentStopFuture != null && !currentStopFuture.isDone()) { log.info("Stop operation already in progress for adapter '{}'", getId()); return currentStopFuture; } - - // 2. Check if adapter is already stopped - idempotent operation final var currentState = currentState(); - if (currentState.state() == AdapterStateEnum.STOPPED) { + if (currentState.adapter() == AdapterStateEnum.STOPPED) { log.info("Adapter '{}' is already stopped, returning success", getId()); return CompletableFuture.completedFuture(null); } - - // 3. Check if start operation is in progress - prevent overlap if (currentStartFuture != null && !currentStartFuture.isDone()) { log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + - getId() + + adapter.getId() + "' while start operation is in progress")); } - // 4. Create and execute the stop operation - final var input = new ProtocolAdapterStopInputImpl(); - final var output = new ProtocolAdapterStopOutputImpl(); - log.debug("Adapter '{}': Creating stop operation future", getId()); - - // Defensive check: if executor is shutdown, execute stop synchronously - // This can happen during JVM shutdown if there's a race between shutdown hooks - if (sharedAdapterExecutor.isShutdown()) { - log.warn("Adapter '{}': Executor is shutdown, executing stop operation synchronously in current thread", getId()); - try { - // Execute stop logic directly in calling thread - final CompletableFuture syncFuture = performStopOperation(input, output) - .whenComplete((result, throwable) -> { - log.debug("Adapter '{}': Synchronous stop operation completed, starting cleanup", getId()); - - // Always call destroy() to ensure all resources are properly released - try { - log.info("Destroying adapter with id '{}' to release all resources", getId()); - adapter.destroy(); - log.debug("Adapter '{}': destroy() completed successfully", getId()); - } catch (final Exception destroyException) { - log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); - } - - if (throwable == null) { - log.info("Stopped adapter with id '{}' successfully", adapter.getId()); - } else { - log.error("Error stopping adapter with id {}", adapter.getId(), throwable); - } - - // Clear reference to stop future - log.debug("Adapter '{}': Cleared currentStopFuture reference", getId()); - currentStopFuture = null; - }); - - currentStopFuture = syncFuture; - return syncFuture; - } catch (final Exception e) { - log.error("Adapter '{}': Exception during synchronous stop", getId(), e); - return CompletableFuture.failedFuture(e); - } - } - - final var stopFuture = CompletableFuture.supplyAsync(() -> performStopOperation(input, output), - sharedAdapterExecutor) // Use shared executor to reduce thread overhead + currentStopFuture = CompletableFuture.supplyAsync(this::stopProtocolAdapter, sharedAdapterExecutor) .thenCompose(Function.identity()) .whenComplete((result, throwable) -> { log.debug("Adapter '{}': Stop operation completed, starting cleanup", getId()); - - // Always call destroy() to ensure all resources are properly released - // This prevents resource leaks from underlying client libraries try { - log.info("Destroying adapter with id '{}' to release all resources", getId()); adapter.destroy(); - log.debug("Adapter '{}': destroy() completed successfully", getId()); } catch (final Exception destroyException) { log.error("Error destroying adapter with id {}", adapter.getId(), destroyException); } - if (throwable == null) { - log.info("Stopped adapter with id '{}' successfully", adapter.getId()); + log.info("Successfully stopped adapter with id '{}' successfully", adapter.getId()); } else { log.error("Error stopping adapter with id {}", adapter.getId(), throwable); } - - // Clear the current operation when complete operationLock.lock(); try { currentStopFuture = null; - log.debug("Adapter '{}': Cleared currentStopFuture reference", getId()); } finally { operationLock.unlock(); } }); - - // 5. Store the future before returning - ensures visibility to other threads - currentStopFuture = stopFuture; - return stopFuture; - + return currentStopFuture; } finally { operationLock.unlock(); } @@ -445,10 +281,6 @@ public void setRuntimeStatus(final @NotNull ProtocolAdapterState.RuntimeStatus r return protocolAdapterState.getLastErrorMessage(); } - protected void initStartAttempt() { - lastStartAttemptTime = System.currentTimeMillis(); - } - public @NotNull ProtocolAdapterFactory getAdapterFactory() { return adapterFactory; } @@ -509,116 +341,133 @@ public boolean isBatchPolling() { return adapter instanceof BatchPollingProtocolAdapter; } - private void startPolling( - final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService, - final @NotNull EventService eventService) { - if (isBatchPolling()) { - log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); - final PerAdapterSampler sampler = new PerAdapterSampler(this, eventService, tagManager); - protocolAdapterPollingService.schedulePolling(sampler); + private void cleanupConnectionStatusListener() { + if (connectionStatusListener != null) { + protocolAdapterState.setConnectionStatusListener(CONNECTION_STATUS_NOOP_CONSUMER); + connectionStatusListener = null; } + } - if (isPolling()) { - config.getTags().forEach(tag -> { - final PerContextSampler sampler = new PerContextSampler(this, - new PollingContextWrapper("unused", - tag.getName(), - MessageHandlingOptions.MQTTMessagePerTag, - false, - false, - List.of(), - 1, - -1), - eventService, - tagManager); - protocolAdapterPollingService.schedulePolling(sampler); - }); + private @NotNull Optional startConsumers(final @NotNull EventService eventService) { + try { + // create/subscribe tag consumer + config.getNorthboundMappings() + .stream() + .map(mapping -> northboundConsumerFactory.build(this, mapping, protocolAdapterMetricsService)) + .forEach(northboundTagConsumer -> { + tagManager.addConsumer(northboundTagConsumer); + consumers.add(northboundTagConsumer); + }); + + // start polling + if (isBatchPolling()) { + log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); + pollingService.schedulePolling(new PerAdapterSampler(this, eventService, tagManager)); + } + if (isPolling()) { + config.getTags() + .forEach(tag -> pollingService.schedulePolling(new PerContextSampler(this, + new PollingContextWrapper("unused", + tag.getName(), + MessageHandlingOptions.MQTTMessagePerTag, + false, + false, + List.of(), + 1, + -1), + eventService, + tagManager))); + } + + // FSM's accept() method handles: + // 1. Transitioning northbound adapter + // 2. Triggering startSouthbound() when CONNECTED (only for writing adapters) + // For non-writing adapters that are only polling, southbound is not applicable + // but we still need to track northbound connection status + connectionStatusListener = this; + protocolAdapterState.setConnectionStatusListener(connectionStatusListener); + return Optional.empty(); + } catch (final Throwable e) { + log.error("Protocol adapter start failed", e); + return Optional.of(e); } } - private void stopPolling( - final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService) { + private void stopPolling() { if (isPolling() || isBatchPolling()) { log.debug("Stopping polling for protocol adapter with id '{}'", getId()); - protocolAdapterPollingService.stopPollingForAdapterInstance(getAdapter()); + pollingService.stopPollingForAdapterInstance(adapter); } } - private boolean startWriting(final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService) { - log.debug("Start writing for protocol adapter with id '{}'", getId()); - - final var southboundMappings = getSouthboundMappings(); - final var writingContexts = southboundMappings.stream() - .map(InternalWritingContextImpl::new) - .collect(Collectors.toList()); - - return protocolAdapterWritingService.startWriting((WritingProtocolAdapter) getAdapter(), - getProtocolAdapterMetricsService(), - writingContexts); - } - - private void stopWriting(final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService) { - //no check for 'writing is enabled', as we have to stop it anyway since the license could have been removed in the meantime. + private void stopWriting() { if (isWriting()) { log.debug("Stopping writing for protocol adapter with id '{}'", getId()); - final var writingContexts = getSouthboundMappings().stream() - .map(mapping -> (InternalWritingContext) new InternalWritingContextImpl(mapping)) - .toList(); - protocolAdapterWritingService.stopWriting((WritingProtocolAdapter) getAdapter(), writingContexts); + writingService.stopWriting((WritingProtocolAdapter) adapter, + config.getSouthboundMappings() + .stream() + .map(mapping -> (InternalWritingContext) new InternalWritingContextImpl(mapping)) + .toList()); } } - private void createAndSubscribeTagConsumer() { - getNorthboundMappings().stream() - .map(northboundMapping -> northboundConsumerFactory.build(this, - northboundMapping, - protocolAdapterMetricsService)) - .forEach(northboundTagConsumer -> { - tagManager.addConsumer(northboundTagConsumer); - consumers.add(northboundTagConsumer); - }); - } - - /** - * Performs the actual stop operation for the adapter. - * Extracted into a separate method so it can be called both asynchronously - * (normal case) and synchronously (when executor is shutdown during JVM shutdown). - * - * @param input the stop input - * @param output the stop output - * @return the completion future from the adapter's stop operation - */ - private @NotNull CompletableFuture performStopOperation( - final @NotNull ProtocolAdapterStopInput input, - final @NotNull ProtocolAdapterStopOutput output) { - log.debug("Adapter '{}': Stop operation executing in thread '{}'", getId(), Thread.currentThread().getName()); - - // Signal FSM to stop (calls onStopping() internally) - log.debug("Adapter '{}': Stopping adapter FSM", getId()); - stopAdapter(); + private @NotNull Supplier<@NotNull CompletableFuture> startProtocolAdapter( + final @NotNull ModuleServices moduleServices) { + return () -> { + startAdapter(); // start FSM + final ProtocolAdapterStartOutputImpl output = new ProtocolAdapterStartOutputImpl(); + try { + adapter.start(new ProtocolAdapterStartInputImpl(moduleServices), output); + } catch (final Throwable t) { + output.getStartFuture().completeExceptionally(t); + } + return output.getStartFuture(); + }; + } - // Clean up listeners to prevent memory leaks - log.debug("Adapter '{}': Cleaning up connection status listener", getId()); + private @NotNull CompletableFuture stopProtocolAdapter() { + log.debug("Adapter '{}': Stop operation executing in thread '{}'", + adapter.getId(), + Thread.currentThread().getName()); + stopAdapter(); cleanupConnectionStatusListener(); - - // Remove consumers - log.debug("Adapter '{}': Removing {} consumers", getId(), consumers.size()); consumers.forEach(tagManager::removeConsumer); + stopPolling(); + stopWriting(); + final var output = new ProtocolAdapterStopOutputImpl(); + try { + adapter.stop(new ProtocolAdapterStopInputImpl(), output); + } catch (final Throwable throwable) { + log.error("Adapter '{}': Exception during adapter.stop()", adapter.getId(), throwable); + output.getOutputFuture().completeExceptionally(throwable); + } + log.debug("Adapter '{}': Waiting for stop output future", adapter.getId()); + return output.getOutputFuture(); + } - log.debug("Adapter '{}': Stopping polling", getId()); - stopPolling(protocolAdapterPollingService); - - log.debug("Adapter '{}': Stopping writing", getId()); - stopWriting(protocolAdapterWritingService); - + private void stopProtocolAdapterOnFailedStart() { + log.warn("Stopping adapter with id {} after a failed start", adapter.getId()); + stopAdapter(); + cleanupConnectionStatusListener(); + stopPolling(); + stopWriting(); + final var output = new ProtocolAdapterStopOutputImpl(); try { - log.debug("Adapter '{}': Calling adapter.stop()", getId()); - adapter.stop(input, output); + adapter.stop(new ProtocolAdapterStopInputImpl(), output); } catch (final Throwable throwable) { - log.error("Adapter '{}': Exception during adapter.stop()", getId(), throwable); - ((ProtocolAdapterStopOutputImpl) output).getOutputFuture().completeExceptionally(throwable); + log.error("Stopping adapter after a start error failed", throwable); + } + try { + output.getOutputFuture().get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (final TimeoutException e) { + log.error("Timeout waiting for adapter {} to stop after failed start", adapter.getId()); + } catch (final Throwable throwable) { + log.error("Stopping adapter after a start error failed", throwable); + } + try { + adapter.destroy(); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {} after failed start", adapter.getId(), destroyException); } - log.debug("Adapter '{}': Waiting for stop output future", getId()); - return ((ProtocolAdapterStopOutputImpl) output).getOutputFuture(); } } From 32b3e6467f493ec5ffacca05b74d4b40586d3af2 Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 23 Oct 2025 10:08:56 +0200 Subject: [PATCH 32/50] fix misnomer --- .../hivemq/fsm/ProtocolAdapterFSMTest.java | 2 +- .../protocols/ProtocolAdapterManagerTest.java | 54 ++++++++----------- ...ProtocolAdapterWrapperConcurrencyTest.java | 33 ++++++------ 3 files changed, 40 insertions(+), 49 deletions(-) diff --git a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java index 2f20a4b97a..ad99c0767b 100644 --- a/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java @@ -437,7 +437,7 @@ void stateListener_notifiedOnTransition() { fsm.startAdapter(); assertThat(capturedState.get()).isNotNull(); - assertThat(capturedState.get().state()).isEqualTo(AdapterStateEnum.STARTED); + assertThat(capturedState.get().adapter()).isEqualTo(AdapterStateEnum.STARTED); } @Test diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java index 6297278a5b..8b905e4f42 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterManagerTest.java @@ -32,6 +32,7 @@ import com.hivemq.adapter.sdk.api.writing.WritingOutput; import com.hivemq.adapter.sdk.api.writing.WritingPayload; import com.hivemq.adapter.sdk.api.writing.WritingProtocolAdapter; +import com.hivemq.common.shutdown.ShutdownHooks; import com.hivemq.configuration.reader.ProtocolAdapterExtractor; import com.hivemq.edge.HiveMQEdgeRemoteService; import com.hivemq.edge.VersionProvider; @@ -78,6 +79,7 @@ class ProtocolAdapterManagerTest { private final @NotNull NorthboundConsumerFactory northboundConsumerFactory = mock(); private final @NotNull TagManager tagManager = mock(); private final @NotNull ProtocolAdapterExtractor protocolAdapterExtractor = mock(); + private final @NotNull ShutdownHooks shutdownHooks = mock(); private final @NotNull ProtocolAdapterConfigConverter protocolAdapterConfigConverter = mock(); @@ -89,8 +91,7 @@ class ProtocolAdapterManagerTest { void setUp() { testExecutor = Executors.newCachedThreadPool(); testScheduledExecutor = Executors.newScheduledThreadPool(2); - protocolAdapterManager = new ProtocolAdapterManager( - metricRegistry, + protocolAdapterManager = new ProtocolAdapterManager(metricRegistry, moduleServices, remoteService, eventService, @@ -104,7 +105,7 @@ void setUp() { tagManager, protocolAdapterExtractor, testExecutor, - testScheduledExecutor); + shutdownHooks); } @AfterEach @@ -124,9 +125,7 @@ void test_startWritingAdapterSucceeded_eventsFired() throws Exception { final EventBuilder eventBuilder = new EventBuilderImpl(mock()); when(protocolAdapterWritingService.writingEnabled()).thenReturn(true); - when(protocolAdapterWritingService.startWriting(any(), - any(), - any())).thenReturn(true); + when(protocolAdapterWritingService.startWriting(any(), any(), any())).thenReturn(true); when(eventService.createAdapterEvent(anyString(), anyString())).thenReturn(eventBuilder); final var adapterState = new ProtocolAdapterStateImpl(eventService, "test-adapter", "test-protocol"); final ProtocolAdapterWrapper adapterWrapper = new ProtocolAdapterWrapper(mock(), @@ -175,13 +174,11 @@ void test_startWritingNotEnabled_writingNotStarted() throws Exception { } @Test - void test_startWriting_adapterFailedStart_resourcesCleanedUp() throws Exception{ + void test_startWriting_adapterFailedStart_resourcesCleanedUp() throws Exception { final EventBuilder eventBuilder = new EventBuilderImpl(mock()); when(protocolAdapterWritingService.writingEnabled()).thenReturn(true); - when(protocolAdapterWritingService - .startWriting(any(), any(), any())) - .thenReturn(true); + when(protocolAdapterWritingService.startWriting(any(), any(), any())).thenReturn(true); when(eventService.createAdapterEvent(anyString(), anyString())).thenReturn(eventBuilder); final var adapterState = new ProtocolAdapterStateImpl(eventService, "test-adapter", "test-protocol"); @@ -199,10 +196,8 @@ void test_startWriting_adapterFailedStart_resourcesCleanedUp() throws Exception{ testExecutor); // Start will fail, but we expect cleanup to happen - assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()) - .isInstanceOf(ExecutionException.class) - .hasCauseInstanceOf(RuntimeException.class) - .hasMessageContaining("failed"); + assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()).isInstanceOf( + ExecutionException.class).hasCauseInstanceOf(RuntimeException.class).hasMessageContaining("failed"); // Even though start failed, cleanup should have occurred assertThat(adapterWrapper.getRuntimeStatus()).isEqualTo(ProtocolAdapterState.RuntimeStatus.STOPPED); @@ -218,9 +213,7 @@ void test_startWriting_eventServiceFailedStart_resourcesCleanedUp() throws Excep when(eventService.createAdapterEvent(anyString(), anyString())).thenReturn(eventBuilder); when(protocolAdapterWritingService.writingEnabled()).thenReturn(true); - when(protocolAdapterWritingService.startWriting(any(), - any(), - any())).thenReturn(true); + when(protocolAdapterWritingService.startWriting(any(), any(), any())).thenReturn(true); final var adapterState = new ProtocolAdapterStateImpl(eventService, "test-adapter", "test-protocol"); final ProtocolAdapterWrapper adapterWrapper = new ProtocolAdapterWrapper(mock(), @@ -236,10 +229,8 @@ void test_startWriting_eventServiceFailedStart_resourcesCleanedUp() throws Excep testExecutor); // Start will fail, but we expect cleanup to happen - assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()) - .isInstanceOf(ExecutionException.class) - .hasCauseInstanceOf(RuntimeException.class) - .hasMessageContaining("failed"); + assertThatThrownBy(() -> protocolAdapterManager.startAsync(adapterWrapper).get()).isInstanceOf( + ExecutionException.class).hasCauseInstanceOf(RuntimeException.class).hasMessageContaining("failed"); // Even though start failed, cleanup should have occurred assertThat(adapterWrapper.getRuntimeStatus()).isEqualTo(ProtocolAdapterState.RuntimeStatus.STOPPED); @@ -301,8 +292,7 @@ void test_stopWritingAdapterFailed_eventsFired() throws Exception { // Start the adapter first to transition FSM state to STARTED protocolAdapterManager.startAsync(adapterWrapper).get(); - assertThatThrownBy(() -> protocolAdapterManager.stopAsync(adapterWrapper).get()) - .rootCause() + assertThatThrownBy(() -> protocolAdapterManager.stopAsync(adapterWrapper).get()).rootCause() .isInstanceOf(RuntimeException.class) .hasMessage("failed"); @@ -393,8 +383,7 @@ static class TestWritingAdapter implements WritingProtocolAdapter { } @Override - public void write( - final @NotNull WritingInput writingInput, final @NotNull WritingOutput writingOutput) { + public void write(final @NotNull WritingInput writingInput, final @NotNull WritingOutput writingOutput) { } @@ -410,7 +399,8 @@ public void write( @Override public void start( - final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + final @NotNull ProtocolAdapterStartInput input, + final @NotNull ProtocolAdapterStartOutput output) { if (success) { adapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STARTED); output.startedSuccessfully(); @@ -421,7 +411,8 @@ public void start( @Override public void stop( - final @NotNull ProtocolAdapterStopInput input, final @NotNull ProtocolAdapterStopOutput output) { + final @NotNull ProtocolAdapterStopInput input, + final @NotNull ProtocolAdapterStopOutput output) { if (success) { adapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STOPPED); output.stoppedSuccessfully(); @@ -445,8 +436,7 @@ static class TestWritingAdapterFailOnStop implements WritingProtocolAdapter { } @Override - public void write( - final @NotNull WritingInput writingInput, final @NotNull WritingOutput writingOutput) { + public void write(final @NotNull WritingInput writingInput, final @NotNull WritingOutput writingOutput) { } @@ -462,14 +452,16 @@ public void write( @Override public void start( - final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + final @NotNull ProtocolAdapterStartInput input, + final @NotNull ProtocolAdapterStartOutput output) { adapterState.setRuntimeStatus(ProtocolAdapterState.RuntimeStatus.STARTED); output.startedSuccessfully(); } @Override public void stop( - final @NotNull ProtocolAdapterStopInput input, final @NotNull ProtocolAdapterStopOutput output) { + final @NotNull ProtocolAdapterStopInput input, + final @NotNull ProtocolAdapterStopOutput output) { output.failStop(new RuntimeException("failed"), "could not stop"); } diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java index e20ef2652f..f0a2b24c19 100644 --- a/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/ProtocolAdapterWrapperConcurrencyTest.java @@ -69,7 +69,7 @@ class ProtocolAdapterWrapperConcurrencyTest { private @Nullable ExecutorService executor; private static void verifyStateConsistency(final ProtocolAdapterFSM.State state) { - final ProtocolAdapterFSM.AdapterStateEnum adapterState = state.state(); + final ProtocolAdapterFSM.AdapterStateEnum adapterState = state.adapter(); final ProtocolAdapterFSM.StateEnum northbound = state.northbound(); final ProtocolAdapterFSM.StateEnum southbound = state.southbound(); @@ -256,7 +256,7 @@ void test_fsmStateTransitions_areAtomic() throws Exception { // Verify complete state is valid final var state = requireNonNull(wrapper).currentState(); assertNotNull(state, "State should never be null"); - assertNotNull(state.state(), "Adapter state should never be null"); + assertNotNull(state.adapter(), "Adapter state should never be null"); assertNotNull(state.northbound(), "Northbound state should never be null"); assertNotNull(state.southbound(), "Southbound state should never be null"); @@ -289,14 +289,14 @@ void test_fsmStateTransitions_areAtomic() throws Exception { // Verify final state is complete and valid final var finalState = requireNonNull(wrapper).currentState(); assertNotNull(finalState, "Final state should not be null"); - assertNotNull(finalState.state(), "Final adapter state should not be null"); + assertNotNull(finalState.adapter(), "Final adapter state should not be null"); assertNotNull(finalState.northbound(), "Final northbound state should not be null"); assertNotNull(finalState.southbound(), "Final southbound state should not be null"); // Final state should be STOPPED or STARTED - assertTrue(finalState.state() == ProtocolAdapterFSM.AdapterStateEnum.STOPPED || - finalState.state() == ProtocolAdapterFSM.AdapterStateEnum.STARTED, - "Final state should be stable: " + finalState.state()); + assertTrue(finalState.adapter() == ProtocolAdapterFSM.AdapterStateEnum.STOPPED || + finalState.adapter() == ProtocolAdapterFSM.AdapterStateEnum.STARTED, + "Final state should be stable: " + finalState.adapter()); } @Test @@ -316,7 +316,7 @@ void test_stateReads_alwaysConsistent() throws Exception { while (!stopLatch.await(0, TimeUnit.MILLISECONDS)) { final var state = requireNonNull(wrapper).currentState(); assertNotNull(state, "State should never be null"); - assertNotNull(state.state(), "Adapter state should never be null"); + assertNotNull(state.adapter(), "Adapter state should never be null"); assertNotNull(state.northbound(), "Northbound state should never be null"); assertNotNull(state.southbound(), "Southbound state should never be null"); @@ -443,18 +443,17 @@ void test_adapterIdAccess_isThreadSafe() throws Exception { @Test @Timeout(10) void test_concurrentStartAsync_properSerialization() throws Exception { - runConcurrentOperations(SMALL_THREAD_COUNT, - () -> { - try { - requireNonNull(wrapper).startAsync(requireNonNull(mockModuleServices)).get(); - } catch (final Exception e) { - // Expected - concurrent operations may fail - } - }); + runConcurrentOperations(SMALL_THREAD_COUNT, () -> { + try { + requireNonNull(wrapper).startAsync(requireNonNull(mockModuleServices)).get(); + } catch (final Exception e) { + // Expected - concurrent operations may fail + } + }); final var state = requireNonNull(wrapper).currentState(); assertNotNull(state); - assertNotNull(state.state()); + assertNotNull(state.adapter()); } @Test @@ -467,7 +466,7 @@ void test_concurrentStopAsync_properSerialization() throws Exception { final var state = requireNonNull(wrapper).currentState(); assertNotNull(state); - assertNotNull(state.state()); + assertNotNull(state.adapter()); } @Test From 7784973e418acd29b93dce3dfe3df765e6cb206b Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 23 Oct 2025 13:27:16 +0200 Subject: [PATCH 33/50] fixes --- .../embedded/internal/EmbeddedHiveMQImpl.java | 33 ------------------- .../protocols/ProtocolAdapterManager.java | 20 ++++------- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java index ea9f36eeb1..e5e55f11d6 100644 --- a/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/embedded/internal/EmbeddedHiveMQImpl.java @@ -20,7 +20,6 @@ import com.google.common.annotations.VisibleForTesting; import com.hivemq.HiveMQEdgeMain; import com.hivemq.bootstrap.ioc.Injector; -import com.hivemq.common.executors.ioc.ExecutorsModule; import com.hivemq.configuration.ConfigurationBootstrap; import com.hivemq.configuration.info.SystemInformationImpl; import com.hivemq.configuration.service.ConfigurationService; @@ -28,7 +27,6 @@ import com.hivemq.edge.modules.ModuleLoader; import com.hivemq.embedded.EmbeddedExtension; import com.hivemq.embedded.EmbeddedHiveMQ; -import com.hivemq.protocols.ProtocolAdapterManager; import com.hivemq.util.ThreadFactoryUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -199,39 +197,8 @@ private void performStop( try { final long startTime = System.currentTimeMillis(); - try { - // Step 1: Shutdown protocol adapters BEFORE stopping HiveMQ - // This allows adapters to complete their stop operations cleanly - if (hiveMQServer != null && hiveMQServer.getInjector() != null) { - try { - final ProtocolAdapterManager protocolAdapterManager = - hiveMQServer.getInjector().protocolAdapterManager(); - if (protocolAdapterManager != null) { - protocolAdapterManager.shutdown(); - } - } catch (final Exception ex) { - log.warn("Exception during protocol adapter manager shutdown", ex); - } - } - - // Step 2: Stop HiveMQ, running all shutdown hooks (including BridgeService) hiveMQServer.stop(); - - // Step 3: NOW shut down executors after all shutdown hooks have completed - // This prevents race conditions where callbacks try to execute on terminated executors - if (hiveMQServer != null && hiveMQServer.getInjector() != null) { - try { - ExecutorsModule.shutdownExecutor(hiveMQServer.getInjector().executorService(), - ExecutorsModule.CACHED_WORKER_GROUP_NAME, - 10); - ExecutorsModule.shutdownExecutor(hiveMQServer.getInjector().scheduledExecutor(), - ExecutorsModule.SCHEDULED_WORKER_GROUP_NAME, - 5); - } catch (final Exception ex) { - log.warn("Exception during executor shutdown", ex); - } - } } catch (final Exception ex) { if (desiredState == State.CLOSED) { log.error("Exception during running shutdown hook.", ex); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 38a8584999..73cc10d3cf 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -363,7 +363,7 @@ public boolean isWritingEnabled() { .toList()); } - public void shutdown() { + private void shutdown() { if (shutdownInitiated.compareAndSet(false, true)) { shutdownExecutor(singleThreadRefreshExecutor, "protocol-adapter-manager-refresh", 5); @@ -374,7 +374,7 @@ public void shutdown() { return; } - // Initiate stop for all adapters + // initiate stop for all adapters log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); final List> stopFutures = new ArrayList<>(); for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { @@ -385,21 +385,15 @@ public void shutdown() { log.error("Error initiating stop for adapter '{}' during shutdown", wrapper.getId(), e); } } - - // Wait for all adapters to stop, with timeout - final CompletableFuture allStops = - CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])); + // wait for all adapters to stop, with timeout try { - allStops.get(20, TimeUnit.SECONDS); + CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])).get(20, TimeUnit.SECONDS); log.info("All adapters stopped successfully during shutdown"); } catch (final TimeoutException e) { - log.warn( - "Timeout waiting for adapters to stop during shutdown (waited 20s). Proceeding with executor shutdown."); - // Log which adapters failed to stop + log.warn("Timeout waiting for adapters to stop during shutdown"); for (int i = 0; i < stopFutures.size(); i++) { if (!stopFutures.get(i).isDone()) { - log.warn("Adapter '{}' did not complete stop operation within timeout", - adaptersToStop.get(i).getId()); + log.warn("Adapter '{}' did not complete stop operation", adaptersToStop.get(i).getId()); } } } catch (final InterruptedException e) { @@ -409,8 +403,6 @@ public void shutdown() { log.error("Error occurred while stopping adapters during shutdown", e.getCause()); } log.info("Protocol Adapter Manager shutdown completed"); - } else { - log.debug("Protocol Adapter Manager shutdown already initiated, skipping"); } } From 5b7c5907e4f8d815906fd085aed81ac2092fb0f1 Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 23 Oct 2025 13:34:27 +0200 Subject: [PATCH 34/50] strengthen shutdown --- .../src/main/java/com/hivemq/HiveMQEdgeMain.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java b/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java index aad3bf4dcf..a19e450030 100644 --- a/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java +++ b/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java @@ -174,10 +174,12 @@ public void start(final @Nullable EmbeddedExtension embeddedExtension) public void stop() { stopGateway(); - try { - Runtime.getRuntime().removeShutdownHook(shutdownThread); - } catch (final IllegalStateException ignored) { - //ignore + if (shutdownThread != null) { + try { + Runtime.getRuntime().removeShutdownHook(shutdownThread); + } catch (final IllegalStateException ignored) { + //ignore + } } } From 3a58ce95e5c8d1f40bff13a6b4f534dfcaa293b3 Mon Sep 17 00:00:00 2001 From: marregui Date: Thu, 23 Oct 2025 23:47:31 +0200 Subject: [PATCH 35/50] strengthen tests --- .../common/executors/ioc/ExecutorsModule.java | 26 --------------- .../protocols/ProtocolAdapterManager.java | 33 +++++++++++++++---- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java index a185d4f562..f53224b53a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java +++ b/hivemq-edge/src/main/java/com/hivemq/common/executors/ioc/ExecutorsModule.java @@ -26,7 +26,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @Module @@ -54,31 +53,6 @@ public abstract class ExecutorsModule { return Executors.newCachedThreadPool(new HiveMQEdgeThreadFactory(CACHED_WORKER_GROUP_NAME)); } - public static void shutdownExecutor( - final @NotNull ExecutorService executor, - final @NotNull String name, - final int timeoutSeconds) { - log.debug("Shutting down executor service: {}", name); - if (!executor.isShutdown()) { - executor.shutdown(); - } - try { - if (!executor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); - executor.shutdownNow(); - if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { - log.error("Executor service {} still has running tasks after forced shutdown", name); - } - } else { - log.debug("Executor service {} shut down successfully", name); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted while waiting for executor service {} to terminate", name); - executor.shutdownNow(); - } - } - private static class HiveMQEdgeThreadFactory implements ThreadFactory { private final @NotNull String factoryName; private final @NotNull ThreadGroup group; diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 73cc10d3cf..66f38f02a3 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -63,7 +63,6 @@ import java.util.function.Supplier; import java.util.stream.Collectors; -import static com.hivemq.common.executors.ioc.ExecutorsModule.shutdownExecutor; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_FAILED_TO_START; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_MISSING; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ALREADY_EXISTS; @@ -88,7 +87,7 @@ public class ProtocolAdapterManager { private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; private final @NotNull TagManager tagManager; private final @NotNull ProtocolAdapterExtractor config; - private final @NotNull ExecutorService singleThreadRefreshExecutor; + private final @NotNull ExecutorService refreshExecutor; private final @NotNull ExecutorService sharedAdapterExecutor; private final @NotNull AtomicBoolean shutdownInitiated; @@ -124,7 +123,7 @@ public ProtocolAdapterManager( this.config = config; this.sharedAdapterExecutor = sharedAdapterExecutor; this.protocolAdapters = new ConcurrentHashMap<>(); - this.singleThreadRefreshExecutor = Executors.newSingleThreadExecutor(); + this.refreshExecutor = Executors.newSingleThreadExecutor(); this.shutdownInitiated = new AtomicBoolean(false); shutdownHooks.add(new HiveMQShutdownHook() { @Override @@ -161,7 +160,7 @@ public void start() { } public void refresh(final @NotNull List configs) { - singleThreadRefreshExecutor.submit(() -> { + refreshExecutor.submit(() -> { log.info("Refreshing adapters"); final Map protocolAdapterConfigs = configs.stream() @@ -365,7 +364,7 @@ public boolean isWritingEnabled() { private void shutdown() { if (shutdownInitiated.compareAndSet(false, true)) { - shutdownExecutor(singleThreadRefreshExecutor, "protocol-adapter-manager-refresh", 5); + shutdownRefreshExecutor(); log.info("Initiating shutdown of Protocol Adapter Manager"); final List adaptersToStop = new ArrayList<>(protocolAdapters.values()); @@ -387,7 +386,7 @@ private void shutdown() { } // wait for all adapters to stop, with timeout try { - CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])).get(20, TimeUnit.SECONDS); + CompletableFuture.allOf(stopFutures.toArray(new CompletableFuture[0])).get(15, TimeUnit.SECONDS); log.info("All adapters stopped successfully during shutdown"); } catch (final TimeoutException e) { log.warn("Timeout waiting for adapters to stop during shutdown"); @@ -406,6 +405,28 @@ private void shutdown() { } } + private void shutdownRefreshExecutor() { + final String name = "protocol-adapter-manager-refresh"; + final int timeoutSeconds = 5; + log.debug("Shutting {} executor service", name); + refreshExecutor.shutdown(); + try { + if (!refreshExecutor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); + refreshExecutor.shutdownNow(); + if (!refreshExecutor.awaitTermination(2, TimeUnit.SECONDS)) { + log.error("Executor service {} still has running tasks after forced shutdown", name); + } + } else { + log.debug("Executor service {} shut down successfully", name); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for executor service {} to terminate", name); + refreshExecutor.shutdownNow(); + } + } + private @NotNull ProtocolAdapterWrapper createAdapterInternal( final @NotNull ProtocolAdapterConfig config, final @NotNull String version) { From dae77130559c20ac7ed484b18502e1cdf4d7d23e Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 24 Oct 2025 00:02:36 +0200 Subject: [PATCH 36/50] put back this stateless state in http adapter, which I removed in error --- .../java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java index baa31018b7..b9f69a4692 100644 --- a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java +++ b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java @@ -111,7 +111,7 @@ public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { try { - // Don't manually set connection status - FSM manages this automatically + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.STATELESS); if (httpClient == null) { final HttpClient.Builder builder = HttpClient.newBuilder(); builder.version(HttpClient.Version.HTTP_1_1) From f31d57a712375a1ba5840d4d73d8c1724b1b323d Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 24 Oct 2025 09:55:06 +0200 Subject: [PATCH 37/50] cleanup resources after tests run --- .../adapters/http/HttpProtocolAdapter.java | 155 +++++++++--------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java index b9f69a4692..9c5172a263 100644 --- a/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java +++ b/modules/hivemq-edge-module-http/src/main/java/com/hivemq/edge/adapters/http/HttpProtocolAdapter.java @@ -60,9 +60,10 @@ import java.util.Base64; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.ERROR; +import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.STATELESS; import static com.hivemq.edge.adapters.http.config.HttpSpecificAdapterConfig.JSON_MIME_TYPE; import static com.hivemq.edge.adapters.http.config.HttpSpecificAdapterConfig.PLAIN_MIME_TYPE; @@ -73,7 +74,6 @@ public class HttpProtocolAdapter implements BatchPollingProtocolAdapter { private static final @NotNull String CONTENT_TYPE_HEADER = "Content-Type"; private static final @NotNull String BASE64_ENCODED_VALUE = "data:%s;base64,%s"; private static final @NotNull String USER_AGENT_HEADER = "User-Agent"; - private static final @NotNull String RESPONSE_DATA = "httpResponseData"; private final @NotNull ProtocolAdapterInformation adapterInformation; private final @NotNull HttpSpecificAdapterConfig adapterConfig; @@ -85,6 +85,7 @@ public class HttpProtocolAdapter implements BatchPollingProtocolAdapter { private final @NotNull String adapterId; private final @Nullable ObjectMapper objectMapper; + private final @NotNull AtomicBoolean started; private volatile @Nullable HttpClient httpClient = null; public HttpProtocolAdapter( @@ -93,12 +94,17 @@ public HttpProtocolAdapter( this.adapterId = input.getAdapterId(); this.adapterInformation = adapterInformation; this.adapterConfig = input.getConfig(); - this.tags = input.getTags().stream().map(tag -> (HttpTag)tag).toList(); + this.tags = input.getTags().stream().map(tag -> (HttpTag) tag).toList(); this.version = input.getVersion(); this.protocolAdapterState = input.getProtocolAdapterState(); this.moduleServices = input.moduleServices(); this.adapterFactories = input.adapterFactories(); this.objectMapper = new ObjectMapper(); + this.started = new AtomicBoolean(false); + } + + private static boolean isSuccessStatusCode(final int statusCode) { + return statusCode >= 200 && statusCode <= 299; } @Override @@ -110,8 +116,13 @@ public HttpProtocolAdapter( public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + if (!started.compareAndSet(false, true)) { + // Already started, idempotent - just return success + output.startedSuccessfully(); + return; + } try { - protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.STATELESS); + protocolAdapterState.setConnectionStatus(STATELESS); if (httpClient == null) { final HttpClient.Builder builder = HttpClient.newBuilder(); builder.version(HttpClient.Version.HTTP_1_1) @@ -124,12 +135,18 @@ public void start( } output.startedSuccessfully(); } catch (final Exception e) { + started.set(false); output.failStart(e, "Unable to start http protocol adapter."); } } @Override public void stop(final @NotNull ProtocolAdapterStopInput input, final @NotNull ProtocolAdapterStopOutput output) { + if (!started.compareAndSet(true, false)) { + // Already stopped, idempotent - just return success + output.stoppedSuccessfully(); + return; + } httpClient = null; output.stoppedSuccessfully(); } @@ -147,9 +164,7 @@ public void destroy() { } @Override - public void poll( - final @NotNull BatchPollingInput pollingInput, final @NotNull BatchPollingOutput pollingOutput) { - + public void poll(final @NotNull BatchPollingInput pollingInput, final @NotNull BatchPollingOutput pollingOutput) { final HttpClient httpClient = this.httpClient; if (httpClient == null) { pollingOutput.fail(new ProtocolAdapterException(), "No response was created, because the client is null."); @@ -159,46 +174,46 @@ public void poll( final List> pollingFutures = tags.stream().map(tag -> pollHttp(httpClient, tag)).toList(); - CompletableFuture.allOf(pollingFutures.toArray(new CompletableFuture[]{})) - .whenComplete((result, throwable) -> { - if(throwable != null) { - pollingOutput.fail(throwable, "Error while polling tags."); - } else { - try { - for (final CompletableFuture future : pollingFutures) { - final var data = future.get(); - // Update connection status to ERROR if HTTP request failed - if (!data.isSuccessStatusCode()) { - protocolAdapterState.setConnectionStatus(ERROR); - } - // FSM manages STATELESS/CONNECTED status automatically - if (data.isSuccessStatusCode() || - !adapterConfig.getHttpToMqttConfig().isHttpPublishSuccessStatusCodeOnly()) { - data.getDataPoints().forEach(pollingOutput::addDataPoint); - } - } - pollingOutput.finish(); - } catch (final InterruptedException | ExecutionException e) { - pollingOutput.fail(e, "Exception while accessing data of completed future."); + CompletableFuture.allOf(pollingFutures.toArray(new CompletableFuture[]{})).whenComplete((result, throwable) -> { + if (throwable != null) { + pollingOutput.fail(throwable, "Error while polling tags."); + } else { + try { + for (final CompletableFuture future : pollingFutures) { + final var data = future.get(); + // Update connection status to ERROR if HTTP request failed + if (!data.isSuccessStatusCode()) { + protocolAdapterState.setConnectionStatus(ERROR); + } else { + protocolAdapterState.setConnectionStatus(STATELESS); + } + if (data.isSuccessStatusCode() || + !adapterConfig.getHttpToMqttConfig().isHttpPublishSuccessStatusCodeOnly()) { + data.getDataPoints().forEach(pollingOutput::addDataPoint); } } - }); + pollingOutput.finish(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + pollingOutput.fail(e, "Interrupted while accessing data of completed future."); + } catch (final Throwable e) { + pollingOutput.fail(e, "Exception while accessing data of completed future."); + } + } + }); } - private CompletableFuture pollHttp( + private @NotNull CompletableFuture pollHttp( final @NotNull HttpClient httpClient, final @NotNull HttpTag httpTag) { - final HttpRequest.Builder builder = HttpRequest.newBuilder(); - final String url = httpTag.getDefinition().getUrl(); final HttpTagDefinition tagDef = httpTag.getDefinition(); - builder.uri(URI.create(url)); - builder.timeout(Duration.ofSeconds(httpTag.getDefinition().getHttpRequestTimeoutSeconds())); + final HttpRequest.Builder builder = HttpRequest.newBuilder(); + builder.uri(URI.create(tagDef.getUrl())); + builder.timeout(Duration.ofSeconds(tagDef.getHttpRequestTimeoutSeconds())); builder.setHeader(USER_AGENT_HEADER, String.format("HiveMQ-Edge; %s", version)); - tagDef.getHttpHeaders().forEach(hv -> builder.setHeader(hv.getName(), hv.getValue())); - switch (tagDef.getHttpRequestMethod()) { case GET: builder.GET(); @@ -220,31 +235,29 @@ private CompletableFuture pollHttp( builder.header(CONTENT_TYPE_HEADER, tagDef.getHttpRequestBodyContentType().getMimeType()); break; default: - return CompletableFuture - .failedFuture( - new IllegalStateException("There was an unexpected value present in the request config: " + tagDef.getHttpRequestMethod())); + return CompletableFuture.failedFuture(new IllegalStateException( + "There was an unexpected value present in the request config: " + + tagDef.getHttpRequestMethod())); } - - return httpClient - .sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> getHttpData(httpResponse, url, httpTag.getName())); + return httpClient.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString()) + .thenApply(httpResponse -> getHttpData(httpResponse, tagDef.getUrl(), httpTag.getName())); } - private @NotNull HttpData getHttpData(final HttpResponse httpResponse, final String url, - final @NotNull String tagName) { + private @NotNull HttpData getHttpData( + final @NotNull HttpResponse httpResponse, + final @NotNull String url, + final @NotNull String tagName) { Object payloadData = null; - String responseContentType = null; - + String contentType = null; if (isSuccessStatusCode(httpResponse.statusCode())) { final String bodyData = httpResponse.body(); //-- if the content type is json, then apply the JSON to the output data, //-- else encode using base64 (as we dont know what the content is). if (bodyData != null) { - responseContentType = httpResponse.headers().firstValue(CONTENT_TYPE_HEADER).orElse(null); - responseContentType = adapterConfig.getHttpToMqttConfig().isAssertResponseIsJson() ? - JSON_MIME_TYPE : - responseContentType; - if (JSON_MIME_TYPE.equals(responseContentType)) { + contentType = httpResponse.headers().firstValue(CONTENT_TYPE_HEADER).orElse(null); + contentType = + adapterConfig.getHttpToMqttConfig().isAssertResponseIsJson() ? JSON_MIME_TYPE : contentType; + if (JSON_MIME_TYPE.equals(contentType)) { try { payloadData = objectMapper.readTree(bodyData); } catch (final Exception e) { @@ -261,23 +274,20 @@ private CompletableFuture pollHttp( throw new RuntimeException("unable to parse JSON data from HTTP response"); } } else { - if (responseContentType == null) { - responseContentType = PLAIN_MIME_TYPE; + if (contentType == null) { + contentType = PLAIN_MIME_TYPE; } - final String base64 = - Base64.getEncoder().encodeToString(bodyData.getBytes(StandardCharsets.UTF_8)); - payloadData = String.format(BASE64_ENCODED_VALUE, responseContentType, base64); + payloadData = String.format(BASE64_ENCODED_VALUE, + contentType, + Base64.getEncoder().encodeToString(bodyData.getBytes(StandardCharsets.UTF_8))); } } } - final HttpData data = new HttpData(url, - httpResponse.statusCode(), - responseContentType, - adapterFactories.dataPointFactory()); - //When the body is empty, just include the metadata + final HttpData data = + new HttpData(url, httpResponse.statusCode(), contentType, adapterFactories.dataPointFactory()); if (payloadData != null) { - data.addDataPoint(tagName, payloadData); + data.addDataPoint(tagName, payloadData); // when the body is empty, just include the metadata } return data; } @@ -294,26 +304,25 @@ public int getMaxPollingErrorsBeforeRemoval() { @Override public void createTagSchema( - final @NotNull TagSchemaCreationInput input, final @NotNull TagSchemaCreationOutput output) { + final @NotNull TagSchemaCreationInput input, + final @NotNull TagSchemaCreationOutput output) { output.finish(JsonSchema.createJsonSchema()); } - private static boolean isSuccessStatusCode(final int statusCode) { - return statusCode >= 200 && statusCode <= 299; - } - - protected @NotNull SSLContext createTrustAllContext() { + private @NotNull SSLContext createTrustAllContext() { try { - final SSLContext sslContext = SSLContext.getInstance("TLS"); - final X509ExtendedTrustManager trustManager = new X509ExtendedTrustManager() { + final @NotNull SSLContext sslContext = SSLContext.getInstance("TLS"); + final @NotNull X509ExtendedTrustManager trustManager = new X509ExtendedTrustManager() { @Override public void checkClientTrusted( - final X509Certificate @NotNull [] x509Certificates, final @NotNull String s) { + final X509Certificate @NotNull [] x509Certificates, + final @NotNull String s) { } @Override public void checkServerTrusted( - final X509Certificate @NotNull [] x509Certificates, final @NotNull String s) { + final X509Certificate @NotNull [] x509Certificates, + final @NotNull String s) { } @Override From 12962b43df622f4dd64f38a55deb3b16af6f92bb Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 24 Oct 2025 12:14:54 +0200 Subject: [PATCH 38/50] strengthen tests --- .../hivemq/bootstrap/LoggingBootstrap.java | 1 + .../com/hivemq/edge/modules/ModuleLoader.java | 137 ++++++++---------- 2 files changed, 62 insertions(+), 76 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bootstrap/LoggingBootstrap.java b/hivemq-edge/src/main/java/com/hivemq/bootstrap/LoggingBootstrap.java index b2c8b6b657..a718867932 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bootstrap/LoggingBootstrap.java +++ b/hivemq-edge/src/main/java/com/hivemq/bootstrap/LoggingBootstrap.java @@ -214,6 +214,7 @@ public void onStart(final @NotNull LoggerContext context) { @Override public void onReset(final @NotNull LoggerContext context) { log.trace("logback.xml was changed"); + context.getTurboFilterList().remove(logLevelModifierTurboFilter); context.addTurboFilter(logLevelModifierTurboFilter); } diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/ModuleLoader.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/ModuleLoader.java index 19a63fc5d0..a20d2a7d20 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/ModuleLoader.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/ModuleLoader.java @@ -20,72 +20,72 @@ import com.hivemq.edge.modules.adapters.impl.IsolatedModuleClassloader; import com.hivemq.extensions.loader.ClassServiceLoader; import com.hivemq.http.handlers.AlternativeClassloadingStaticFileHandler; +import jakarta.inject.Inject; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; import java.io.File; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; public class ModuleLoader { - private static final Logger log = LoggerFactory.getLogger(ModuleLoader.class); - - private final @NotNull SystemInformation systemInformation; - protected final @NotNull Set modules = new HashSet<>(); - protected final @NotNull Comparator fileComparator = (o1, o2) -> { + protected static final @NotNull Comparator fileComparator = (o1, o2) -> { final long delta = o2.lastModified() - o1.lastModified(); - // we cna easily get an overflow within months, so we can not use the delta directly by casting it to integer! - if (delta == 0) { - return 0; - } else if (delta < 0) { - return -1; - } else { - return 1; - } + return delta == 0 ? 0 : delta < 0 ? -1 : 1; }; - - private final @NotNull ClassServiceLoader classServiceLoader = new ClassServiceLoader(); - private final AtomicBoolean loaded = new AtomicBoolean(); + private static final @NotNull Logger log = LoggerFactory.getLogger(ModuleLoader.class); + protected final @NotNull Set modules; + private final @NotNull SystemInformation systemInformation; + private final @NotNull ClassServiceLoader classServiceLoader; + private final @NotNull AtomicBoolean loaded; @Inject public ModuleLoader(final @NotNull SystemInformation systemInformation) { this.systemInformation = systemInformation; + this.classServiceLoader = new ClassServiceLoader(); + this.loaded = new AtomicBoolean(false); + this.modules = Collections.newSetFromMap(new ConcurrentHashMap<>()); + } + + private static void logException(final @NotNull File file, final @NotNull IOException ioException) { + log.warn("Exception with reason {} while reading module file {}", + ioException.getMessage(), + file.getAbsolutePath()); + log.debug("Original exception", ioException); } public void loadModules() { - if (loaded.get()) { - // avoid duplicate loads - return; - } - loaded.set(true); - final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (Boolean.getBoolean(HiveMQEdgeConstants.DEVELOPMENT_MODE)) { - log.info(String.format("Welcome '%s' is starting...", "48 69 76 65 4D 51 45 64 67 65")); - log.warn( - "\n################################################################################################################\n" + - "# You are running HiveMQ Edge in Development Mode and Modules will be loaded from your workspace NOT your #\n" + - "# HIVEMQ_HOME/modules directory. To load runtime modules from your HOME directory please remove #\n" + - "# '-Dhivemq.edge.workspace.modules=true' from your startup script #\n" + - "################################################################################################################"); - loadFromWorkspace(contextClassLoader); - // load the commercial module loader from the workspace folder - // the loadFromWorkspace() will not find it. - log.info("Loading the commercial module loader from workspace."); - loadCommercialModuleLoaderFromWorkSpace(contextClassLoader); - } else { - // the commercial module loader will be found here in case of a "normal" running hivemq edge - loadFromModulesDirectory(contextClassLoader); + if (loaded.compareAndSet(false, true)) { + final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (Boolean.getBoolean(HiveMQEdgeConstants.DEVELOPMENT_MODE)) { + log.info(String.format("Welcome '%s' is starting...", "48 69 76 65 4D 51 45 64 67 65")); + log.warn(""" + + ################################################################################################################ + # You are running HiveMQ Edge in Development Mode and Modules will be loaded from your workspace NOT your # + # HIVEMQ_HOME/modules directory. To load runtime modules from your HOME directory please remove # + # '-Dhivemq.edge.workspace.modules=true' from your startup script # + ################################################################################################################"""); + loadFromWorkspace(contextClassLoader); + // load the commercial module loader from the workspace folder + // the loadFromWorkspace() will not find it. + log.info("Loading the commercial module loader from workspace."); + loadCommercialModuleLoaderFromWorkSpace(contextClassLoader); + } else { + // the commercial module loader will be found here in case of a "normal" running hivemq edge + loadFromModulesDirectory(contextClassLoader); + } } } @@ -98,31 +98,24 @@ private void loadCommercialModuleLoaderFromWorkSpace(final @NotNull ClassLoader return; } - - final File commercialModuleLoaderLibFolder = - new File(commercialModulesRepoRootFolder, "hivemq-edge-commercial-modules-loader/build/libs"); - if (!commercialModuleLoaderLibFolder.exists()) { + final File libs = new File(commercialModulesRepoRootFolder, "hivemq-edge-commercial-modules-loader/build/libs"); + if (!libs.exists()) { log.error("Could not load commercial module loader as the assumed lib folder '{}' does not exist.", - commercialModuleLoaderLibFolder.getAbsolutePath()); + libs.getAbsolutePath()); return; } - - final File[] tmp = commercialModuleLoaderLibFolder.listFiles(file -> file.getName().endsWith("proguarded.jar")); - + final File[] tmp = libs.listFiles(file -> file.getName().endsWith("proguarded.jar")); if (tmp == null || tmp.length == 0) { - log.info("No commercial module loader jar was discovered in libs folder '{}'", - commercialModuleLoaderLibFolder); + log.info("No commercial module loader jar was discovered in libs folder '{}'", libs); return; } - final List potentialCommercialModuleJars = - new ArrayList<>(Arrays.stream(tmp).sorted(fileComparator).toList()); - - final String absolutePathJar = potentialCommercialModuleJars.get(0).getAbsolutePath(); - if (potentialCommercialModuleJars.size() > 1) { + final List jars = new ArrayList<>(Arrays.stream(tmp).sorted(fileComparator).toList()); + final String absolutePathJar = jars.get(0).getAbsolutePath(); + if (jars.size() > 1) { log.debug( "More than one commercial module loader jar was discovered in libs folder '{}'. Clean unwanted jars to avoid loading the wrong version. The first found jar '{}' will be loaded.", - commercialModuleLoaderLibFolder, + libs, absolutePathJar); } else { log.info("Commercial Module jar '{}' was discovered.", absolutePathJar); @@ -138,15 +131,14 @@ private void loadCommercialModulesLoaderJar(final File jarFile, final @NotNull C log.error("", e); } log.info("Loading commercial module loader from {}", jarFile.getAbsoluteFile()); - final IsolatedModuleClassloader isolatedClassloader = - new IsolatedModuleClassloader(urls.toArray(new URL[0]), parentClassloader); - modules.add(new ModuleLoader.EdgeModule(jarFile, isolatedClassloader, false)); + modules.add(new ModuleLoader.EdgeModule(jarFile, + new IsolatedModuleClassloader(urls.toArray(new URL[0]), parentClassloader), + false)); } protected void loadFromWorkspace(final @NotNull ClassLoader parentClassloader) { log.debug("Loading modules from development workspace."); - final File userDir = new File(System.getProperty("user.dir")); - loadFromWorkspace(parentClassloader, userDir); + loadFromWorkspace(parentClassloader, new File(System.getProperty("user.dir"))); } /** @@ -158,7 +150,7 @@ protected void loadFromWorkspace(final @NotNull ClassLoader parentClassloader) { * @param parentClassloader the parent classloader * @param currentDir the current dir */ - protected void loadFromWorkspace(final @NotNull ClassLoader parentClassloader, final @NotNull File currentDir) { + private void loadFromWorkspace(final @NotNull ClassLoader parentClassloader, final @NotNull File currentDir) { if (currentDir.exists() && currentDir.isDirectory()) { if (currentDir.getName().equals("hivemq-edge")) { discoverWorkspaceModule(new File(currentDir, "modules"), parentClassloader); @@ -173,7 +165,7 @@ protected void loadFromWorkspace(final @NotNull ClassLoader parentClassloader, f } } - protected void discoverWorkspaceModule(final File dir, final @NotNull ClassLoader parentClassloader) { + protected void discoverWorkspaceModule(final @NotNull File dir, final @NotNull ClassLoader parentClassloader) { if (dir.exists()) { final File[] files = dir.listFiles(pathname -> pathname.isDirectory() && pathname.canRead() && @@ -196,14 +188,11 @@ protected void discoverWorkspaceModule(final File dir, final @NotNull ClassLoade urls.add(jar.toURI().toURL()); } } - final IsolatedModuleClassloader isolatedClassloader = - new IsolatedModuleClassloader(urls.toArray(new URL[0]), parentClassloader); - modules.add(new EdgeModule(file, isolatedClassloader, true)); + modules.add(new EdgeModule(file, + new IsolatedModuleClassloader(urls.toArray(new URL[0]), parentClassloader), + true)); } catch (final IOException ioException) { - log.warn("Exception with reason {} while reading module file {}", - ioException.getMessage(), - file.getAbsolutePath()); - log.debug("Original exception", ioException); + logException(file, ioException); } } } @@ -225,10 +214,7 @@ protected void loadFromModulesDirectory(final @NotNull ClassLoader parentClasslo log.debug("Ignoring non jar file in module folder {}.", lib.getAbsolutePath()); } } catch (final IOException ioException) { - log.warn("Exception with reason {} while reading module file {}", - ioException.getMessage(), - lib.getAbsolutePath()); - log.debug("Original exception", ioException); + logException(lib, ioException); } } } @@ -256,7 +242,7 @@ protected void loadFromModulesDirectory(final @NotNull ClassLoader parentClasslo } public @NotNull Set getModules() { - return modules; + return Collections.unmodifiableSet(modules); } public void clear() { @@ -264,7 +250,6 @@ public void clear() { } public static class EdgeModule { - private final @NotNull File root; private final @NotNull ClassLoader classloader; From 2f6f2a561ec9a9db84282bf67e182fb0f2253afd Mon Sep 17 00:00:00 2001 From: marregui Date: Fri, 24 Oct 2025 13:06:09 +0200 Subject: [PATCH 39/50] strengthen tests, use awaitability --- .../databases/DatabaseConnection.java | 24 +++++++++++-------- .../DatabasesPollingProtocolAdapter.java | 15 +++--------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java index 201d2819d8..ebc37bbe95 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java @@ -22,6 +22,7 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; @@ -31,18 +32,19 @@ public class DatabaseConnection { private final @NotNull HikariConfig config; private @Nullable HikariDataSource ds; - public DatabaseConnection(final @NotNull DatabaseType dbType, - final @NotNull String server, - final @NotNull Integer port, - final @NotNull String database, - final @NotNull String username, - final @NotNull String password, - final int connectionTimeout, - final boolean encrypt) { + public DatabaseConnection( + final @NotNull DatabaseType dbType, + final @NotNull String server, + final @NotNull Integer port, + final @NotNull String database, + final @NotNull String username, + final @NotNull String password, + final int connectionTimeout, + final boolean encrypt) { config = new HikariConfig(); - switch (dbType){ + switch (dbType) { case POSTGRESQL -> { config.setDataSourceClassName("org.postgresql.ds.PGSimpleDataSource"); config.addDataSourceProperty("serverName", server); @@ -77,7 +79,9 @@ public DatabaseConnection(final @NotNull DatabaseType dbType, if (encrypt) { properties.setProperty("encrypt", "true"); properties.setProperty("trustServerCertificate", "true"); // Trust the server certificate implicitly - } else properties.setProperty("encrypt", "false"); + } else { + properties.setProperty("encrypt", "false"); + } config.setDataSourceProperties(properties); } } diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java index 74ffb18e6f..2b9b9fc9cd 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java @@ -18,7 +18,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; -import com.hivemq.adapter.sdk.api.config.PollingContext; import com.hivemq.adapter.sdk.api.factories.AdapterFactories; import com.hivemq.adapter.sdk.api.factories.DataPointFactory; import com.hivemq.adapter.sdk.api.model.ProtocolAdapterInput; @@ -26,20 +25,15 @@ import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStartOutput; import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopInput; import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopOutput; -import com.hivemq.adapter.sdk.api.polling.PollingInput; -import com.hivemq.adapter.sdk.api.polling.PollingOutput; -import com.hivemq.adapter.sdk.api.polling.PollingProtocolAdapter; import com.hivemq.adapter.sdk.api.polling.batch.BatchPollingInput; import com.hivemq.adapter.sdk.api.polling.batch.BatchPollingOutput; import com.hivemq.adapter.sdk.api.polling.batch.BatchPollingProtocolAdapter; import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; import com.hivemq.adapter.sdk.api.tag.Tag; -import com.hivemq.edge.adapters.databases.config.DatabaseType; import com.hivemq.edge.adapters.databases.config.DatabasesAdapterConfig; import com.hivemq.edge.adapters.databases.config.DatabasesAdapterTag; import com.hivemq.edge.adapters.databases.config.DatabasesAdapterTagDefinition; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,15 +46,12 @@ import java.util.ArrayList; import java.util.List; -import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.STATELESS; - public class DatabasesPollingProtocolAdapter implements BatchPollingProtocolAdapter { + public static final int TIMEOUT = 30; private static final @NotNull Logger log = LoggerFactory.getLogger(DatabasesPollingProtocolAdapter.class); private static final @NotNull ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public static final int TIMEOUT = 30; - private final @NotNull DatabasesAdapterConfig adapterConfig; private final @NotNull ProtocolAdapterInformation adapterInformation; private final @NotNull ProtocolAdapterState protocolAdapterState; @@ -98,7 +89,8 @@ public DatabasesPollingProtocolAdapter( @Override public void start( - final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + final @NotNull ProtocolAdapterStartInput input, + final @NotNull ProtocolAdapterStartOutput output) { log.debug("Loading PostgreSQL Driver"); try { Class.forName("org.postgresql.Driver"); @@ -124,7 +116,6 @@ public void start( } - databaseConnection.connect(); try { From c7e49deba55eb2fb6cbbeb42ce86e323aaaf8dbc Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 09:48:54 +0200 Subject: [PATCH 40/50] make database adapter/connection thread safe for FSM. ditto with file polling --- .../databases/DatabaseConnection.java | 12 ++- .../DatabasesPollingProtocolAdapter.java | 85 ++++++++++++------- .../file/FilePollingProtocolAdapter.java | 14 +++ 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java index ebc37bbe95..e2e1bfc24e 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java @@ -26,11 +26,13 @@ import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; public class DatabaseConnection { private static final @NotNull Logger log = LoggerFactory.getLogger(DatabaseConnection.class); private final @NotNull HikariConfig config; - private @Nullable HikariDataSource ds; + private final @NotNull AtomicBoolean connected = new AtomicBoolean(false); + private volatile @Nullable HikariDataSource ds; public DatabaseConnection( final @NotNull DatabaseType dbType, @@ -88,6 +90,10 @@ public DatabaseConnection( } public void connect() { + if (!connected.compareAndSet(false, true)) { + log.debug("Database connection already established, skipping connect"); + return; // Already connected + } log.debug("Connection settings : {}", config.toString()); this.ds = new HikariDataSource(config); } @@ -100,6 +106,10 @@ public void connect() { } public void close() { + if (!connected.compareAndSet(true, false)) { + log.debug("Database connection already closed or not connected"); + return; // Already closed or never connected + } if (ds != null && !ds.isClosed()) { log.debug("Closing HikariCP datasource"); try { diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java index 2b9b9fc9cd..f7031438d1 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java @@ -45,13 +45,14 @@ import java.sql.Types; import java.util.ArrayList; import java.util.List; - +import java.util.concurrent.atomic.AtomicBoolean; public class DatabasesPollingProtocolAdapter implements BatchPollingProtocolAdapter { public static final int TIMEOUT = 30; private static final @NotNull Logger log = LoggerFactory.getLogger(DatabasesPollingProtocolAdapter.class); private static final @NotNull ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final @NotNull DatabasesAdapterConfig adapterConfig; private final @NotNull ProtocolAdapterInformation adapterInformation; private final @NotNull ProtocolAdapterState protocolAdapterState; @@ -59,6 +60,7 @@ public class DatabasesPollingProtocolAdapter implements BatchPollingProtocolAdap private final @NotNull List tags; private final @NotNull DatabaseConnection databaseConnection; private final @NotNull AdapterFactories adapterFactories; + private final @NotNull AtomicBoolean started; public DatabasesPollingProtocolAdapter( final @NotNull ProtocolAdapterInformation adapterInformation, @@ -69,9 +71,8 @@ public DatabasesPollingProtocolAdapter( this.protocolAdapterState = input.getProtocolAdapterState(); this.tags = input.getTags(); this.adapterFactories = input.adapterFactories(); - + this.started = new AtomicBoolean(false); log.debug("Building connection string"); - this.databaseConnection = new DatabaseConnection(adapterConfig.getType(), adapterConfig.getServer(), adapterConfig.getPort(), @@ -91,46 +92,62 @@ public DatabasesPollingProtocolAdapter( public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { - log.debug("Loading PostgreSQL Driver"); - try { - Class.forName("org.postgresql.Driver"); - } catch (final ClassNotFoundException e) { - output.failStart(e, null); + if (!started.compareAndSet(false, true)) { + log.debug("Database adapter {} already started, returning success", adapterId); + output.startedSuccessfully(); return; } - log.debug("Loading MariaDB Driver (for MySQL)"); try { - Class.forName("org.mariadb.jdbc.Driver"); - } catch (final ClassNotFoundException e) { - output.failStart(e, null); - return; - } + log.debug("Loading PostgreSQL Driver"); + try { + Class.forName("org.postgresql.Driver"); + } catch (final ClassNotFoundException e) { + output.failStart(e, null); + started.set(false); + return; + } - log.debug("Loading MS SQL Driver"); - try { - Class.forName("com.microsoft.sqlserver.jdbc.SQLServerDataSource"); - } catch (final ClassNotFoundException e) { - output.failStart(e, null); - return; - } + log.debug("Loading MariaDB Driver (for MySQL)"); + try { + Class.forName("org.mariadb.jdbc.Driver"); + } catch (final ClassNotFoundException e) { + output.failStart(e, null); + started.set(false); + return; + } + log.debug("Loading MS SQL Driver"); + try { + Class.forName("com.microsoft.sqlserver.jdbc.SQLServerDataSource"); + } catch (final ClassNotFoundException e) { + output.failStart(e, null); + started.set(false); + return; + } - databaseConnection.connect(); + databaseConnection.connect(); - try { - log.debug("Starting connection to the database instance"); - if (databaseConnection.getConnection().isValid(TIMEOUT)) { - output.startedSuccessfully(); - protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.CONNECTED); - } else { - output.failStart(new Throwable("Error connecting database, please check the configuration"), - "Error connecting database, please check the configuration"); + try { + log.debug("Starting connection to the database instance"); + if (databaseConnection.getConnection().isValid(TIMEOUT)) { + output.startedSuccessfully(); + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.CONNECTED); + } else { + output.failStart(new Throwable("Error connecting database, please check the configuration"), + "Error connecting database, please check the configuration"); + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); + started.set(false); + } + } catch (final Exception e) { + output.failStart(e, null); protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); + started.set(false); } } catch (final Exception e) { + log.error("Unexpected error during adapter start", e); output.failStart(e, null); - protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); + started.set(false); } } @@ -138,6 +155,11 @@ public void start( public void stop( final @NotNull ProtocolAdapterStopInput protocolAdapterStopInput, final @NotNull ProtocolAdapterStopOutput protocolAdapterStopOutput) { + if (!started.compareAndSet(true, false)) { + log.debug("Database adapter {} already stopped, returning success", adapterId); + protocolAdapterStopOutput.stoppedSuccessfully(); + return; + } databaseConnection.close(); protocolAdapterStopOutput.stoppedSuccessfully(); } @@ -247,5 +269,4 @@ public int getPollingIntervalMillis() { public int getMaxPollingErrorsBeforeRemoval() { return adapterConfig.getMaxPollingErrorsBeforeRemoval(); } - } diff --git a/modules/hivemq-edge-module-file/src/main/java/com/hivemq/edge/adapters/file/FilePollingProtocolAdapter.java b/modules/hivemq-edge-module-file/src/main/java/com/hivemq/edge/adapters/file/FilePollingProtocolAdapter.java index 1278d31e56..d5a5767c51 100644 --- a/modules/hivemq-edge-module-file/src/main/java/com/hivemq/edge/adapters/file/FilePollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-file/src/main/java/com/hivemq/edge/adapters/file/FilePollingProtocolAdapter.java @@ -37,6 +37,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; public class FilePollingProtocolAdapter implements BatchPollingProtocolAdapter { @@ -48,6 +49,7 @@ public class FilePollingProtocolAdapter implements BatchPollingProtocolAdapter { private final @NotNull ProtocolAdapterInformation adapterInformation; private final @NotNull ProtocolAdapterState protocolAdapterState; private final @NotNull List tags; + private final @NotNull AtomicBoolean started; public FilePollingProtocolAdapter( final @NotNull String adapterId, @@ -58,6 +60,7 @@ public FilePollingProtocolAdapter( this.adapterConfig = input.getConfig(); this.tags = input.getTags().stream().map(tag -> (FileTag)tag).toList(); this.protocolAdapterState = input.getProtocolAdapterState(); + this.started = new AtomicBoolean(false); } @Override @@ -68,11 +71,17 @@ public FilePollingProtocolAdapter( @Override public void start( final @NotNull ProtocolAdapterStartInput input, final @NotNull ProtocolAdapterStartOutput output) { + if (!started.compareAndSet(false, true)) { + LOG.debug("File adapter {} already started, returning success", adapterId); + output.startedSuccessfully(); + return; + } // any setup which should be done before the adapter starts polling comes here. try { protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.STATELESS); output.startedSuccessfully(); } catch (final Exception e) { + started.set(false); output.failStart(e, null); } } @@ -81,6 +90,11 @@ public void start( public void stop( final @NotNull ProtocolAdapterStopInput protocolAdapterStopInput, final @NotNull ProtocolAdapterStopOutput protocolAdapterStopOutput) { + if (!started.compareAndSet(true, false)) { + LOG.debug("File adapter {} already stopped, returning success", adapterId); + protocolAdapterStopOutput.stoppedSuccessfully(); + return; + } protocolAdapterStopOutput.stoppedSuccessfully(); } From 48939c0cc1babf3b54c35c681189f0db353736ea Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 10:28:38 +0200 Subject: [PATCH 41/50] make CAS less aggressive, more fair, as per Sam's recommendation --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 38 ++++++++++++++++--- .../protocols/ProtocolAdapterWrapper.java | 21 ++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index 73a76d5c55..d37aed430c 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -25,6 +25,7 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; import java.util.function.Consumer; public abstract class ProtocolAdapterFSM implements Consumer { @@ -120,15 +121,24 @@ public void stopAdapter() { } public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) { + int retryCount = 0; while (true) { final var currentState = adapterState.get(); if (canTransition(currentState, newState)) { if (adapterState.compareAndSet(currentState, newState)) { + final State snapshotState = new State(newState, northboundState.get(), southboundState.get()); log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); + notifyListenersAboutStateTransition(snapshotState); return true; } - // CAS failed due to concurrent modification, retry + retryCount++; + if (retryCount > 3) { + // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs + // reduces CPU consumption and cache line contention under high load + final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); + LockSupport.parkNanos(backoffNanos); + } + // Fast retry for attempts 1-3 (optimizes for low contention case) } else { // Transition not allowed from current state throw new IllegalStateException("Cannot transition adapter state to " + newState); @@ -137,15 +147,23 @@ public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) } public boolean transitionNorthboundState(final @NotNull StateEnum newState) { + int retryCount = 0; while (true) { final var currentState = northboundState.get(); if (canTransition(currentState, newState)) { if (northboundState.compareAndSet(currentState, newState)) { + final State snapshotState = new State(adapterState.get(), newState, southboundState.get()); log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); + notifyListenersAboutStateTransition(snapshotState); return true; } - // CAS failed due to concurrent modification, retry + retryCount++; + if (retryCount > 3) { + // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs + final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); + LockSupport.parkNanos(backoffNanos); + } + // Fast retry for attempts 1-3 (optimizes for low contention case) } else { // Transition not allowed from current state throw new IllegalStateException("Cannot transition northbound state to " + newState); @@ -154,15 +172,23 @@ public boolean transitionNorthboundState(final @NotNull StateEnum newState) { } public boolean transitionSouthboundState(final @NotNull StateEnum newState) { + int retryCount = 0; while (true) { final var currentState = southboundState.get(); if (canTransition(currentState, newState)) { if (southboundState.compareAndSet(currentState, newState)) { + final State snapshotState = new State(adapterState.get(), northboundState.get(), newState); log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(currentState()); + notifyListenersAboutStateTransition(snapshotState); return true; } - // CAS failed due to concurrent modification, retry + retryCount++; + if (retryCount > 3) { + // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs + final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); + LockSupport.parkNanos(backoffNanos); + } + // Fast retry for attempts 1-3 (optimizes for low contention case) } else { // Transition not allowed from current state throw new IllegalStateException("Cannot transition southbound state to " + newState); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index bce5fdbe62..1e36d23559 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -84,6 +84,8 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private @Nullable CompletableFuture currentStartFuture; private @Nullable CompletableFuture currentStopFuture; private @Nullable Consumer connectionStatusListener; + private volatile boolean startOperationInProgress; + private volatile boolean stopOperationInProgress; public ProtocolAdapterWrapper( final @NotNull ProtocolAdapterMetricsService protocolAdapterMetricsService, @@ -164,8 +166,7 @@ public boolean startSouthbound() { public @NotNull CompletableFuture startAsync(final @NotNull ModuleServices moduleServices) { operationLock.lock(); try { - // validate state - if (currentStartFuture != null && !currentStartFuture.isDone()) { + if (startOperationInProgress) { log.info("Start operation already in progress for adapter '{}'", getId()); return currentStartFuture; } @@ -173,13 +174,14 @@ public boolean startSouthbound() { log.info("Adapter '{}' is already started, returning success", getId()); return CompletableFuture.completedFuture(null); } - if (currentStopFuture != null && !currentStopFuture.isDone()) { + if (stopOperationInProgress) { log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + adapter.getId() + "' while stop operation is in progress")); } + startOperationInProgress = true; lastStartAttemptTime = System.currentTimeMillis(); currentStartFuture = CompletableFuture.supplyAsync(startProtocolAdapter(moduleServices), sharedAdapterExecutor) @@ -196,6 +198,7 @@ public boolean startSouthbound() { } operationLock.lock(); try { + startOperationInProgress = false; currentStartFuture = null; } finally { operationLock.unlock(); @@ -210,8 +213,7 @@ public boolean startSouthbound() { public @NotNull CompletableFuture stopAsync() { operationLock.lock(); try { - // validate state - if (currentStopFuture != null && !currentStopFuture.isDone()) { + if (stopOperationInProgress) { log.info("Stop operation already in progress for adapter '{}'", getId()); return currentStopFuture; } @@ -220,13 +222,14 @@ public boolean startSouthbound() { log.info("Adapter '{}' is already stopped, returning success", getId()); return CompletableFuture.completedFuture(null); } - if (currentStartFuture != null && !currentStartFuture.isDone()) { + if (startOperationInProgress) { log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + adapter.getId() + "' while start operation is in progress")); } + stopOperationInProgress = true; log.debug("Adapter '{}': Creating stop operation future", getId()); currentStopFuture = CompletableFuture.supplyAsync(this::stopProtocolAdapter, sharedAdapterExecutor) .thenCompose(Function.identity()) @@ -244,6 +247,7 @@ public boolean startSouthbound() { } operationLock.lock(); try { + stopOperationInProgress = false; currentStopFuture = null; } finally { operationLock.unlock(); @@ -342,9 +346,10 @@ public boolean isBatchPolling() { } private void cleanupConnectionStatusListener() { - if (connectionStatusListener != null) { - protocolAdapterState.setConnectionStatusListener(CONNECTION_STATUS_NOOP_CONSUMER); + final Consumer listenerToClean = connectionStatusListener; + if (listenerToClean != null) { connectionStatusListener = null; + protocolAdapterState.setConnectionStatusListener(CONNECTION_STATUS_NOOP_CONSUMER); } } From a2535b49efd4b8e1a02814ee1f20c02b871b2e70 Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 14:40:33 +0200 Subject: [PATCH 42/50] remove stress over adapter by not restarting it and instead providing hot reload for mappings and tags (cheaper). will see how this improves stability. --- .../impl/ProtocolAdaptersResourceImpl.java | 93 ++++++---- .../protocols/ProtocolAdapterConfig.java | 36 +++- .../protocols/ProtocolAdapterManager.java | 72 ++++++-- .../protocols/ProtocolAdapterWrapper.java | 163 +++++++++++++++++- 4 files changed, 300 insertions(+), 64 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java index 7b2ce3eb4f..bccdba014d 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java @@ -70,6 +70,8 @@ import com.hivemq.edge.api.model.StatusTransitionResult; import com.hivemq.edge.api.model.TagSchema; import com.hivemq.edge.modules.adapters.impl.ProtocolAdapterDiscoveryOutputImpl; +import com.hivemq.persistence.mappings.NorthboundMapping; +import com.hivemq.persistence.mappings.SouthboundMapping; import com.hivemq.persistence.topicfilter.TopicFilterPersistence; import com.hivemq.persistence.topicfilter.TopicFilterPojo; import com.hivemq.protocols.InternalProtocolAdapterWritingService; @@ -803,22 +805,31 @@ public int getDepth() { missingTags)); } - return configExtractor.getAdapterByAdapterId(adapterId) - .map(cfg -> new ProtocolAdapterEntity(cfg.getAdapterId(), - cfg.getProtocolId(), - cfg.getConfigVersion(), - cfg.getConfig(), - converted, - cfg.getSouthboundMappings(), - cfg.getTags())) - .map(newCfg -> { - if (!configExtractor.updateAdapter(newCfg)) { - return adapterCannotBeUpdatedError(adapterId); - } - log.info("Successfully updated northbound mappings for adapter '{}'.", adapterId); - return Response.ok(northboundMappings).build(); - }) - .orElseGet(adapterNotUpdatedError(adapterId)); + final List convertedMappings = + converted.stream().map(NorthboundMappingEntity::toPersistence).toList(); + if (protocolAdapterManager.updateNorthboundMappingsHotReload(adapterId, convertedMappings)) { + // update config persistence + return configExtractor.getAdapterByAdapterId(adapterId) + .map(cfg -> new ProtocolAdapterEntity(cfg.getAdapterId(), + cfg.getProtocolId(), + cfg.getConfigVersion(), + cfg.getConfig(), + converted, + cfg.getSouthboundMappings(), + cfg.getTags())) + .map(newCfg -> { + if (!configExtractor.updateAdapter(newCfg)) { + return adapterCannotBeUpdatedError(adapterId); + } + log.info("Successfully updated northbound mappings for adapter '{}' via hot-reload.", + adapterId); + return Response.ok(northboundMappings).build(); + }) + .orElseGet(adapterNotUpdatedError(adapterId)); + } else { + log.error("Hot-reload failed for northbound mappings on adapter '{}'", adapterId); + return errorResponse(new InternalServerError("Failed to hot-reload northbound mappings")); + } }; } @@ -841,22 +852,31 @@ public int getDepth() { missingTags)); } - return configExtractor.getAdapterByAdapterId(adapterId) - .map(cfg -> new ProtocolAdapterEntity(cfg.getAdapterId(), - cfg.getProtocolId(), - cfg.getConfigVersion(), - cfg.getConfig(), - cfg.getNorthboundMappings(), - converted, - cfg.getTags())) - .map(newCfg -> { - if (!configExtractor.updateAdapter(newCfg)) { - return adapterCannotBeUpdatedError(adapterId); - } - log.info("Successfully updated fromMappings for adapter '{}'.", adapterId); - return Response.ok(southboundMappings).build(); - }) - .orElseGet(adapterNotUpdatedError(adapterId)); + final List convertedMappings = + converted.stream().map(entity -> entity.toPersistence(objectMapper)).toList(); + if (protocolAdapterManager.updateSouthboundMappingsHotReload(adapterId, convertedMappings)) { + // update config persistence + return configExtractor.getAdapterByAdapterId(adapterId) + .map(cfg -> new ProtocolAdapterEntity(cfg.getAdapterId(), + cfg.getProtocolId(), + cfg.getConfigVersion(), + cfg.getConfig(), + cfg.getNorthboundMappings(), + converted, + cfg.getTags())) + .map(newCfg -> { + if (!configExtractor.updateAdapter(newCfg)) { + return adapterCannotBeUpdatedError(adapterId); + } + log.info("Successfully updated southbound mappings for adapter '{}' via hot-reload.", + adapterId); + return Response.ok(southboundMappings).build(); + }) + .orElseGet(adapterNotUpdatedError(adapterId)); + } else { + log.error("Hot-reload failed for southbound mappings on adapter '{}'", adapterId); + return errorResponse(new InternalServerError("Failed to hot-reload southbound mappings")); + } }; } @@ -911,11 +931,10 @@ private void validateAdapterSchema( private @NotNull Adapter toAdapter(final @NotNull ProtocolAdapterWrapper value) { final String adapterId = value.getId(); - final Map config = runWithContextLoader( - value.getAdapterFactory().getClass().getClassLoader(), - () -> { - final Map cfg = value.getAdapterFactory() - .unconvertConfigObject(objectMapper, value.getConfigObject()); + final Map config = + runWithContextLoader(value.getAdapterFactory().getClass().getClassLoader(), () -> { + final Map cfg = + value.getAdapterFactory().unconvertConfigObject(objectMapper, value.getConfigObject()); cfg.put("id", value.getId()); return cfg; }); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java index 07375c919d..e52f22930d 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java @@ -30,12 +30,12 @@ public class ProtocolAdapterConfig { private final @NotNull ProtocolSpecificAdapterConfig adapterConfig; - private final @NotNull List tags; + private @NotNull List tags; private final @NotNull String adapterId; private final @NotNull String protocolId; private final int configVersion; - private final @NotNull List southboundMappings; - private final @NotNull List northboundMappings; + private @NotNull List southboundMappings; + private @NotNull List northboundMappings; public ProtocolAdapterConfig( final @NotNull String adapterId, @@ -99,6 +99,36 @@ public int getConfigVersion() { return configVersion; } + /** + * Updates the tags for hot-reload support. + * This method is used to update tags without restarting the adapter. + * + * @param tags the new tags + */ + public void setTags(final @NotNull List tags) { + this.tags = tags; + } + + /** + * Updates the northbound mappings for hot-reload support. + * This method is used to update northbound mappings without restarting the adapter. + * + * @param northboundMappings the new northbound mappings + */ + public void setNorthboundMappings(final @NotNull List northboundMappings) { + this.northboundMappings = northboundMappings; + } + + /** + * Updates the southbound mappings for hot-reload support. + * This method is used to update southbound mappings without restarting the adapter. + * + * @param southboundMappings the new southbound mappings + */ + public void setSouthboundMappings(final @NotNull List southboundMappings) { + this.southboundMappings = southboundMappings; + } + @Override public boolean equals(final Object o) { if (o == null || getClass() != o.getClass()) return false; diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 66f38f02a3..046d719ff7 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -40,6 +40,8 @@ import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingService; import com.hivemq.persistence.domain.DomainTag; import com.hivemq.persistence.domain.DomainTagAddResult; +import com.hivemq.persistence.mappings.NorthboundMapping; +import com.hivemq.persistence.mappings.SouthboundMapping; import com.hivemq.protocols.northbound.NorthboundConsumerFactory; import jakarta.inject.Inject; import jakarta.inject.Singleton; @@ -305,28 +307,68 @@ public boolean isWritingEnabled() { if (alreadyExists) { return DomainTagAddResult.failed(ALREADY_EXISTS, adapterId); } - deleteAdapterInternal(wrapper.getId()); + try { - tags.add(configConverter.domainTagToTag(wrapper.getProtocolAdapterInformation().getProtocolId(), - domainTag)); - startAsync(createAdapterInternal(new ProtocolAdapterConfig(wrapper.getId(), - wrapper.getAdapterInformation().getProtocolId(), - wrapper.getAdapterInformation().getCurrentConfigVersion(), - wrapper.getConfigObject(), - wrapper.getSouthboundMappings(), - wrapper.getNorthboundMappings(), - tags), versionProvider.getVersion())).get(); + final var convertedTag = + configConverter.domainTagToTag(wrapper.getProtocolAdapterInformation().getProtocolId(), + domainTag); + + // Use hot-reload to add tag without restarting the adapter + log.debug("Adding tag '{}' to adapter '{}' via hot-reload", domainTag.getTagName(), adapterId); + wrapper.addTagHotReload(convertedTag, eventService); + + log.info("Successfully added tag '{}' to adapter '{}' via hot-reload", + domainTag.getTagName(), + adapterId); return DomainTagAddResult.success(); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Interrupted while async execution: ", e.getCause()); + } catch (final IllegalStateException e) { + log.error("Cannot hot-reload tag, adapter not in correct state: {}", e.getMessage()); + return DomainTagAddResult.failed(ADAPTER_FAILED_TO_START, adapterId); } catch (final Throwable e) { - log.error("Exception happened while async execution: ", e.getCause()); + log.error("Exception happened while adding tag via hot-reload: ", e); + return DomainTagAddResult.failed(ADAPTER_FAILED_TO_START, adapterId); } - return DomainTagAddResult.failed(ADAPTER_FAILED_TO_START, adapterId); }).orElse(DomainTagAddResult.failed(ADAPTER_MISSING, adapterId)); } + public boolean updateNorthboundMappingsHotReload( + final @NotNull String adapterId, + final @NotNull List northboundMappings) { + return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { + try { + log.debug("Updating northbound mappings for adapter '{}' via hot-reload", adapterId); + wrapper.updateMappingsHotReload(northboundMappings, null, eventService); + log.info("Successfully updated northbound mappings for adapter '{}' via hot-reload", adapterId); + return true; + } catch (final IllegalStateException e) { + log.error("Cannot hot-reload northbound mappings, adapter not in correct state: {}", e.getMessage()); + return false; + } catch (final Throwable e) { + log.error("Exception happened while updating northbound mappings via hot-reload: ", e); + return false; + } + }).orElse(false); + } + + public boolean updateSouthboundMappingsHotReload( + final @NotNull String adapterId, + final @NotNull List southboundMappings) { + return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { + try { + log.debug("Updating southbound mappings for adapter '{}' via hot-reload", adapterId); + wrapper.updateMappingsHotReload(null, southboundMappings, eventService); + log.info("Successfully updated southbound mappings for adapter '{}' via hot-reload", adapterId); + return true; + } catch (final IllegalStateException e) { + log.error("Cannot hot-reload southbound mappings, adapter not in correct state: {}", e.getMessage()); + return false; + } catch (final Throwable e) { + log.error("Exception happened while updating southbound mappings via hot-reload: ", e); + return false; + } + }).orElse(false); + } + public @NotNull List getDomainTags() { return protocolAdapters.values() .stream() diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 1e36d23559..d017871355 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -45,6 +45,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -175,7 +176,21 @@ public boolean startSouthbound() { return CompletableFuture.completedFuture(null); } if (stopOperationInProgress) { - log.warn("Cannot start adapter '{}' while stop operation is in progress", getId()); + log.warn("Stop operation in progress for adapter '{}', waiting for it to complete before starting", + getId()); + final CompletableFuture stopFuture = currentStopFuture; + if (stopFuture != null) { + // Wait for stop to complete, then retry start + return stopFuture.handle((result, throwable) -> { + if (throwable != null) { + log.warn("Stop operation failed for adapter '{}', but proceeding with start", + getId(), + throwable); + } + return null; + }).thenCompose(v -> startAsync(moduleServices)); + } + log.error("Stop operation in progress but currentStopFuture is null for adapter '{}'", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot start adapter '" + adapter.getId() + "' while stop operation is in progress")); @@ -223,7 +238,21 @@ public boolean startSouthbound() { return CompletableFuture.completedFuture(null); } if (startOperationInProgress) { - log.warn("Cannot stop adapter '{}' while start operation is in progress", getId()); + log.warn("Start operation in progress for adapter '{}', waiting for it to complete before stopping", + getId()); + final CompletableFuture startFuture = currentStartFuture; + if (startFuture != null) { + // Wait for start to complete, then retry stop + return startFuture.handle((result, throwable) -> { + if (throwable != null) { + log.warn("Start operation failed for adapter '{}', but proceeding with stop", + getId(), + throwable); + } + return null; + }).thenCompose(v -> stopAsync()); + } + log.error("Start operation in progress but currentStartFuture is null for adapter '{}'", getId()); return CompletableFuture.failedFuture(new IllegalStateException("Cannot stop adapter '" + adapter.getId() + "' while start operation is in progress")); @@ -345,6 +374,81 @@ public boolean isBatchPolling() { return adapter instanceof BatchPollingProtocolAdapter; } + public void addTagHotReload(final @NotNull Tag tag, final @NotNull EventService eventService) { + operationLock.lock(); + try { + // Update config with new tag regardless of adapter state + final List updatedTags = new ArrayList<>(config.getTags()); + updatedTags.add(tag); + config.setTags(updatedTags); + if (adapterState.get() != AdapterStateEnum.STARTED) { + log.debug("Adapter '{}' not started yet, only updating config for tag '{}'", getId(), tag.getName()); + return; + } + if (isPolling()) { + log.debug("Starting polling for new tag '{}' on adapter '{}'", tag.getName(), getId()); + pollingService.schedulePolling(new PerContextSampler(this, + new PollingContextWrapper("unused", + tag.getName(), + MessageHandlingOptions.MQTTMessagePerTag, + false, + false, + List.of(), + 1, + -1), + eventService, + tagManager)); + } + log.info("Successfully added tag '{}' to adapter '{}' via hot-reload", tag.getName(), getId()); + } finally { + operationLock.unlock(); + } + } + + public void updateMappingsHotReload( + final @Nullable List northboundMappings, + final @Nullable List southboundMappings, + final @NotNull EventService eventService) { + operationLock.lock(); + try { + if (northboundMappings != null) { + config.setNorthboundMappings(northboundMappings); + } + if (southboundMappings != null) { + config.setSouthboundMappings(southboundMappings); + } + if (adapterState.get() != AdapterStateEnum.STARTED) { + log.debug("Adapter '{}' not started yet, only updating config for mappings", getId()); + return; + } + log.debug("Stopping existing consumers for adapter '{}'", getId()); + consumers.forEach(tagManager::removeConsumer); + consumers.clear(); + if (northboundMappings != null) { + log.debug("Updating northbound mappings for adapter '{}'", getId()); + northboundMappings.stream() + .map(mapping -> northboundConsumerFactory.build(this, mapping, protocolAdapterMetricsService)) + .forEach(consumer -> { + tagManager.addConsumer(consumer); + consumers.add(consumer); + }); + } + if (southboundMappings != null && isWriting()) { + log.debug("Updating southbound mappings for adapter '{}'", getId()); + final StateEnum currentSouthboundState = currentState().southbound(); + if (currentSouthboundState == StateEnum.CONNECTED) { + stopWriting(); + } + if (currentSouthboundState == StateEnum.CONNECTED) { + startSouthbound(); + } + } + log.info("Successfully updated mappings for adapter '{}' via hot-reload", getId()); + } finally { + operationLock.unlock(); + } + } + private void cleanupConnectionStatusListener() { final Consumer listenerToClean = connectionStatusListener; if (listenerToClean != null) { @@ -401,18 +505,42 @@ private void cleanupConnectionStatusListener() { private void stopPolling() { if (isPolling() || isBatchPolling()) { log.debug("Stopping polling for protocol adapter with id '{}'", getId()); - pollingService.stopPollingForAdapterInstance(adapter); + try { + pollingService.stopPollingForAdapterInstance(adapter); + log.debug("Polling stopped successfully for adapter '{}'", getId()); + } catch (final Exception e) { + log.error("Error stopping polling for adapter '{}'", getId(), e); + } } } private void stopWriting() { if (isWriting()) { log.debug("Stopping writing for protocol adapter with id '{}'", getId()); - writingService.stopWriting((WritingProtocolAdapter) adapter, - config.getSouthboundMappings() - .stream() - .map(mapping -> (InternalWritingContext) new InternalWritingContextImpl(mapping)) - .toList()); + try { + // Transition southbound state to indicate shutdown in progress + final StateEnum currentSouthboundState = currentState().southbound(); + if (currentSouthboundState == StateEnum.CONNECTED) { + transitionSouthboundState(StateEnum.DISCONNECTING); + } + writingService.stopWriting((WritingProtocolAdapter) adapter, + config.getSouthboundMappings() + .stream() + .map(mapping -> (InternalWritingContext) new InternalWritingContextImpl(mapping)) + .toList()); + if (currentSouthboundState == StateEnum.CONNECTED || + currentSouthboundState == StateEnum.DISCONNECTING) { + transitionSouthboundState(StateEnum.DISCONNECTED); + } + log.debug("Writing stopped successfully for adapter '{}'", getId()); + } catch (final IllegalStateException stateException) { + // State transition failed, log but continue cleanup + log.warn("State transition failed while stopping writing for adapter '{}': {}", + getId(), + stateException.getMessage()); + } catch (final Exception e) { + log.error("Error stopping writing for adapter '{}'", getId(), e); + } } } @@ -436,9 +564,17 @@ private void stopWriting() { Thread.currentThread().getName()); stopAdapter(); cleanupConnectionStatusListener(); - consumers.forEach(tagManager::removeConsumer); + try { + consumers.forEach(tagManager::removeConsumer); + consumers.clear(); + log.debug("Adapter '{}': Consumers cleaned up successfully", getId()); + } catch (final Exception e) { + log.error("Adapter '{}': Error cleaning up consumers", getId(), e); + } + stopPolling(); stopWriting(); + final var output = new ProtocolAdapterStopOutputImpl(); try { adapter.stop(new ProtocolAdapterStopInputImpl(), output); @@ -454,8 +590,17 @@ private void stopProtocolAdapterOnFailedStart() { log.warn("Stopping adapter with id {} after a failed start", adapter.getId()); stopAdapter(); cleanupConnectionStatusListener(); + try { + consumers.forEach(tagManager::removeConsumer); + consumers.clear(); + log.debug("Adapter '{}': Consumers cleaned up after failed start", getId()); + } catch (final Exception e) { + log.error("Adapter '{}': Error cleaning up consumers after failed start", getId(), e); + } + stopPolling(); stopWriting(); + final var output = new ProtocolAdapterStopOutputImpl(); try { adapter.stop(new ProtocolAdapterStopInputImpl(), output); From 61f2a96ea80fa256e702567355f9cda4893aac17 Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 14:55:59 +0200 Subject: [PATCH 43/50] simplify code --- .../com/hivemq/fsm/ProtocolAdapterFSM.java | 242 ++++++++++-------- .../protocols/ProtocolAdapterManager.java | 57 ++--- .../protocols/ProtocolAdapterWrapper.java | 68 +++-- 3 files changed, 191 insertions(+), 176 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java index d37aed430c..b75a015794 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/ProtocolAdapterFSM.java @@ -26,57 +26,61 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; +import java.util.function.BiFunction; +import java.util.function.BiPredicate; import java.util.function.Consumer; public abstract class ProtocolAdapterFSM implements Consumer { - private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); - - public enum StateEnum { - DISCONNECTED, - CONNECTING, - CONNECTED, - DISCONNECTING, - ERROR_CLOSING, - CLOSING, - ERROR, - CLOSED, - NOT_SUPPORTED - } - - public static final @NotNull Map> possibleTransitions = Map.of( - StateEnum.DISCONNECTED, Set.of(StateEnum.DISCONNECTED, StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.CLOSED, StateEnum.NOT_SUPPORTED), //allow idempotent DISCONNECTED->DISCONNECTED transitions; for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED; NOT_SUPPORTED for adapters without southbound - StateEnum.CONNECTING, Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), // allow idempotent CONNECTING->CONNECTING; can go back to DISCONNECTED - StateEnum.CONNECTED, Set.of(StateEnum.CONNECTED, StateEnum.DISCONNECTING, StateEnum.CONNECTING, StateEnum.CLOSING, StateEnum.ERROR_CLOSING, StateEnum.DISCONNECTED), // allow idempotent CONNECTED->CONNECTED; transition to CONNECTING in case of recovery, DISCONNECTED for direct transition - StateEnum.DISCONNECTING, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can go to DISCONNECTED or CLOSING - StateEnum.CLOSING, Set.of(StateEnum.CLOSED), - StateEnum.ERROR_CLOSING, Set.of(StateEnum.ERROR), - StateEnum.ERROR, Set.of(StateEnum.ERROR, StateEnum.CONNECTING, StateEnum.DISCONNECTED), // allow idempotent ERROR->ERROR; can recover from error - StateEnum.CLOSED, Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), // can restart from closed or go to closing - StateEnum.NOT_SUPPORTED, Set.of(StateEnum.NOT_SUPPORTED) // Terminal state for adapters without southbound support; allow idempotent transitions + public static final @NotNull Map> possibleTransitions = Map.of(StateEnum.DISCONNECTED, + Set.of(StateEnum.DISCONNECTED, + StateEnum.CONNECTING, + StateEnum.CONNECTED, + StateEnum.CLOSED, + StateEnum.NOT_SUPPORTED), + //allow idempotent DISCONNECTED->DISCONNECTED transitions; for compatibility, we allow to go from CONNECTING to CONNECTED directly, and allow testing transition to CLOSED; NOT_SUPPORTED for adapters without southbound + StateEnum.CONNECTING, + Set.of(StateEnum.CONNECTING, StateEnum.CONNECTED, StateEnum.ERROR, StateEnum.DISCONNECTED), + // allow idempotent CONNECTING->CONNECTING; can go back to DISCONNECTED + StateEnum.CONNECTED, + Set.of(StateEnum.CONNECTED, + StateEnum.DISCONNECTING, + StateEnum.CONNECTING, + StateEnum.CLOSING, + StateEnum.ERROR_CLOSING, + StateEnum.DISCONNECTED), + // allow idempotent CONNECTED->CONNECTED; transition to CONNECTING in case of recovery, DISCONNECTED for direct transition + StateEnum.DISCONNECTING, + Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), + // can go to DISCONNECTED or CLOSING + StateEnum.CLOSING, + Set.of(StateEnum.CLOSED), + StateEnum.ERROR_CLOSING, + Set.of(StateEnum.ERROR), + StateEnum.ERROR, + Set.of(StateEnum.ERROR, StateEnum.CONNECTING, StateEnum.DISCONNECTED), + // allow idempotent ERROR->ERROR; can recover from error + StateEnum.CLOSED, + Set.of(StateEnum.DISCONNECTED, StateEnum.CLOSING), + // can restart from closed or go to closing + StateEnum.NOT_SUPPORTED, + Set.of(StateEnum.NOT_SUPPORTED) + // Terminal state for adapters without southbound support; allow idempotent transitions ); - - public enum AdapterStateEnum { - STARTING, - STARTED, - STOPPING, - STOPPED - } - public static final Map> possibleAdapterStateTransitions = Map.of( - AdapterStateEnum.STOPPED, Set.of(AdapterStateEnum.STARTING), - AdapterStateEnum.STARTING, Set.of(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPED), - AdapterStateEnum.STARTED, Set.of(AdapterStateEnum.STOPPING), - AdapterStateEnum.STOPPING, Set.of(AdapterStateEnum.STOPPED) - ); - + AdapterStateEnum.STOPPED, + Set.of(AdapterStateEnum.STARTING), + AdapterStateEnum.STARTING, + Set.of(AdapterStateEnum.STARTED, AdapterStateEnum.STOPPED), + AdapterStateEnum.STARTED, + Set.of(AdapterStateEnum.STOPPING), + AdapterStateEnum.STOPPING, + Set.of(AdapterStateEnum.STOPPED)); + private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterFSM.class); + protected final @NotNull AtomicReference adapterState; private final @NotNull AtomicReference northboundState; private final @NotNull AtomicReference southboundState; - protected final @NotNull AtomicReference adapterState; private final @NotNull List> stateTransitionListeners; - - public record State(AdapterStateEnum adapter, StateEnum northbound, StateEnum southbound) { } - private final @NotNull String adapterId; public ProtocolAdapterFSM(final @NotNull String adapterId) { @@ -87,6 +91,11 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { this.stateTransitionListeners = new CopyOnWriteArrayList<>(); } + private static boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { + final var allowedTransitions = possibleTransitions.get(currentState); + return allowedTransitions != null && allowedTransitions.contains(newState); + } + public abstract boolean onStarting(); public abstract void onStopping(); @@ -95,10 +104,10 @@ public ProtocolAdapterFSM(final @NotNull String adapterId) { // ADAPTER signals public void startAdapter() { - if(transitionAdapterState(AdapterStateEnum.STARTING)) { + if (transitionAdapterState(AdapterStateEnum.STARTING)) { log.debug("Protocol adapter {} starting", adapterId); - if(onStarting()) { - if(!transitionAdapterState(AdapterStateEnum.STARTED)) { + if (onStarting()) { + if (!transitionAdapterState(AdapterStateEnum.STARTED)) { log.warn("Protocol adapter {} already started", adapterId); } } else { @@ -110,9 +119,9 @@ public void startAdapter() { } public void stopAdapter() { - if(transitionAdapterState(AdapterStateEnum.STOPPING)) { + if (transitionAdapterState(AdapterStateEnum.STOPPING)) { onStopping(); - if(!transitionAdapterState(AdapterStateEnum.STOPPED)) { + if (!transitionAdapterState(AdapterStateEnum.STOPPED)) { log.warn("Protocol adapter {} already stopped", adapterId); } } else { @@ -121,77 +130,76 @@ public void stopAdapter() { } public boolean transitionAdapterState(final @NotNull AdapterStateEnum newState) { - int retryCount = 0; - while (true) { - final var currentState = adapterState.get(); - if (canTransition(currentState, newState)) { - if (adapterState.compareAndSet(currentState, newState)) { - final State snapshotState = new State(newState, northboundState.get(), southboundState.get()); - log.debug("Adapter state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(snapshotState); - return true; - } - retryCount++; - if (retryCount > 3) { - // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs - // reduces CPU consumption and cache line contention under high load - final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); - LockSupport.parkNanos(backoffNanos); - } - // Fast retry for attempts 1-3 (optimizes for low contention case) - } else { - // Transition not allowed from current state - throw new IllegalStateException("Cannot transition adapter state to " + newState); - } - } + return performStateTransition(adapterState, + newState, + this::canTransition, + "Adapter", + (current, next) -> new State(next, northboundState.get(), southboundState.get())); } public boolean transitionNorthboundState(final @NotNull StateEnum newState) { - int retryCount = 0; - while (true) { - final var currentState = northboundState.get(); - if (canTransition(currentState, newState)) { - if (northboundState.compareAndSet(currentState, newState)) { - final State snapshotState = new State(adapterState.get(), newState, southboundState.get()); - log.debug("Northbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); - notifyListenersAboutStateTransition(snapshotState); - return true; - } - retryCount++; - if (retryCount > 3) { - // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs - final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); - LockSupport.parkNanos(backoffNanos); - } - // Fast retry for attempts 1-3 (optimizes for low contention case) - } else { - // Transition not allowed from current state - throw new IllegalStateException("Cannot transition northbound state to " + newState); - } - } + return performStateTransition(northboundState, + newState, + ProtocolAdapterFSM::canTransition, + "Northbound", + (current, next) -> new State(adapterState.get(), next, southboundState.get())); } public boolean transitionSouthboundState(final @NotNull StateEnum newState) { + return performStateTransition(southboundState, + newState, + ProtocolAdapterFSM::canTransition, + "Southbound", + (current, next) -> new State(adapterState.get(), northboundState.get(), next)); + } + + /** + * Generic state transition implementation with retry logic and exponential backoff. + * Eliminates code duplication across adapter, northbound, and southbound transitions. + * + * @param stateRef the atomic reference to the state being transitioned + * @param newState the target state + * @param canTransitionFn function to check if transition is valid + * @param stateName name of the state type for logging + * @param stateSnapshotFn function to create state snapshot after successful transition + * @param the state type (StateEnum or AdapterStateEnum) + * @return true if transition succeeded + * @throws IllegalStateException if transition is not allowed from current state + */ + private boolean performStateTransition( + final @NotNull AtomicReference stateRef, + final @NotNull T newState, + final @NotNull BiPredicate canTransitionFn, + final @NotNull String stateName, + final @NotNull BiFunction stateSnapshotFn) { int retryCount = 0; while (true) { - final var currentState = southboundState.get(); - if (canTransition(currentState, newState)) { - if (southboundState.compareAndSet(currentState, newState)) { - final State snapshotState = new State(adapterState.get(), northboundState.get(), newState); - log.debug("Southbound state transition from {} to {} for adapter {}", currentState, newState, adapterId); + final T currentState = stateRef.get(); + if (canTransitionFn.test(currentState, newState)) { + if (stateRef.compareAndSet(currentState, newState)) { + final State snapshotState = stateSnapshotFn.apply(currentState, newState); + log.debug("{} state transition from {} to {} for adapter {}", + stateName, + currentState, + newState, + adapterId); notifyListenersAboutStateTransition(snapshotState); return true; } retryCount++; if (retryCount > 3) { // progressive backoff: 1μs, 2μs, 4μs, 8μs, ..., capped at 100μs + // reduces CPU consumption and cache line contention under high load final long backoffNanos = Math.min(1_000L * (1L << (retryCount - 4)), 100_000L); LockSupport.parkNanos(backoffNanos); } // Fast retry for attempts 1-3 (optimizes for low contention case) } else { // Transition not allowed from current state - throw new IllegalStateException("Cannot transition southbound state to " + newState); + throw new IllegalStateException("Cannot transition " + + stateName.toLowerCase() + + " state to " + + newState); } } } @@ -199,21 +207,18 @@ public boolean transitionSouthboundState(final @NotNull StateEnum newState) { @Override public void accept(final ProtocolAdapterState.ConnectionStatus connectionStatus) { final var transitionResult = switch (connectionStatus) { - case CONNECTED -> - transitionNorthboundState(StateEnum.CONNECTED) && startSouthbound(); + case CONNECTED -> transitionNorthboundState(StateEnum.CONNECTED) && startSouthbound(); case CONNECTING -> transitionNorthboundState(StateEnum.CONNECTING); case DISCONNECTED -> transitionNorthboundState(StateEnum.DISCONNECTED); case ERROR -> transitionNorthboundState(StateEnum.ERROR); case UNKNOWN -> transitionNorthboundState(StateEnum.DISCONNECTED); case STATELESS -> transitionNorthboundState(StateEnum.NOT_SUPPORTED); }; - if(!transitionResult) { + if (!transitionResult) { log.warn("Failed to transition connection state to {} for adapter {}", connectionStatus, adapterId); } } - // Additional methods to support full state machine functionality - public boolean startDisconnecting() { return transitionNorthboundState(StateEnum.DISCONNECTING); } @@ -222,6 +227,8 @@ public boolean startClosing() { return transitionNorthboundState(StateEnum.CLOSING); } + // Additional methods to support full state machine functionality + public boolean startErrorClosing() { return transitionNorthboundState(StateEnum.ERROR_CLOSING); } @@ -279,14 +286,33 @@ private void notifyListenersAboutStateTransition(final @NotNull State newState) stateTransitionListeners.forEach(listener -> listener.accept(newState)); } - private static boolean canTransition(final @NotNull StateEnum currentState, final @NotNull StateEnum newState) { - final var allowedTransitions = possibleTransitions.get(currentState); + private boolean canTransition( + final @NotNull AdapterStateEnum currentState, + final @NotNull AdapterStateEnum newState) { + final var allowedTransitions = possibleAdapterStateTransitions.get(currentState); return allowedTransitions != null && allowedTransitions.contains(newState); } - private static boolean canTransition(final @NotNull AdapterStateEnum currentState, final @NotNull AdapterStateEnum newState) { - final var allowedTransitions = possibleAdapterStateTransitions.get(currentState); - return allowedTransitions != null && allowedTransitions.contains(newState); + public enum StateEnum { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + ERROR_CLOSING, + CLOSING, + ERROR, + CLOSED, + NOT_SUPPORTED + } + + public enum AdapterStateEnum { + STARTING, + STARTED, + STOPPING, + STOPPED + } + + public record State(AdapterStateEnum adapter, StateEnum northbound, StateEnum southbound) { } } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 046d719ff7..1d156e745d 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -68,6 +68,7 @@ import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_FAILED_TO_START; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ADAPTER_MISSING; import static com.hivemq.persistence.domain.DomainTagAddResult.DomainTagPutStatus.ALREADY_EXISTS; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.failedFuture; @SuppressWarnings("unchecked") @@ -154,6 +155,24 @@ public void run() { } } + private static boolean updateMappingsHotReload( + final @NotNull ProtocolAdapterWrapper wrapper, + final @NotNull String mappingType, + final @NotNull Runnable updateOperation) { + try { + log.debug("Updating {} mappings for adapter '{}' via hot-reload", mappingType, wrapper.getId()); + updateOperation.run(); + log.info("Successfully updated {} mappings for adapter '{}' via hot-reload", mappingType, wrapper.getId()); + return true; + } catch (final IllegalStateException e) { + log.error("Cannot hot-reload {} mappings, adapter not in correct state: {}", mappingType, e.getMessage()); + return false; + } catch (final Throwable e) { + log.error("Exception happened while updating {} mappings via hot-reload: ", mappingType, e); + return false; + } + } + public void start() { if (log.isDebugEnabled()) { log.debug("Starting adapters"); @@ -334,39 +353,17 @@ public boolean isWritingEnabled() { public boolean updateNorthboundMappingsHotReload( final @NotNull String adapterId, final @NotNull List northboundMappings) { - return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { - try { - log.debug("Updating northbound mappings for adapter '{}' via hot-reload", adapterId); - wrapper.updateMappingsHotReload(northboundMappings, null, eventService); - log.info("Successfully updated northbound mappings for adapter '{}' via hot-reload", adapterId); - return true; - } catch (final IllegalStateException e) { - log.error("Cannot hot-reload northbound mappings, adapter not in correct state: {}", e.getMessage()); - return false; - } catch (final Throwable e) { - log.error("Exception happened while updating northbound mappings via hot-reload: ", e); - return false; - } - }).orElse(false); + return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, + "northbound", + () -> wrapper.updateMappingsHotReload(northboundMappings, null))).orElse(false); } public boolean updateSouthboundMappingsHotReload( final @NotNull String adapterId, final @NotNull List southboundMappings) { - return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> { - try { - log.debug("Updating southbound mappings for adapter '{}' via hot-reload", adapterId); - wrapper.updateMappingsHotReload(null, southboundMappings, eventService); - log.info("Successfully updated southbound mappings for adapter '{}' via hot-reload", adapterId); - return true; - } catch (final IllegalStateException e) { - log.error("Cannot hot-reload southbound mappings, adapter not in correct state: {}", e.getMessage()); - return false; - } catch (final Throwable e) { - log.error("Exception happened while updating southbound mappings via hot-reload: ", e); - return false; - } - }).orElse(false); + return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, + "southbound", + () -> wrapper.updateMappingsHotReload(null, southboundMappings))).orElse(false); } public @NotNull List getDomainTags() { @@ -552,7 +549,7 @@ private void deleteAdapterInternal(final @NotNull String adapterId) { Preconditions.checkNotNull(wrapper); final String wid = wrapper.getId(); log.info("Starting protocol-adapter '{}'.", wid); - return wrapper.startAsync(moduleServices).whenComplete((result, throwable) -> { + return requireNonNull(wrapper.startAsync(moduleServices)).whenComplete((result, throwable) -> { if (throwable == null) { log.info("Protocol-adapter '{}' started successfully.", wid); fireStartEvent(wrapper, @@ -585,7 +582,7 @@ private void fireStartEvent( @NotNull CompletableFuture stopAsync(final @NotNull ProtocolAdapterWrapper wrapper) { Preconditions.checkNotNull(wrapper); log.info("Stopping protocol-adapter '{}'.", wrapper.getId()); - return wrapper.stopAsync().whenComplete((result, throwable) -> { + return requireNonNull(wrapper.stopAsync()).whenComplete((result, throwable) -> { final Event.SEVERITY severity; final String message; final String wid = wrapper.getId(); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index d017871355..04ba62ca4a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -164,7 +164,7 @@ public boolean startSouthbound() { return started; } - public @NotNull CompletableFuture startAsync(final @NotNull ModuleServices moduleServices) { + public @Nullable CompletableFuture startAsync(final @NotNull ModuleServices moduleServices) { operationLock.lock(); try { if (startOperationInProgress) { @@ -225,7 +225,7 @@ public boolean startSouthbound() { } } - public @NotNull CompletableFuture stopAsync() { + public @Nullable CompletableFuture stopAsync() { operationLock.lock(); try { if (stopOperationInProgress) { @@ -407,8 +407,7 @@ public void addTagHotReload(final @NotNull Tag tag, final @NotNull EventService public void updateMappingsHotReload( final @Nullable List northboundMappings, - final @Nullable List southboundMappings, - final @NotNull EventService eventService) { + final @Nullable List southboundMappings) { operationLock.lock(); try { if (northboundMappings != null) { @@ -562,62 +561,55 @@ private void stopWriting() { log.debug("Adapter '{}': Stop operation executing in thread '{}'", adapter.getId(), Thread.currentThread().getName()); - stopAdapter(); - cleanupConnectionStatusListener(); - try { - consumers.forEach(tagManager::removeConsumer); - consumers.clear(); - log.debug("Adapter '{}': Consumers cleaned up successfully", getId()); - } catch (final Exception e) { - log.error("Adapter '{}': Error cleaning up consumers", getId(), e); - } + return performStopOperations(); + } - stopPolling(); - stopWriting(); + private void stopProtocolAdapterOnFailedStart() { + log.warn("Stopping adapter with id {} after a failed start", adapter.getId()); + final CompletableFuture stopFuture = performStopOperations(); - final var output = new ProtocolAdapterStopOutputImpl(); + // Wait synchronously for stop to complete with timeout try { - adapter.stop(new ProtocolAdapterStopInputImpl(), output); + stopFuture.get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (final TimeoutException e) { + log.error("Timeout waiting for adapter {} to stop after failed start", adapter.getId()); } catch (final Throwable throwable) { - log.error("Adapter '{}': Exception during adapter.stop()", adapter.getId(), throwable); - output.getOutputFuture().completeExceptionally(throwable); + log.error("Stopping adapter after a start error failed", throwable); + } + + // Always destroy adapter after failed start + try { + adapter.destroy(); + } catch (final Exception destroyException) { + log.error("Error destroying adapter with id {} after failed start", adapter.getId(), destroyException); } - log.debug("Adapter '{}': Waiting for stop output future", adapter.getId()); - return output.getOutputFuture(); } - private void stopProtocolAdapterOnFailedStart() { - log.warn("Stopping adapter with id {} after a failed start", adapter.getId()); + private @NotNull CompletableFuture performStopOperations() { stopAdapter(); cleanupConnectionStatusListener(); + // Clean up consumers try { consumers.forEach(tagManager::removeConsumer); consumers.clear(); - log.debug("Adapter '{}': Consumers cleaned up after failed start", getId()); + log.debug("Adapter '{}': Consumers cleaned up successfully", getId()); } catch (final Exception e) { - log.error("Adapter '{}': Error cleaning up consumers after failed start", getId(), e); + log.error("Adapter '{}': Error cleaning up consumers", getId(), e); } stopPolling(); stopWriting(); + // Initiate adapter stop final var output = new ProtocolAdapterStopOutputImpl(); try { adapter.stop(new ProtocolAdapterStopInputImpl(), output); } catch (final Throwable throwable) { - log.error("Stopping adapter after a start error failed", throwable); - } - try { - output.getOutputFuture().get(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (final TimeoutException e) { - log.error("Timeout waiting for adapter {} to stop after failed start", adapter.getId()); - } catch (final Throwable throwable) { - log.error("Stopping adapter after a start error failed", throwable); - } - try { - adapter.destroy(); - } catch (final Exception destroyException) { - log.error("Error destroying adapter with id {} after failed start", adapter.getId(), destroyException); + log.error("Adapter '{}': Exception during adapter.stop()", adapter.getId(), throwable); + output.getOutputFuture().completeExceptionally(throwable); } + + log.debug("Adapter '{}': Waiting for stop output future", adapter.getId()); + return output.getOutputFuture(); } } From dd644d8b60e9712d2b0b2a41f04e290c0c851b54 Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 17:58:08 +0200 Subject: [PATCH 44/50] strengthen code --- .../impl/ProtocolAdaptersResourceImpl.java | 16 +-- .../protocols/ProtocolAdapterManager.java | 28 ++++- .../protocols/ProtocolAdapterWrapper.java | 118 +++++++++++++++--- 3 files changed, 134 insertions(+), 28 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java index bccdba014d..847e226398 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java @@ -578,21 +578,11 @@ public int getDepth() { return adapterNotFoundError(adapterId).get(); }) : errorResponse(new ConfigWritingDisabled()); - - //TODO - -// case ALREADY_USED_BY_ANOTHER_ADAPTER: -// //noinspection DataFlowIssue cant be null here. -// final @NotNull String tagName = domainTagUpdateResult.getErrorMessage(); -// return ErrorResponseUtil.errorResponse(new AlreadyExistsError("The tag '" + -// tagName + -// "' cannot be created since another item already exists with the same id.")); } @Override public @NotNull Response getDomainTags() { final List domainTags = protocolAdapterManager.getDomainTags(); - // empty list is also 200 as discussed. return Response.ok(new DomainTagList().items(domainTags.stream() .map(com.hivemq.persistence.domain.DomainTag::toModel) .toList())).build(); @@ -818,6 +808,9 @@ public int getDepth() { cfg.getSouthboundMappings(), cfg.getTags())) .map(newCfg -> { + // Enable skip flag to prevent refresh() from restarting adapter + // The flag will be cleared by refresh() when it checks it + ProtocolAdapterManager.enableSkipNextRefresh(); if (!configExtractor.updateAdapter(newCfg)) { return adapterCannotBeUpdatedError(adapterId); } @@ -865,6 +858,9 @@ public int getDepth() { converted, cfg.getTags())) .map(newCfg -> { + // Enable skip flag to prevent refresh() from restarting adapter + // The flag will be cleared by refresh() when it checks it + ProtocolAdapterManager.enableSkipNextRefresh(); if (!configExtractor.updateAdapter(newCfg)) { return adapterCannotBeUpdatedError(adapterId); } diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 1d156e745d..02cb7a420d 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -76,6 +76,11 @@ public class ProtocolAdapterManager { private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterManager.class); + // ThreadLocal flag to prevent refresh() from restarting adapters during hot-reload config updates + // AtomicBoolean to skip next refresh() call during hot-reload config updates + // Must be atomic (not ThreadLocal) because refresh() runs in a different thread (refreshExecutor) + private static final @NotNull AtomicBoolean skipRefreshForAdapter = new AtomicBoolean(false); + private final @NotNull Map protocolAdapters; private final @NotNull MetricRegistry metricRegistry; private final @NotNull ModuleServicesImpl moduleServices; @@ -173,6 +178,19 @@ private static boolean updateMappingsHotReload( } } + /** + * Enables skipping the next refresh operation for hot-reload config updates. + * This prevents the refresh() callback from restarting adapters when the config + * change originates from a hot-reload operation. + */ + public static void enableSkipNextRefresh() { + skipRefreshForAdapter.set(true); + } + + public static void disableSkipNextRefresh() { + skipRefreshForAdapter.set(false); + } + public void start() { if (log.isDebugEnabled()) { log.debug("Starting adapters"); @@ -182,6 +200,12 @@ public void start() { public void refresh(final @NotNull List configs) { refreshExecutor.submit(() -> { + // Atomically check and clear skip flag (hot-reload in progress) + if (skipRefreshForAdapter.getAndSet(false)) { + log.debug("Skipping refresh because hot-reload config update is in progress"); + return; + } + log.info("Refreshing adapters"); final Map protocolAdapterConfigs = configs.stream() @@ -355,7 +379,7 @@ public boolean updateNorthboundMappingsHotReload( final @NotNull List northboundMappings) { return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, "northbound", - () -> wrapper.updateMappingsHotReload(northboundMappings, null))).orElse(false); + () -> wrapper.updateMappingsHotReload(northboundMappings, null, eventService))).orElse(false); } public boolean updateSouthboundMappingsHotReload( @@ -363,7 +387,7 @@ public boolean updateSouthboundMappingsHotReload( final @NotNull List southboundMappings) { return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, "southbound", - () -> wrapper.updateMappingsHotReload(null, southboundMappings))).orElse(false); + () -> wrapper.updateMappingsHotReload(null, southboundMappings, eventService))).orElse(false); } public @NotNull List getDomainTags() { diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 04ba62ca4a..0fab9e24ee 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -80,6 +80,7 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private final @NotNull TagManager tagManager; private final @NotNull List consumers; private final @NotNull ReentrantLock operationLock; + private final @NotNull Object adapterLock; // protects underlying adapter start/stop calls private final @NotNull ExecutorService sharedAdapterExecutor; protected volatile @Nullable Long lastStartAttemptTime; private @Nullable CompletableFuture currentStartFuture; @@ -114,6 +115,7 @@ public ProtocolAdapterWrapper( this.consumers = new CopyOnWriteArrayList<>(); this.operationLock = new ReentrantLock(); this.sharedAdapterExecutor = sharedAdapterExecutor; + this.adapterLock = new Object(); if (log.isDebugEnabled()) { registerStateTransitionListener(state -> log.debug( @@ -375,8 +377,17 @@ public boolean isBatchPolling() { } public void addTagHotReload(final @NotNull Tag tag, final @NotNull EventService eventService) { + // Wait for any in-progress operations before proceeding + waitForOperationsToComplete(); + operationLock.lock(); try { + if (startOperationInProgress || stopOperationInProgress) { + throw new IllegalStateException("Cannot hot-reload tag for adapter '" + + getId() + + "': operation started during wait"); + } + // Update config with new tag regardless of adapter state final List updatedTags = new ArrayList<>(config.getTags()); updatedTags.add(tag); @@ -407,9 +418,18 @@ public void addTagHotReload(final @NotNull Tag tag, final @NotNull EventService public void updateMappingsHotReload( final @Nullable List northboundMappings, - final @Nullable List southboundMappings) { + final @Nullable List southboundMappings, + final @NotNull EventService eventService) { + waitForOperationsToComplete(); + operationLock.lock(); try { + if (startOperationInProgress || stopOperationInProgress) { + throw new IllegalStateException("Cannot hot-reload mappings for adapter '" + + getId() + + "': operation started during wait"); + } + if (northboundMappings != null) { config.setNorthboundMappings(northboundMappings); } @@ -420,10 +440,16 @@ public void updateMappingsHotReload( log.debug("Adapter '{}' not started yet, only updating config for mappings", getId()); return; } - log.debug("Stopping existing consumers for adapter '{}'", getId()); - consumers.forEach(tagManager::removeConsumer); - consumers.clear(); + + // Stop existing consumers and polling if (northboundMappings != null) { + log.debug("Stopping existing consumers and polling for adapter '{}'", getId()); + consumers.forEach(tagManager::removeConsumer); + consumers.clear(); + + // Stop polling to restart with new mappings + stopPolling(); + log.debug("Updating northbound mappings for adapter '{}'", getId()); northboundMappings.stream() .map(mapping -> northboundConsumerFactory.build(this, mapping, protocolAdapterMetricsService)) @@ -431,14 +457,37 @@ public void updateMappingsHotReload( tagManager.addConsumer(consumer); consumers.add(consumer); }); + + // Restart polling with new consumers + log.debug("Restarting polling for adapter '{}'", getId()); + if (isBatchPolling()) { + log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); + pollingService.schedulePolling(new PerAdapterSampler(this, eventService, tagManager)); + } + if (isPolling()) { + config.getTags() + .forEach(tag -> pollingService.schedulePolling(new PerContextSampler(this, + new PollingContextWrapper("unused", + tag.getName(), + MessageHandlingOptions.MQTTMessagePerTag, + false, + false, + List.of(), + 1, + -1), + eventService, + tagManager))); + } } + if (southboundMappings != null && isWriting()) { log.debug("Updating southbound mappings for adapter '{}'", getId()); final StateEnum currentSouthboundState = currentState().southbound(); - if (currentSouthboundState == StateEnum.CONNECTED) { + final boolean wasConnected = (currentSouthboundState == StateEnum.CONNECTED); + if (wasConnected) { + log.debug("Stopping southbound for adapter '{}' before hot-reload", getId()); stopWriting(); - } - if (currentSouthboundState == StateEnum.CONNECTED) { + log.debug("Restarting southbound for adapter '{}' after hot-reload", getId()); startSouthbound(); } } @@ -448,6 +497,35 @@ public void updateMappingsHotReload( } } + private void waitForOperationsToComplete() { + CompletableFuture futureToWait = null; + operationLock.lock(); + try { + if (startOperationInProgress) { + log.debug("Adapter '{}': Waiting for start operation to complete before hot-reload", getId()); + futureToWait = currentStartFuture; + } else if (stopOperationInProgress) { + log.debug("Adapter '{}': Waiting for stop operation to complete before hot-reload", getId()); + futureToWait = currentStopFuture; + } + } finally { + operationLock.unlock(); + } + + if (futureToWait != null) { + try { + // Wait with a timeout to prevent indefinite blocking + futureToWait.get(30, TimeUnit.SECONDS); + log.debug("Adapter '{}': Operation completed, proceeding with hot-reload", getId()); + } catch (final TimeoutException e) { + log.warn("Adapter '{}': Operation did not complete within 30 seconds, proceeding with hot-reload anyway", + getId()); + } catch (final Exception e) { + log.warn("Adapter '{}': Operation completed with error, but proceeding with hot-reload", getId(), e); + } + } + } + private void cleanupConnectionStatusListener() { final Consumer listenerToClean = connectionStatusListener; if (listenerToClean != null) { @@ -548,10 +626,15 @@ private void stopWriting() { return () -> { startAdapter(); // start FSM final ProtocolAdapterStartOutputImpl output = new ProtocolAdapterStartOutputImpl(); - try { - adapter.start(new ProtocolAdapterStartInputImpl(moduleServices), output); - } catch (final Throwable t) { - output.getStartFuture().completeExceptionally(t); + synchronized (adapterLock) { + log.debug("Adapter '{}': Calling adapter.start() in thread '{}'", + getId(), + Thread.currentThread().getName()); + try { + adapter.start(new ProtocolAdapterStartInputImpl(moduleServices), output); + } catch (final Throwable t) { + output.getStartFuture().completeExceptionally(t); + } } return output.getStartFuture(); }; @@ -602,11 +685,14 @@ private void stopProtocolAdapterOnFailedStart() { // Initiate adapter stop final var output = new ProtocolAdapterStopOutputImpl(); - try { - adapter.stop(new ProtocolAdapterStopInputImpl(), output); - } catch (final Throwable throwable) { - log.error("Adapter '{}': Exception during adapter.stop()", adapter.getId(), throwable); - output.getOutputFuture().completeExceptionally(throwable); + synchronized (adapterLock) { + log.debug("Adapter '{}': Calling adapter.stop() in thread '{}'", getId(), Thread.currentThread().getName()); + try { + adapter.stop(new ProtocolAdapterStopInputImpl(), output); + } catch (final Throwable throwable) { + log.error("Adapter '{}': Exception during adapter.stop()", adapter.getId(), throwable); + output.getOutputFuture().completeExceptionally(throwable); + } } log.debug("Adapter '{}': Waiting for stop output future", adapter.getId()); From cc1cca71a5cc22ac2c17b0423551aa7531d34055 Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 21:07:34 +0200 Subject: [PATCH 45/50] prevent thread leak --- .../adapters/impl/polling/PollingTask.java | 56 +++++++++++-------- .../ProtocolAdapterPollingServiceImpl.java | 14 ++--- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java index bfe9f4e653..4f2d141a5b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java @@ -19,9 +19,9 @@ import com.hivemq.adapter.sdk.api.events.model.Event; import com.hivemq.configuration.service.InternalConfigurations; import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingSampler; -import org.jetbrains.annotations.NotNull; import com.hivemq.util.ExceptionUtils; import com.hivemq.util.NanoTimeProvider; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,10 +29,12 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; public class PollingTask implements Runnable { @@ -42,12 +44,11 @@ public class PollingTask implements Runnable { private final @NotNull ScheduledExecutorService scheduledExecutorService; private final @NotNull EventService eventService; private final @NotNull NanoTimeProvider nanoTimeProvider; - private final @NotNull AtomicInteger watchdogErrorCount = new AtomicInteger(); - private final @NotNull AtomicInteger applicationErrorCount = new AtomicInteger(); - + private final @NotNull AtomicInteger watchdogErrorCount; + private final @NotNull AtomicInteger applicationErrorCount; + private final @NotNull AtomicBoolean continueScheduling; + private final @NotNull AtomicReference> currentScheduledFuture; private volatile long nanosOfLastPolling; - private final @NotNull AtomicBoolean continueScheduling = new AtomicBoolean(true); - public PollingTask( final @NotNull ProtocolAdapterPollingSampler sampler, @@ -58,6 +59,19 @@ public PollingTask( this.scheduledExecutorService = scheduledExecutorService; this.eventService = eventService; this.nanoTimeProvider = nanoTimeProvider; + this.watchdogErrorCount = new AtomicInteger(); + this.applicationErrorCount = new AtomicInteger(); + this.continueScheduling = new AtomicBoolean(true); + this.currentScheduledFuture = new AtomicReference<>(); + } + + private static long getBackoff(final int errorCount) { + //-- This will backoff up to a max of about a day (unless the max provided is less) + final long max = InternalConfigurations.ADAPTER_RUNTIME_MAX_APPLICATION_ERROR_BACKOFF.get(); + long f = (long) (Math.pow(2, Math.min(errorCount, 20)) * 100); + f += ThreadLocalRandom.current().nextInt(0, errorCount * 100); + f = Math.min(f, max); + return f; } @Override @@ -90,6 +104,11 @@ public void run() { public void stopScheduling() { continueScheduling.set(false); + // Cancel any currently scheduled future to prevent thread leaks + final ScheduledFuture future = currentScheduledFuture.getAndSet(null); + if (future != null) { + future.cancel(true); + } } private void handleInterruptionException(final @NotNull Throwable throwable) { @@ -99,7 +118,8 @@ private void handleInterruptionException(final @NotNull Throwable throwable) { final var errorCountTotal = watchdogErrorCount.incrementAndGet(); final var stopBecauseOfTooManyErrors = errorCountTotal > InternalConfigurations.ADAPTER_RUNTIME_WATCHDOG_TIMEOUT_ERRORS_BEFORE_INTERRUPT.get(); - final var milliSecondsSinceLastPoll = TimeUnit.NANOSECONDS.toMillis(nanoTimeProvider.nanoTime() - nanosOfLastPolling); + final var milliSecondsSinceLastPoll = + TimeUnit.NANOSECONDS.toMillis(nanoTimeProvider.nanoTime() - nanosOfLastPolling); if (stopBecauseOfTooManyErrors) { log.warn( "Detected bad system process {} in sampler {} - terminating process to maintain health ({}ms runtime)", @@ -121,7 +141,6 @@ private void handleInterruptionException(final @NotNull Throwable throwable) { } } - private void handleExceptionDuringPolling(final @NotNull Throwable throwable) { final int errorCountTotal = applicationErrorCount.incrementAndGet(); final int maxErrorsBeforeRemoval = sampler.getMaxErrorsBeforeRemoval(); @@ -145,11 +164,12 @@ private void handleExceptionDuringPolling(final @NotNull Throwable throwable) { notifyOnError(sampler, throwable, false); // no rescheduling } - } private void notifyOnError( - final @NotNull ProtocolAdapterPollingSampler sampler, final @NotNull Throwable t, final boolean continuing) { + final @NotNull ProtocolAdapterPollingSampler sampler, + final @NotNull Throwable t, + final boolean continuing) { try { sampler.error(t, continuing); } catch (final Throwable samplerError) { @@ -178,12 +198,10 @@ private void reschedule(final int errorCountTotal) { } final long nonNegativeDelay = Math.max(0, delayInMillis); - if (errorCountTotal == 0) { schedule(nonNegativeDelay); } else { - final long backoff = getBackoff(errorCountTotal, - InternalConfigurations.ADAPTER_RUNTIME_MAX_APPLICATION_ERROR_BACKOFF.get()); + final long backoff = getBackoff(errorCountTotal); final long effectiveDelay = Math.max(nonNegativeDelay, backoff); schedule(effectiveDelay); } @@ -193,7 +211,9 @@ private void reschedule(final int errorCountTotal) { void schedule(final long nonNegativeDelay) { if (continueScheduling.get()) { try { - scheduledExecutorService.schedule(this, nonNegativeDelay, TimeUnit.MILLISECONDS); + currentScheduledFuture.set(scheduledExecutorService.schedule(this, + nonNegativeDelay, + TimeUnit.MILLISECONDS)); } catch (final RejectedExecutionException rejectedExecutionException) { // ignore. This is fine during shutdown. } @@ -204,12 +224,4 @@ private void resetErrorStats() { applicationErrorCount.set(0); watchdogErrorCount.set(0); } - - private static long getBackoff(final int errorCount, final long max) { - //-- This will backoff up to a max of about a day (unless the max provided is less) - long f = (long) (Math.pow(2, Math.min(errorCount, 20)) * 100); - f += ThreadLocalRandom.current().nextInt(0, errorCount * 100); - f = Math.min(f, max); - return f; - } } diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/ProtocolAdapterPollingServiceImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/ProtocolAdapterPollingServiceImpl.java index 68d6365c03..a6904f3578 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/ProtocolAdapterPollingServiceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/ProtocolAdapterPollingServiceImpl.java @@ -21,21 +21,18 @@ import com.hivemq.common.shutdown.ShutdownHooks; import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingSampler; import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingService; -import org.jetbrains.annotations.NotNull; import com.hivemq.util.NanoTimeProvider; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -/** - * @author Daniel Krüger - */ @Singleton public class ProtocolAdapterPollingServiceImpl implements ProtocolAdapterPollingService { @@ -60,7 +57,8 @@ public ProtocolAdapterPollingServiceImpl( @Override public void schedulePolling(final @NotNull ProtocolAdapterPollingSampler sampler) { - final PollingTask pollingTask = new PollingTask(sampler, scheduledExecutorService, eventService, nanoTimeProvider); + final PollingTask pollingTask = + new PollingTask(sampler, scheduledExecutorService, eventService, nanoTimeProvider); scheduledExecutorService.schedule(pollingTask, sampler.getInitialDelay(), sampler.getUnit()); samplerToTask.put(sampler, pollingTask); } @@ -82,7 +80,6 @@ public void stopAllPolling() { samplerToTask.keySet().forEach(this::stopPolling); } - private class Shutdown implements HiveMQShutdownHook { @Override public @NotNull String name() { @@ -104,5 +101,4 @@ public void run() { } } } - } From 9e4e8dccdbcd9c28505dce0b7c6c260e8d26e967 Mon Sep 17 00:00:00 2001 From: marregui Date: Sat, 25 Oct 2025 22:36:02 +0200 Subject: [PATCH 46/50] dont propagate error event messages if the adapter is already stopped --- .../src/main/java/com/hivemq/bootstrap/ioc/Injector.java | 6 +----- .../modules/adapters/impl/ProtocolAdapterStateImpl.java | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java index 64a6e30bcc..175895a570 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java +++ b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java @@ -57,11 +57,10 @@ import com.hivemq.uns.ioc.UnsServiceModule; import dagger.BindsInstance; import dagger.Component; - import jakarta.inject.Singleton; + import java.util.Set; import java.util.concurrent.ExecutorService; -import java.util.concurrent.ScheduledExecutorService; @SuppressWarnings({"NullabilityAnnotations", "UnusedReturnValue"}) @Component(modules = { @@ -123,11 +122,8 @@ public interface Injector { // UnsServiceModule uns(); - // Executor accessors for coordinated shutdown ExecutorService executorService(); - ScheduledExecutorService scheduledExecutor(); - @Component.Builder interface Builder { diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index 27e9a5af82..1817e0942f 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -85,7 +85,8 @@ public void reportErrorMessage( // This is can be sent through the API to give an indication of the // status of an adapter runtime. lastErrorMessage.set(errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage); - if (sendEvent) { + // Don't send error events if the adapter is already stopped + if (sendEvent && runtimeStatus.get() != RuntimeStatus.STOPPED) { final var eventBuilder = eventService.createAdapterEvent(adapterId, protocolId) .withSeverity(EventImpl.SEVERITY.ERROR) .withMessage(String.format("Adapter '%s' encountered an error.", adapterId)); From be8fb073c691c30049c255503e60bde4b14afc9e Mon Sep 17 00:00:00 2001 From: marregui Date: Sun, 26 Oct 2025 10:59:13 +0100 Subject: [PATCH 47/50] fix slurped delete of adapter --- .../java/com/hivemq/protocols/ProtocolAdapterManager.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 02cb7a420d..a65587380b 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -228,7 +228,10 @@ public void refresh(final @NotNull List configs) { if (log.isDebugEnabled()) { log.debug("Deleting adapter '{}'", name); } - stopAsync(name).whenComplete((ignored, t) -> deleteAdapterInternal(name)).get(); + stopAsync(name).handle((result, throwable) -> { + deleteAdapterInternal(name); + return null; + }).get(); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); failedAdapters.add(name); From e3b0e083187290b56736ceca0cf1f44efb05aea3 Mon Sep 17 00:00:00 2001 From: marregui Date: Sun, 26 Oct 2025 12:59:50 +0100 Subject: [PATCH 48/50] remove race condition --- .../protocols/ProtocolAdapterManager.java | 41 ++++++++++++++----- .../protocols/ProtocolAdapterWrapper.java | 15 ++++++- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index a65587380b..4b031e3108 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -199,6 +199,12 @@ public void start() { } public void refresh(final @NotNull List configs) { + // Don't submit refresh if shutdown initiated or executor is shutting down + if (shutdownInitiated.get() || refreshExecutor.isShutdown()) { + log.debug("Skipping refresh because manager is shutting down"); + return; + } + refreshExecutor.submit(() -> { // Atomically check and clear skip flag (hot-reload in progress) if (skipRefreshForAdapter.getAndSet(false)) { @@ -264,18 +270,33 @@ public void refresh(final @NotNull List configs) { log.error( "Existing adapters were modified while a refresh was ongoing, adapter with name '{}' was deleted and could not be updated", name); + return; } - if (wrapper != null && !protocolAdapterConfigs.get(name).equals(wrapper.getConfig())) { - if (log.isDebugEnabled()) { - log.debug("Updating adapter '{}'", name); + + if (!protocolAdapterConfigs.get(name).equals(wrapper.getConfig())) { + final boolean isStarted = + wrapper.getRuntimeStatus() == ProtocolAdapterState.RuntimeStatus.STARTED; + + if (!isStarted) { + // Adapter is stopped - update config by recreating wrapper but don't start + if (log.isDebugEnabled()) { + log.debug("Updating config for stopped adapter '{}' without starting", name); + } + deleteAdapterInternal(name); + createAdapterInternal(protocolAdapterConfigs.get(name), versionProvider.getVersion()); + } else { + // Adapter is started - do full stop->delete->create->start cycle + if (log.isDebugEnabled()) { + log.debug("Updating adapter '{}'", name); + } + stopAsync(name).thenApply(v -> { + deleteAdapterInternal(name); + return null; + }) + .thenCompose(ignored -> startAsync(createAdapterInternal(protocolAdapterConfigs.get( + name), versionProvider.getVersion()))) + .get(); } - stopAsync(name).thenApply(v -> { - deleteAdapterInternal(name); - return null; - }) - .thenCompose(ignored -> startAsync(createAdapterInternal(protocolAdapterConfigs.get(name), - versionProvider.getVersion()))) - .get(); } else { if (log.isDebugEnabled()) { log.debug("Not-updating adapter '{}' since the config is unchanged", name); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index 0fab9e24ee..dd69c6ad25 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -157,7 +157,20 @@ public boolean startSouthbound() { .map(InternalWritingContextImpl::new) .collect(Collectors.toList())); if (started) { - log.info("Southbound started for adapter {}", adapter.getId()); + // Allow time for writing capabilities to initialize after hot-reload + // This prevents data loss by ensuring ReactiveWriters complete MQTT subscription + // and are ready to process messages before hot-reload completes. + // TODO: Replace with proper event-based initialization by: + // 1. Making ReactiveWriter.start() return CompletableFuture + // 2. Having ProtocolAdapterWritingServiceImpl.startWriting() wait on all futures + // 3. Removing this sleep in favor of the future-based approach + try { + Thread.sleep(1000); + log.info("Southbound started for adapter {}", adapter.getId()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Interrupted while waiting for southbound initialization after hot-reload for adapter '{}'", getId()); + } transitionSouthboundState(StateEnum.CONNECTED); } else { log.error("Southbound start failed for adapter {}", adapter.getId()); From f2ebdee652e6ea1448f00308fa07bac6f92ff473 Mon Sep 17 00:00:00 2001 From: marregui Date: Sun, 26 Oct 2025 21:18:47 +0100 Subject: [PATCH 49/50] code improvements --- .../main/java/com/hivemq/HiveMQEdgeMain.java | 79 +++----- .../impl/ProtocolAdaptersResourceImpl.java | 10 +- .../com/hivemq/bootstrap/ioc/Injector.java | 1 - .../reader/ProtocolAdapterExtractor.java | 169 +++++++++--------- .../impl/ProtocolAdapterStateImpl.java | 9 +- .../adapters/impl/polling/PollingTask.java | 3 +- .../persistence/ScheduledCleanUpService.java | 63 +++---- .../AbstractSubscriptionSampler.java | 1 - .../protocols/ProtocolAdapterConfig.java | 48 ++--- .../protocols/ProtocolAdapterManager.java | 64 ++----- .../protocols/ProtocolAdapterWrapper.java | 146 ++++++++++----- .../databases/DatabaseConnection.java | 10 +- .../DatabasesPollingProtocolAdapter.java | 4 +- .../modbus/ModbusProtocolAdapter.java | 37 ++-- .../adapters/opcua/OpcUaProtocolAdapter.java | 43 ++--- 15 files changed, 326 insertions(+), 361 deletions(-) diff --git a/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java b/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java index a19e450030..c9a7797417 100644 --- a/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java +++ b/hivemq-edge/src/main/java/com/hivemq/HiveMQEdgeMain.java @@ -21,35 +21,32 @@ import com.hivemq.bootstrap.LoggingBootstrap; import com.hivemq.bootstrap.ioc.Injector; import com.hivemq.bootstrap.ioc.Persistences; -import com.hivemq.bootstrap.services.AfterHiveMQStartBootstrapService; import com.hivemq.bootstrap.services.AfterHiveMQStartBootstrapServiceImpl; import com.hivemq.common.shutdown.ShutdownHooks; import com.hivemq.configuration.info.SystemInformation; import com.hivemq.configuration.info.SystemInformationImpl; -import com.hivemq.configuration.service.ApiConfigurationService; import com.hivemq.configuration.service.ConfigurationService; import com.hivemq.edge.modules.ModuleLoader; import com.hivemq.embedded.EmbeddedExtension; import com.hivemq.exceptions.HiveMQEdgeStartupException; +import com.hivemq.http.JaxrsHttpServer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import com.hivemq.http.JaxrsHttpServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Objects; import java.util.concurrent.TimeUnit; +import static java.util.Objects.requireNonNull; + public class HiveMQEdgeMain { - private static final Logger log = LoggerFactory.getLogger(HiveMQEdgeMain.class); + private static final @NotNull Logger log = LoggerFactory.getLogger(HiveMQEdgeMain.class); - private @Nullable ConfigurationService configService; private final @NotNull ModuleLoader moduleLoader; private final @NotNull MetricRegistry metricRegistry; private final @NotNull SystemInformation systemInformation; - + private @Nullable ConfigurationService configService; private @Nullable JaxrsHttpServer jaxrsServer; - private @Nullable Injector injector; private @Nullable Thread shutdownThread; @@ -64,22 +61,30 @@ public HiveMQEdgeMain( this.moduleLoader = moduleLoader; } - public void bootstrap() throws HiveMQEdgeStartupException { - // Already bootstrapped. - if (injector != null) { - return; + public static void main(final String @NotNull [] args) throws Exception { + log.info("Starting HiveMQ Edge..."); + final long startTime = System.nanoTime(); + final SystemInformationImpl systemInformation = new SystemInformationImpl(true); + final ModuleLoader moduleLoader = new ModuleLoader(systemInformation); + final HiveMQEdgeMain server = new HiveMQEdgeMain(systemInformation, new MetricRegistry(), null, moduleLoader); + try { + server.start(null); + log.info("Started HiveMQ Edge in {}ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime)); + } catch (final HiveMQEdgeStartupException e) { + log.error("HiveMQ Edge start was aborted with error.", e); } - final HiveMQEdgeBootstrap bootstrap = - new HiveMQEdgeBootstrap(metricRegistry, systemInformation, moduleLoader, configService); - + } - injector = bootstrap.bootstrap(); - if (configService == null) { - configService = injector.configurationService(); + public void bootstrap() throws HiveMQEdgeStartupException { + if (injector == null) { + injector = + new HiveMQEdgeBootstrap(metricRegistry, systemInformation, moduleLoader, configService).bootstrap(); + if (configService == null) { + configService = injector.configurationService(); + } } } - protected void startGateway(final @Nullable EmbeddedExtension embeddedExtension) throws HiveMQEdgeStartupException { if (injector == null) { throw new HiveMQEdgeStartupException("invalid startup state"); @@ -90,9 +95,7 @@ protected void startGateway(final @Nullable EmbeddedExtension embeddedExtension) throw new HiveMQEdgeStartupException("User aborted."); } - final HiveMQEdgeGateway instance = injector.edgeGateway(); - instance.start(embeddedExtension); - + injector.edgeGateway().start(embeddedExtension); initializeApiServer(injector); startApiServer(); } @@ -102,11 +105,9 @@ protected void stopGateway() { return; } final ShutdownHooks shutdownHooks = injector.shutdownHooks(); - // Already shutdown. if (shutdownHooks.isShuttingDown()) { return; } - shutdownHooks.runShutdownHooks(); //clear metrics @@ -114,13 +115,11 @@ protected void stopGateway() { //Stop the API Webserver stopApiServer(); - LoggingBootstrap.resetLogging(); } protected void initializeApiServer(final @NotNull Injector injector) { - final ApiConfigurationService config = Objects.requireNonNull(configService).apiConfiguration(); - if (jaxrsServer == null && config.isEnabled()) { + if (jaxrsServer == null && requireNonNull(configService).apiConfiguration().isEnabled()) { jaxrsServer = injector.apiServer(); } else { log.info("API is DISABLED by configuration"); @@ -142,7 +141,6 @@ protected void stopApiServer() { protected void afterStart() { afterHiveMQStartBootstrap(); - //hook method } private void afterHiveMQStartBootstrap() { @@ -150,13 +148,11 @@ private void afterHiveMQStartBootstrap() { final Persistences persistences = injector.persistences(); Preconditions.checkNotNull(persistences); Preconditions.checkNotNull(configService); - try { - final AfterHiveMQStartBootstrapService afterHiveMQStartBootstrapService = - AfterHiveMQStartBootstrapServiceImpl.decorate(injector.completeBootstrapService(), + injector.commercialModuleLoaderDiscovery() + .afterHiveMQStart(AfterHiveMQStartBootstrapServiceImpl.decorate(injector.completeBootstrapService(), injector.protocolAdapterManager(), - injector.services().modulesAndExtensionsService()); - injector.commercialModuleLoaderDiscovery().afterHiveMQStart(afterHiveMQStartBootstrapService); + injector.services().modulesAndExtensionsService())); } catch (final Exception e) { log.warn("Error on bootstrapping modules:", e); throw new HiveMQEdgeStartupException(e); @@ -183,24 +179,7 @@ public void stop() { } } - public static void main(final String @NotNull [] args) throws Exception { - log.info("Starting HiveMQ Edge..."); - final long startTime = System.nanoTime(); - final SystemInformationImpl systemInformation = new SystemInformationImpl(true); - final ModuleLoader moduleLoader = new ModuleLoader(systemInformation); - final HiveMQEdgeMain server = - new HiveMQEdgeMain(systemInformation, new MetricRegistry(), null, moduleLoader); - try { - server.start(null); - log.info("Started HiveMQ Edge in {}ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime)); - } catch (final HiveMQEdgeStartupException e) { - log.error("HiveMQ Edge start was aborted with error.", e); - } - } - public @Nullable Injector getInjector() { return injector; } - - } diff --git a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java index 847e226398..b4f7c924ab 100644 --- a/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/api/resources/impl/ProtocolAdaptersResourceImpl.java @@ -808,10 +808,7 @@ public int getDepth() { cfg.getSouthboundMappings(), cfg.getTags())) .map(newCfg -> { - // Enable skip flag to prevent refresh() from restarting adapter - // The flag will be cleared by refresh() when it checks it - ProtocolAdapterManager.enableSkipNextRefresh(); - if (!configExtractor.updateAdapter(newCfg)) { + if (!configExtractor.updateAdapter(newCfg, false)) { return adapterCannotBeUpdatedError(adapterId); } log.info("Successfully updated northbound mappings for adapter '{}' via hot-reload.", @@ -858,10 +855,7 @@ public int getDepth() { converted, cfg.getTags())) .map(newCfg -> { - // Enable skip flag to prevent refresh() from restarting adapter - // The flag will be cleared by refresh() when it checks it - ProtocolAdapterManager.enableSkipNextRefresh(); - if (!configExtractor.updateAdapter(newCfg)) { + if (!configExtractor.updateAdapter(newCfg, false)) { return adapterCannotBeUpdatedError(adapterId); } log.info("Successfully updated southbound mappings for adapter '{}' via hot-reload.", diff --git a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java index 175895a570..c70481c268 100644 --- a/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java +++ b/hivemq-edge/src/main/java/com/hivemq/bootstrap/ioc/Injector.java @@ -177,5 +177,4 @@ interface Builder { Injector build(); } - } diff --git a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ProtocolAdapterExtractor.java b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ProtocolAdapterExtractor.java index d47ba6b56e..09918973f4 100644 --- a/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ProtocolAdapterExtractor.java +++ b/hivemq-edge/src/main/java/com/hivemq/configuration/reader/ProtocolAdapterExtractor.java @@ -19,44 +19,44 @@ import com.hivemq.configuration.entity.HiveMQConfigEntity; import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; import com.hivemq.configuration.entity.adapter.TagEntity; +import jakarta.xml.bind.ValidationEvent; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import jakarta.xml.bind.ValidationEvent; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import java.util.stream.Collectors; - -public class ProtocolAdapterExtractor implements ReloadableExtractor, List<@NotNull ProtocolAdapterEntity>> { - private volatile @NotNull List allConfigs = List.of(); - - private final @NotNull Set tagNames = new CopyOnWriteArraySet<>(); - private volatile @Nullable Consumer> consumer = cfg -> log.debug("No consumer registered yet"); +public class ProtocolAdapterExtractor + implements ReloadableExtractor, List<@NotNull ProtocolAdapterEntity>> { + private final @NotNull Set tagNames; private final @NotNull ConfigFileReaderWriter configFileReaderWriter; + private volatile @NotNull List allConfigs; + private volatile @Nullable Consumer> consumer; public ProtocolAdapterExtractor(final @NotNull ConfigFileReaderWriter configFileReaderWriter) { this.configFileReaderWriter = configFileReaderWriter; + this.tagNames = new CopyOnWriteArraySet<>(); + this.allConfigs = List.of(); + this.consumer = cfg -> log.debug("No consumer registered yet"); } public @NotNull List getAllConfigs() { return allConfigs; } - public @NotNull Optional getAdapterByAdapterId(String adapterId) { + public @NotNull Optional getAdapterByAdapterId(final @NotNull String adapterId) { return allConfigs.stream().filter(adapter -> adapter.getAdapterId().equals(adapterId)).findFirst(); } @Override - public synchronized Configurator.@NotNull ConfigResult updateConfig(final HiveMQConfigEntity config) { + public synchronized Configurator.@NotNull ConfigResult updateConfig(final @NotNull HiveMQConfigEntity config) { final List validationEvents = new ArrayList<>(); final var newConfigs = List.copyOf(config.getProtocolAdapterConfig()); newConfigs.forEach(entity -> entity.validate(validationEvents)); @@ -76,17 +76,15 @@ public ProtocolAdapterExtractor(final @NotNull ConfigFileReaderWriter configFile }); } - public synchronized Configurator.ConfigResult updateAllAdapters(final @NotNull List adapterConfigs) { + public synchronized @NotNull Configurator.ConfigResult updateAllAdapters(final @NotNull List adapterConfigs) { final var newConfigs = List.copyOf(adapterConfigs); - return updateTagNames(newConfigs) - .map(duplicates -> Configurator.ConfigResult.ERROR) - .orElseGet(() -> { - replaceConfigsAndTriggerWrite(newConfigs); - return Configurator.ConfigResult.SUCCESS; - }); + return updateTagNames(newConfigs).map(duplicates -> Configurator.ConfigResult.ERROR).orElseGet(() -> { + replaceConfigsAndTriggerWrite(newConfigs); + return Configurator.ConfigResult.SUCCESS; + }); } - private void replaceConfigsAndTriggerWrite(List<@NotNull ProtocolAdapterEntity> newConfigs) { + private void replaceConfigsAndTriggerWrite(final @NotNull List<@NotNull ProtocolAdapterEntity> newConfigs) { allConfigs = newConfigs; notifyConsumer(); configFileReaderWriter.writeConfigWithSync(); @@ -99,68 +97,79 @@ public synchronized boolean addAdapter(final @NotNull ProtocolAdapterEntity prot protocolAdapterConfig.getProtocolId() + "'"); } - return addTagNamesIfNoDuplicates(protocolAdapterConfig.getTags()) - .map(dupes -> { - log.error("Found duplicated tag names: {}", dupes); - return false; - }) - .orElseGet(() -> { - final var newConfigs = new ImmutableList.Builder() - .addAll(allConfigsTemp) - .add(protocolAdapterConfig) - .build(); - replaceConfigsAndTriggerWrite(newConfigs); - return true; - }); + return addTagNamesIfNoDuplicates(protocolAdapterConfig.getTags()).map(dupes -> { + log.error("Found duplicated tag names: {}", dupes); + return false; + }).orElseGet(() -> { + final var newConfigs = new ImmutableList.Builder<@NotNull ProtocolAdapterEntity>().addAll(allConfigsTemp) + .add(protocolAdapterConfig) + .build(); + replaceConfigsAndTriggerWrite(newConfigs); + return true; + }); } public synchronized boolean updateAdapter( final @NotNull ProtocolAdapterEntity protocolAdapterConfig) { + return updateAdapter(protocolAdapterConfig, true); + } + + /** + * Update adapter config, optionally triggering refresh. + * When triggerRefresh=false, this is used for hot-reload operations where the adapter + * has already been updated in-place and we only need to persist the config change. + */ + public synchronized boolean updateAdapter( + final @NotNull ProtocolAdapterEntity protocolAdapterConfig, + final boolean triggerRefresh) { final var duplicateTags = new HashSet(); final var updated = new AtomicBoolean(false); - final var newConfigs = allConfigs - .stream() - .map(oldInstance -> { - if(oldInstance.getAdapterId().equals(protocolAdapterConfig.getAdapterId())) { - return replaceTagNamesIfNoDuplicates(oldInstance.getTags(), protocolAdapterConfig.getTags()) - .map(dupes -> { - duplicateTags.addAll(dupes); - return oldInstance; - }) - .orElseGet(() -> { - updated.set(true); - return protocolAdapterConfig; - }); - } else { - return oldInstance; - } - }).toList(); - if(updated.get()) { - if(!duplicateTags.isEmpty()) { + final var newConfigs = allConfigs.stream().map(oldInstance -> { + if (oldInstance.getAdapterId().equals(protocolAdapterConfig.getAdapterId())) { + return replaceTagNamesIfNoDuplicates(oldInstance.getTags(), + protocolAdapterConfig.getTags()).map(dupes -> { + duplicateTags.addAll(dupes); + return oldInstance; + }).orElseGet(() -> { + updated.set(true); + return protocolAdapterConfig; + }); + } else { + return oldInstance; + } + }).toList(); + if (updated.get()) { + if (!duplicateTags.isEmpty()) { log.error("Found duplicated tag names: {}", duplicateTags); return false; } else { - replaceConfigsAndTriggerWrite(newConfigs); + if (triggerRefresh) { + replaceConfigsAndTriggerWrite(newConfigs); + } else { + // Hot-reload: update config without triggering refresh (adapter already updated) + replaceConfigsWithoutNotify(newConfigs); + } return true; } } return false; } + private void replaceConfigsWithoutNotify(final @NotNull List<@NotNull ProtocolAdapterEntity> newConfigs) { + allConfigs = newConfigs; + configFileReaderWriter.writeConfigWithSync(); + } + public synchronized boolean deleteAdapter(final @NotNull String adapterId) { final var newConfigs = new ArrayList<>(allConfigs); - final var deleted = allConfigs - .stream() - .filter(config -> config.getAdapterId().equals(adapterId)) - .findFirst() - .map(found -> { + final var deleted = + allConfigs.stream().filter(config -> config.getAdapterId().equals(adapterId)).findFirst().map(found -> { newConfigs.remove(found); - tagNames.removeAll(found.getTags().stream().map(TagEntity::getName).toList()); + found.getTags().stream().map(TagEntity::getName).toList().forEach(tagNames::remove); return true; - }) - .orElse(false); + }).orElse(false); - if(deleted) { + if (deleted) { replaceConfigsAndTriggerWrite(List.copyOf(newConfigs)); return true; } @@ -169,25 +178,23 @@ public synchronized boolean deleteAdapter(final @NotNull String adapterId) { private void notifyConsumer() { final var consumer = this.consumer; - if(consumer != null) { + if (consumer != null) { consumer.accept(allConfigs); } } - private Optional> updateTagNames(List entities) { + private @NotNull Optional> updateTagNames(final @NotNull List entities) { final var newTagNames = new HashSet(); final var duplicates = new HashSet(); - entities.stream() - .flatMap(cfg -> - cfg.getTags().stream()).forEach(tag -> { - if (newTagNames.contains(tag.getName())) { - duplicates.add(tag.getName()); - } else { - newTagNames.add(tag.getName()); - } - }); + entities.stream().flatMap(cfg -> cfg.getTags().stream()).forEach(tag -> { + if (newTagNames.contains(tag.getName())) { + duplicates.add(tag.getName()); + } else { + newTagNames.add(tag.getName()); + } + }); - if(!duplicates.isEmpty()) { + if (!duplicates.isEmpty()) { log.error("Duplicate tags detected while updating: {}", duplicates); return Optional.of(duplicates); } @@ -196,7 +203,7 @@ private Optional> updateTagNames(List entitie return Optional.empty(); } - private Optional> addTagNamesIfNoDuplicates(List newTags) { + private @NotNull Optional> addTagNamesIfNoDuplicates(final @NotNull List newTags) { final var newTagNames = new HashSet(); final var duplicates = new HashSet(); newTags.forEach(tag -> { @@ -207,7 +214,7 @@ private Optional> addTagNamesIfNoDuplicates(List newTags) } }); - if(!duplicates.isEmpty()) { + if (!duplicates.isEmpty()) { log.error("Duplicate tags detected while adding: {}", duplicates); return Optional.of(duplicates); } @@ -215,13 +222,15 @@ private Optional> addTagNamesIfNoDuplicates(List newTags) return Optional.empty(); } - private Optional> replaceTagNamesIfNoDuplicates(List oldTags, List newTags) { + private @NotNull Optional> replaceTagNamesIfNoDuplicates( + final @NotNull List oldTags, + final @NotNull List newTags) { final var newTagNames = new HashSet(); final var duplicates = new HashSet(); final var currentTagNames = new HashSet<>(tagNames); - currentTagNames.removeAll(oldTags.stream().map(TagEntity::getName).toList()); + oldTags.stream().map(TagEntity::getName).toList().forEach(currentTagNames::remove); newTags.forEach(tag -> { if (currentTagNames.contains(tag.getName())) { @@ -230,7 +239,7 @@ private Optional> replaceTagNamesIfNoDuplicates(List oldT newTagNames.add(tag.getName()); } }); - if(!duplicates.isEmpty()) { + if (!duplicates.isEmpty()) { log.error("Duplicate tags detected while replacing: {}", duplicates); return Optional.of(duplicates); } @@ -240,13 +249,13 @@ private Optional> replaceTagNamesIfNoDuplicates(List oldT } @Override - public synchronized void registerConsumer(final Consumer> consumer) { + public synchronized void registerConsumer(final @Nullable Consumer> consumer) { this.consumer = consumer; notifyConsumer(); } @Override - public boolean needsRestartWithConfig(final HiveMQConfigEntity config) { + public boolean needsRestartWithConfig(final @NotNull HiveMQConfigEntity config) { return false; } diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java index 1817e0942f..7b08c12d96 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/ProtocolAdapterStateImpl.java @@ -28,9 +28,9 @@ import java.util.function.Consumer; public class ProtocolAdapterStateImpl implements ProtocolAdapterState { - protected final @NotNull AtomicReference runtimeStatus; - protected final @NotNull AtomicReference connectionStatus; - protected final @NotNull AtomicReference<@Nullable String> lastErrorMessage; + private final @NotNull AtomicReference runtimeStatus; + private final @NotNull AtomicReference connectionStatus; + private final @NotNull AtomicReference<@Nullable String> lastErrorMessage; private final @NotNull EventService eventService; private final @NotNull String adapterId; private final @NotNull String protocolId; @@ -85,8 +85,7 @@ public void reportErrorMessage( // This is can be sent through the API to give an indication of the // status of an adapter runtime. lastErrorMessage.set(errorMessage == null ? throwable == null ? null : throwable.getMessage() : errorMessage); - // Don't send error events if the adapter is already stopped - if (sendEvent && runtimeStatus.get() != RuntimeStatus.STOPPED) { + if (sendEvent) { final var eventBuilder = eventService.createAdapterEvent(adapterId, protocolId) .withSeverity(EventImpl.SEVERITY.ERROR) .withMessage(String.format("Adapter '%s' encountered an error.", adapterId)); diff --git a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java index 4f2d141a5b..85ab22daaa 100644 --- a/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java +++ b/hivemq-edge/src/main/java/com/hivemq/edge/modules/adapters/impl/polling/PollingTask.java @@ -104,7 +104,6 @@ public void run() { public void stopScheduling() { continueScheduling.set(false); - // Cancel any currently scheduled future to prevent thread leaks final ScheduledFuture future = currentScheduledFuture.getAndSet(null); if (future != null) { future.cancel(true); @@ -214,7 +213,7 @@ void schedule(final long nonNegativeDelay) { currentScheduledFuture.set(scheduledExecutorService.schedule(this, nonNegativeDelay, TimeUnit.MILLISECONDS)); - } catch (final RejectedExecutionException rejectedExecutionException) { + } catch (final RejectedExecutionException ignored) { // ignore. This is fine during shutdown. } } diff --git a/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java b/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java index cb405af3e6..bb2d3ea445 100644 --- a/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java +++ b/hivemq-edge/src/main/java/com/hivemq/persistence/ScheduledCleanUpService.java @@ -23,19 +23,19 @@ import com.google.common.util.concurrent.ListeningScheduledExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.hivemq.configuration.service.InternalConfigurationService; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import com.hivemq.persistence.clientqueue.ClientQueuePersistence; import com.hivemq.persistence.clientsession.ClientSessionPersistence; import com.hivemq.persistence.clientsession.ClientSessionSubscriptionPersistence; import com.hivemq.persistence.ioc.annotation.Persistence; import com.hivemq.persistence.retained.RetainedMessagePersistence; import com.hivemq.persistence.util.FutureUtils; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; import java.util.concurrent.TimeUnit; @@ -54,29 +54,23 @@ @Singleton public class ScheduledCleanUpService { - static final int NUMBER_OF_PERSISTENCES = 4; - /** * The counter index that is associated with the client session persistence in the clean up job scheduling logic */ public static final int CLIENT_SESSION_PERSISTENCE_INDEX = 0; - /** * The counter index that is associated with the subscription persistence in the clean up job scheduling logic */ public static final int SUBSCRIPTION_PERSISTENCE_INDEX = 1; - /** * The counter index that is associated with the retained messages persistence in the clean up job scheduling logic */ public static final int RETAINED_MESSAGES_PERSISTENCE_INDEX = 2; - /** * The counter index that is associated with the client queue persistence in the clean up job scheduling logic */ public static final int CLIENT_QUEUE_PERSISTENCE_INDEX = 3; - - + static final int NUMBER_OF_PERSISTENCES = 4; private static final Logger log = LoggerFactory.getLogger(ScheduledCleanUpService.class); private final @NotNull ListeningScheduledExecutorService scheduledExecutorService; @@ -84,20 +78,20 @@ public class ScheduledCleanUpService { private final @NotNull ClientSessionSubscriptionPersistence subscriptionPersistence; private final @NotNull RetainedMessagePersistence retainedMessagePersistence; private final @NotNull ClientQueuePersistence clientQueuePersistence; - - private int bucketIndex = 0; - private int persistenceIndex = 0; private final int persistenceBucketCount; private final int cleanUpJobSchedule; private final int cleanUpTaskTimeoutSec; + private int bucketIndex = 0; + private int persistenceIndex = 0; @Inject - public ScheduledCleanUpService(final @NotNull @Persistence ListeningScheduledExecutorService scheduledExecutorService, - final @NotNull ClientSessionPersistence clientSessionPersistence, - final @NotNull ClientSessionSubscriptionPersistence subscriptionPersistence, - final @NotNull RetainedMessagePersistence retainedMessagePersistence, - final @NotNull ClientQueuePersistence clientQueuePersistence, - final @NotNull InternalConfigurationService internalConfigurationService) { + public ScheduledCleanUpService( + final @NotNull @Persistence ListeningScheduledExecutorService scheduledExecutorService, + final @NotNull ClientSessionPersistence clientSessionPersistence, + final @NotNull ClientSessionSubscriptionPersistence subscriptionPersistence, + final @NotNull RetainedMessagePersistence retainedMessagePersistence, + final @NotNull ClientQueuePersistence clientQueuePersistence, + final @NotNull InternalConfigurationService internalConfigurationService) { this.scheduledExecutorService = scheduledExecutorService; this.clientSessionPersistence = clientSessionPersistence; @@ -121,15 +115,11 @@ synchronized void scheduleCleanUpTask() { if (scheduledExecutorService.isShutdown()) { return; } - final ListenableScheduledFuture schedule = scheduledExecutorService.schedule( - new CleanUpTask( - this, - scheduledExecutorService, - cleanUpTaskTimeoutSec, - bucketIndex, - persistenceIndex), - cleanUpJobSchedule, - TimeUnit.SECONDS); + final ListenableScheduledFuture schedule = scheduledExecutorService.schedule(new CleanUpTask(this, + scheduledExecutorService, + cleanUpTaskTimeoutSec, + bucketIndex, + persistenceIndex), cleanUpJobSchedule, TimeUnit.SECONDS); persistenceIndex = (persistenceIndex + 1) % NUMBER_OF_PERSISTENCES; if (persistenceIndex == 0) { bucketIndex = (bucketIndex + 1) % persistenceBucketCount; @@ -164,11 +154,12 @@ static final class CleanUpTask implements Callable { private final int persistenceIndex; @VisibleForTesting - CleanUpTask(final @NotNull ScheduledCleanUpService scheduledCleanUpService, - final @NotNull ListeningScheduledExecutorService scheduledExecutorService, - final int cleanUpTaskTimeoutSec, - final int bucketIndex, - final int persistenceIndex) { + CleanUpTask( + final @NotNull ScheduledCleanUpService scheduledCleanUpService, + final @NotNull ListeningScheduledExecutorService scheduledExecutorService, + final int cleanUpTaskTimeoutSec, + final int bucketIndex, + final int persistenceIndex) { checkNotNull(scheduledCleanUpService, "Clean up service must not be null"); checkNotNull(scheduledExecutorService, "Executor service must not be null"); this.scheduledCleanUpService = scheduledCleanUpService; @@ -186,11 +177,11 @@ public Void call() { @Override public void onSuccess(final @Nullable Void aVoid) { - scheduledCleanUpService.scheduleCleanUpTask(); + scheduledCleanUpService.scheduleCleanUpTask(); } @Override - public void onFailure(final Throwable throwable) { + public void onFailure(final @NotNull Throwable throwable) { // We expect CancellationExceptions only for timeouts. We don't want to spam the log with // messages that suggest to a customer that something is wrong because the task is actually // still running, but we're going to schedule the next one to ensure progress. diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java index 3cc9fd2aa6..0716427fe1 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/AbstractSubscriptionSampler.java @@ -48,7 +48,6 @@ public abstract class AbstractSubscriptionSampler implements ProtocolAdapterPoll protected final @NotNull ProtocolAdapterWrapper protocolAdapter; protected final @NotNull EventService eventService; - public AbstractSubscriptionSampler( final @NotNull ProtocolAdapterWrapper protocolAdapter, final @NotNull EventService eventService) { this.protocolAdapter = protocolAdapter; diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java index e52f22930d..ee71662df8 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterConfig.java @@ -30,10 +30,10 @@ public class ProtocolAdapterConfig { private final @NotNull ProtocolSpecificAdapterConfig adapterConfig; - private @NotNull List tags; private final @NotNull String adapterId; private final @NotNull String protocolId; private final int configVersion; + private @NotNull List tags; private @NotNull List southboundMappings; private @NotNull List northboundMappings; @@ -87,51 +87,35 @@ public ProtocolAdapterConfig( return tags; } - public @NotNull List getNorthboundMappings() { - return northboundMappings; - } - - public @NotNull List getSouthboundMappings() { - return southboundMappings; - } - - public int getConfigVersion() { - return configVersion; - } - - /** - * Updates the tags for hot-reload support. - * This method is used to update tags without restarting the adapter. - * - * @param tags the new tags - */ public void setTags(final @NotNull List tags) { this.tags = tags; } - /** - * Updates the northbound mappings for hot-reload support. - * This method is used to update northbound mappings without restarting the adapter. - * - * @param northboundMappings the new northbound mappings - */ + public @NotNull List getNorthboundMappings() { + return northboundMappings; + } + public void setNorthboundMappings(final @NotNull List northboundMappings) { this.northboundMappings = northboundMappings; } - /** - * Updates the southbound mappings for hot-reload support. - * This method is used to update southbound mappings without restarting the adapter. - * - * @param southboundMappings the new southbound mappings - */ + public @NotNull List getSouthboundMappings() { + return southboundMappings; + } + public void setSouthboundMappings(final @NotNull List southboundMappings) { this.southboundMappings = southboundMappings; } + public int getConfigVersion() { + return configVersion; + } + @Override public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) return false; + if (o == null || getClass() != o.getClass()) { + return false; + } final ProtocolAdapterConfig that = (ProtocolAdapterConfig) o; return getConfigVersion() == that.getConfigVersion() && Objects.equals(getAdapterConfig(), that.getAdapterConfig()) && diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java index 4b031e3108..9b40b2621a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterManager.java @@ -76,11 +76,6 @@ public class ProtocolAdapterManager { private static final @NotNull Logger log = LoggerFactory.getLogger(ProtocolAdapterManager.class); - // ThreadLocal flag to prevent refresh() from restarting adapters during hot-reload config updates - // AtomicBoolean to skip next refresh() call during hot-reload config updates - // Must be atomic (not ThreadLocal) because refresh() runs in a different thread (refreshExecutor) - private static final @NotNull AtomicBoolean skipRefreshForAdapter = new AtomicBoolean(false); - private final @NotNull Map protocolAdapters; private final @NotNull MetricRegistry metricRegistry; private final @NotNull ModuleServicesImpl moduleServices; @@ -160,7 +155,7 @@ public void run() { } } - private static boolean updateMappingsHotReload( + private static @NotNull Boolean updateMappingsHotReload( final @NotNull ProtocolAdapterWrapper wrapper, final @NotNull String mappingType, final @NotNull Runnable updateOperation) { @@ -168,29 +163,16 @@ private static boolean updateMappingsHotReload( log.debug("Updating {} mappings for adapter '{}' via hot-reload", mappingType, wrapper.getId()); updateOperation.run(); log.info("Successfully updated {} mappings for adapter '{}' via hot-reload", mappingType, wrapper.getId()); - return true; + return Boolean.TRUE; } catch (final IllegalStateException e) { log.error("Cannot hot-reload {} mappings, adapter not in correct state: {}", mappingType, e.getMessage()); - return false; + return Boolean.FALSE; } catch (final Throwable e) { log.error("Exception happened while updating {} mappings via hot-reload: ", mappingType, e); - return false; + return Boolean.FALSE; } } - /** - * Enables skipping the next refresh operation for hot-reload config updates. - * This prevents the refresh() callback from restarting adapters when the config - * change originates from a hot-reload operation. - */ - public static void enableSkipNextRefresh() { - skipRefreshForAdapter.set(true); - } - - public static void disableSkipNextRefresh() { - skipRefreshForAdapter.set(false); - } - public void start() { if (log.isDebugEnabled()) { log.debug("Starting adapters"); @@ -198,37 +180,33 @@ public void start() { config.registerConsumer(this::refresh); } - public void refresh(final @NotNull List configs) { - // Don't submit refresh if shutdown initiated or executor is shutting down + private void refresh(final @NotNull List configs) { if (shutdownInitiated.get() || refreshExecutor.isShutdown()) { log.debug("Skipping refresh because manager is shutting down"); return; } refreshExecutor.submit(() -> { - // Atomically check and clear skip flag (hot-reload in progress) - if (skipRefreshForAdapter.getAndSet(false)) { - log.debug("Skipping refresh because hot-reload config update is in progress"); - return; - } - log.info("Refreshing adapters"); final Map protocolAdapterConfigs = configs.stream() .map(configConverter::fromEntity) .collect(Collectors.toMap(ProtocolAdapterConfig::getAdapterId, Function.identity())); - final List loadListOfAdapterNames = new ArrayList<>(protocolAdapterConfigs.keySet()); + final List loadedAdapterIds = new ArrayList<>(protocolAdapterConfigs.keySet()); final List adaptersToBeDeleted = new ArrayList<>(protocolAdapters.keySet()); - adaptersToBeDeleted.removeAll(loadListOfAdapterNames); - final List adaptersToBeCreated = new ArrayList<>(loadListOfAdapterNames); + adaptersToBeDeleted.removeAll(loadedAdapterIds); + + final List adaptersToBeCreated = new ArrayList<>(loadedAdapterIds); adaptersToBeCreated.removeAll(protocolAdapters.keySet()); + final List adaptersToBeUpdated = new ArrayList<>(protocolAdapters.keySet()); adaptersToBeUpdated.removeAll(adaptersToBeCreated); adaptersToBeUpdated.removeAll(adaptersToBeDeleted); final List failedAdapters = new ArrayList<>(); + adaptersToBeDeleted.forEach(name -> { try { if (log.isDebugEnabled()) { @@ -274,21 +252,17 @@ public void refresh(final @NotNull List configs) { } if (!protocolAdapterConfigs.get(name).equals(wrapper.getConfig())) { - final boolean isStarted = - wrapper.getRuntimeStatus() == ProtocolAdapterState.RuntimeStatus.STARTED; - - if (!isStarted) { - // Adapter is stopped - update config by recreating wrapper but don't start + if (wrapper.getRuntimeStatus() != ProtocolAdapterState.RuntimeStatus.STARTED) { if (log.isDebugEnabled()) { log.debug("Updating config for stopped adapter '{}' without starting", name); } deleteAdapterInternal(name); createAdapterInternal(protocolAdapterConfigs.get(name), versionProvider.getVersion()); } else { - // Adapter is started - do full stop->delete->create->start cycle if (log.isDebugEnabled()) { log.debug("Updating adapter '{}'", name); } + // TODO is this correct stopAsync(name).thenApply(v -> { deleteAdapterInternal(name); return null; @@ -297,10 +271,6 @@ public void refresh(final @NotNull List configs) { name), versionProvider.getVersion()))) .get(); } - } else { - if (log.isDebugEnabled()) { - log.debug("Not-updating adapter '{}' since the config is unchanged", name); - } } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); @@ -403,7 +373,7 @@ public boolean updateNorthboundMappingsHotReload( final @NotNull List northboundMappings) { return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, "northbound", - () -> wrapper.updateMappingsHotReload(northboundMappings, null, eventService))).orElse(false); + () -> wrapper.updateMappingsHotReload(northboundMappings, null, eventService))).orElse(Boolean.FALSE); } public boolean updateSouthboundMappingsHotReload( @@ -411,7 +381,7 @@ public boolean updateSouthboundMappingsHotReload( final @NotNull List southboundMappings) { return getProtocolAdapterWrapperByAdapterId(adapterId).map(wrapper -> updateMappingsHotReload(wrapper, "southbound", - () -> wrapper.updateMappingsHotReload(null, southboundMappings, eventService))).orElse(false); + () -> wrapper.updateMappingsHotReload(null, southboundMappings, eventService))).orElse(Boolean.FALSE); } public @NotNull List getDomainTags() { @@ -461,7 +431,6 @@ private void shutdown() { } // initiate stop for all adapters - log.info("Stopping {} protocol adapters during shutdown", adaptersToStop.size()); final List> stopFutures = new ArrayList<>(); for (final ProtocolAdapterWrapper wrapper : adaptersToStop) { try { @@ -499,13 +468,10 @@ private void shutdownRefreshExecutor() { refreshExecutor.shutdown(); try { if (!refreshExecutor.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - log.warn("Executor service {} did not terminate in {}s, forcing shutdown", name, timeoutSeconds); refreshExecutor.shutdownNow(); if (!refreshExecutor.awaitTermination(2, TimeUnit.SECONDS)) { log.error("Executor service {} still has running tasks after forced shutdown", name); } - } else { - log.debug("Executor service {} shut down successfully", name); } } catch (final InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java index dd69c6ad25..5f84f08d8a 100644 --- a/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/ProtocolAdapterWrapper.java @@ -86,6 +86,7 @@ public class ProtocolAdapterWrapper extends ProtocolAdapterFSM { private @Nullable CompletableFuture currentStartFuture; private @Nullable CompletableFuture currentStopFuture; private @Nullable Consumer connectionStatusListener; + private volatile @Nullable ModuleServices lastModuleServices; // Stored for hot-reload operations private volatile boolean startOperationInProgress; private volatile boolean stopOperationInProgress; @@ -169,7 +170,8 @@ public boolean startSouthbound() { log.info("Southbound started for adapter {}", adapter.getId()); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); - log.warn("Interrupted while waiting for southbound initialization after hot-reload for adapter '{}'", getId()); + log.warn("Interrupted while waiting for southbound initialization after hot-reload for adapter '{}'", + getId()); } transitionSouthboundState(StateEnum.CONNECTED); } else { @@ -213,6 +215,7 @@ public boolean startSouthbound() { startOperationInProgress = true; lastStartAttemptTime = System.currentTimeMillis(); + lastModuleServices = moduleServices; // Store for hot-reload operations currentStartFuture = CompletableFuture.supplyAsync(startProtocolAdapter(moduleServices), sharedAdapterExecutor) .thenCompose(Function.identity()) @@ -411,17 +414,7 @@ public void addTagHotReload(final @NotNull Tag tag, final @NotNull EventService } if (isPolling()) { log.debug("Starting polling for new tag '{}' on adapter '{}'", tag.getName(), getId()); - pollingService.schedulePolling(new PerContextSampler(this, - new PollingContextWrapper("unused", - tag.getName(), - MessageHandlingOptions.MQTTMessagePerTag, - false, - false, - List.of(), - 1, - -1), - eventService, - tagManager)); + schedulePollingForTag(tag, eventService); } log.info("Successfully added tag '{}' to adapter '{}' via hot-reload", tag.getName(), getId()); } finally { @@ -471,25 +464,18 @@ public void updateMappingsHotReload( consumers.add(consumer); }); - // Restart polling with new consumers - log.debug("Restarting polling for adapter '{}'", getId()); - if (isBatchPolling()) { - log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); - pollingService.schedulePolling(new PerAdapterSampler(this, eventService, tagManager)); - } - if (isPolling()) { - config.getTags() - .forEach(tag -> pollingService.schedulePolling(new PerContextSampler(this, - new PollingContextWrapper("unused", - tag.getName(), - MessageHandlingOptions.MQTTMessagePerTag, - false, - false, - List.of(), - 1, - -1), - eventService, - tagManager))); + // Restart data flow with new consumers + // For polling adapters: schedule polling + // For subscription-based adapters: reconnect to restart subscriptions + final StateEnum currentNorthboundState = currentState().northbound(); + final boolean wasConnected = (currentNorthboundState == StateEnum.CONNECTED); + if (isPolling() || isBatchPolling()) { + log.debug("Restarting polling for adapter '{}'", getId()); + schedulePollingForAllTags(eventService); + } else if (wasConnected) { + log.debug("Reconnecting subscription-based adapter '{}' after hot-reload", getId()); + stopAdapterConnection(); + startAdapterConnection(); } } @@ -559,24 +545,7 @@ private void cleanupConnectionStatusListener() { }); // start polling - if (isBatchPolling()) { - log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); - pollingService.schedulePolling(new PerAdapterSampler(this, eventService, tagManager)); - } - if (isPolling()) { - config.getTags() - .forEach(tag -> pollingService.schedulePolling(new PerContextSampler(this, - new PollingContextWrapper("unused", - tag.getName(), - MessageHandlingOptions.MQTTMessagePerTag, - false, - false, - List.of(), - 1, - -1), - eventService, - tagManager))); - } + schedulePollingForAllTags(eventService); // FSM's accept() method handles: // 1. Transitioning northbound adapter @@ -634,6 +603,57 @@ private void stopWriting() { } } + private void stopAdapterConnection() { + log.debug("Stopping adapter connection for adapter '{}' during hot-reload", getId()); + try { + // Transition northbound state to indicate shutdown in progress + final StateEnum currentNorthboundState = currentState().northbound(); + if (currentNorthboundState == StateEnum.CONNECTED) { + transitionNorthboundState(StateEnum.DISCONNECTING); + } + + synchronized (adapterLock) { + adapter.stop(new ProtocolAdapterStopInputImpl(), new ProtocolAdapterStopOutputImpl()); + } + + if (currentNorthboundState == StateEnum.CONNECTED || currentNorthboundState == StateEnum.DISCONNECTING) { + transitionNorthboundState(StateEnum.DISCONNECTED); + } + log.debug("Adapter connection stopped successfully for adapter '{}'", getId()); + } catch (final IllegalStateException stateException) { + // State transition failed, log but continue + log.warn("State transition failed while stopping adapter connection for '{}': {}", + getId(), + stateException.getMessage()); + } catch (final Exception e) { + log.error("Error stopping adapter connection for '{}'", getId(), e); + } + } + + private void startAdapterConnection() { + log.debug("Starting adapter connection for adapter '{}' during hot-reload", getId()); + try { + transitionNorthboundState(StateEnum.CONNECTING); + + final ProtocolAdapterStartOutputImpl output = new ProtocolAdapterStartOutputImpl(); + synchronized (adapterLock) { + adapter.start(new ProtocolAdapterStartInputImpl(lastModuleServices), output); + } + + // Wait for the start to complete (with timeout) + output.getStartFuture().get(30, TimeUnit.SECONDS); + + // Connection status will transition to CONNECTED via the connection status listener + log.info("Adapter connection restarted successfully for adapter '{}'", getId()); + } catch (final TimeoutException e) { + log.error("Timeout while restarting adapter connection for '{}'", getId(), e); + transitionNorthboundState(StateEnum.ERROR); + } catch (final Exception e) { + log.error("Error restarting adapter connection for '{}'", getId(), e); + transitionNorthboundState(StateEnum.ERROR); + } + } + private @NotNull Supplier<@NotNull CompletableFuture> startProtocolAdapter( final @NotNull ModuleServices moduleServices) { return () -> { @@ -711,4 +731,32 @@ private void stopProtocolAdapterOnFailedStart() { log.debug("Adapter '{}': Waiting for stop output future", adapter.getId()); return output.getOutputFuture(); } + + private @NotNull PollingContextWrapper createPollingContextForTag(final @NotNull Tag tag) { + return new PollingContextWrapper("unused", + tag.getName(), + MessageHandlingOptions.MQTTMessagePerTag, + false, + false, + List.of(), + 1, + -1); + } + + private void schedulePollingForTag(final @NotNull Tag tag, final @NotNull EventService eventService) { + pollingService.schedulePolling(new PerContextSampler(this, + createPollingContextForTag(tag), + eventService, + tagManager)); + } + + private void schedulePollingForAllTags(final @NotNull EventService eventService) { + if (isBatchPolling()) { + log.debug("Schedule batch polling for protocol adapter with id '{}'", getId()); + pollingService.schedulePolling(new PerAdapterSampler(this, eventService, tagManager)); + } + if (isPolling()) { + config.getTags().forEach(tag -> schedulePollingForTag(tag, eventService)); + } + } } diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java index e2e1bfc24e..bf445c3b39 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabaseConnection.java @@ -31,7 +31,7 @@ public class DatabaseConnection { private static final @NotNull Logger log = LoggerFactory.getLogger(DatabaseConnection.class); private final @NotNull HikariConfig config; - private final @NotNull AtomicBoolean connected = new AtomicBoolean(false); + private final @NotNull AtomicBoolean connected; private volatile @Nullable HikariDataSource ds; public DatabaseConnection( @@ -45,6 +45,7 @@ public DatabaseConnection( final boolean encrypt) { config = new HikariConfig(); + connected = new AtomicBoolean(false); switch (dbType) { case POSTGRESQL -> { @@ -99,7 +100,7 @@ public void connect() { } public @NotNull Connection getConnection() throws SQLException { - if (ds == null) { + if (!connected.get()) { throw new IllegalStateException("Hikari Connection Pool must be started before usage."); } return ds.getConnection(); @@ -108,18 +109,17 @@ public void connect() { public void close() { if (!connected.compareAndSet(true, false)) { log.debug("Database connection already closed or not connected"); - return; // Already closed or never connected + return; } if (ds != null && !ds.isClosed()) { log.debug("Closing HikariCP datasource"); try { - // Hard shutdown of HikariCP to ensure threads are terminated ds.close(); log.debug("HikariCP datasource closed successfully"); } catch (final Exception e) { log.error("Error closing HikariCP datasource", e); } finally { - ds = null; // Clear reference to allow GC + ds = null; } } } diff --git a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java index f7031438d1..f1b6940a52 100644 --- a/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java +++ b/modules/hivemq-edge-module-databases/src/main/java/com/hivemq/edge/adapters/databases/DatabasesPollingProtocolAdapter.java @@ -47,6 +47,8 @@ import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import static com.hivemq.adapter.sdk.api.state.ProtocolAdapterState.ConnectionStatus.STATELESS; + public class DatabasesPollingProtocolAdapter implements BatchPollingProtocolAdapter { public static final int TIMEOUT = 30; @@ -182,7 +184,7 @@ public void poll(final @NotNull BatchPollingInput pollingInput, final @NotNull B log.debug("Handling tags for the adapter"); tags.forEach(tag -> loadDataFromDB(pollingOutput, (DatabasesAdapterTag) tag)); - // Don't manually set connection status - FSM manages this automatically + protocolAdapterState.setConnectionStatus(STATELESS); pollingOutput.finish(); } diff --git a/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java b/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java index 2b7dc8f1b9..c3e917974e 100644 --- a/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java +++ b/modules/hivemq-edge-module-modbus/src/main/java/com/hivemq/edge/adapters/modbus/ModbusProtocolAdapter.java @@ -143,33 +143,26 @@ public void stop(final @NotNull ProtocolAdapterStopInput input, final @NotNull P log.info("Stopping Modbus protocol adapter {}", adapterId); publishChangedDataOnlyHandler.clear(); try { - client - .disconnect() - .whenComplete((unused, throwable) -> { - try { - if (throwable == null) { - protocolAdapterState.setConnectionStatus(DISCONNECTED); - output.stoppedSuccessfully(); - log.info("Successfully stopped Modbus protocol adapter {}", adapterId); - } else { - protocolAdapterState.setConnectionStatus(ERROR); - output.failStop(throwable, "Error encountered closing connection to Modbus server."); - log.error("Unable to stop the connection to the Modbus server", throwable); - } - } finally { - startRequested.set(false); - stopRequested.set(false); + client.disconnect().whenComplete((unused, throwable) -> { + try { + if (throwable == null) { + protocolAdapterState.setConnectionStatus(DISCONNECTED); + output.stoppedSuccessfully(); + log.info("Successfully stopped Modbus protocol adapter {}", adapterId); + } else { + protocolAdapterState.setConnectionStatus(ERROR); + output.failStop(throwable, "Error encountered closing connection to Modbus server."); + log.error("Unable to stop the connection to the Modbus server", throwable); } - }) - .toCompletableFuture() - .get(); + } finally { + startRequested.set(false); + stopRequested.set(false); + } + }).toCompletableFuture().get(); } catch (final InterruptedException | ExecutionException e) { log.error("Unable to stop the connection to the Modbus server", e); } } else { - // stop() called when already stopped or stop in progress - // This can happen when stopping after a failed start - // Just complete successfully - adapter is already stopped log.debug("Stop called for Modbus adapter {} but adapter is already stopped or stopping", adapterId); output.stoppedSuccessfully(); } diff --git a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java index a8102357a7..f46dd196a5 100644 --- a/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java +++ b/modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java @@ -54,7 +54,6 @@ import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Collectors; @@ -114,27 +113,33 @@ public synchronized void start( } final OpcUaClientConnection conn; - if (opcUaClientConnection.compareAndSet(null, conn = new OpcUaClientConnection(adapterId, - tagList, - protocolAdapterState, - input.moduleServices().protocolAdapterTagStreamingService(), - dataPointFactory, - input.moduleServices().eventService(), - protocolAdapterMetricsService, - config, - lastSubscriptionId))) { + if (opcUaClientConnection.compareAndSet(null, + conn = new OpcUaClientConnection(adapterId, + tagList, + protocolAdapterState, + input.moduleServices().protocolAdapterTagStreamingService(), + dataPointFactory, + input.moduleServices().eventService(), + protocolAdapterMetricsService, + config, + lastSubscriptionId))) { protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); - CompletableFuture.supplyAsync(() -> conn.start(parsedConfig)).whenComplete((success, throwable) -> { - if (!success || throwable != null) { - this.opcUaClientConnection.set(null); + try { + if (conn.start(parsedConfig)) { + log.info("Successfully started OPC UA protocol adapter {}", adapterId); + output.startedSuccessfully(); + } else { + opcUaClientConnection.set(null); protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.ERROR); - log.error("Failed to start OPC UA client", throwable); + output.failStart(new IllegalStateException("check the logs"), "Failed to start OPC UA client"); } - }); - - log.info("Successfully started OPC UA protocol adapter {}", adapterId); - output.startedSuccessfully(); + } catch (final Exception e) { + opcUaClientConnection.set(null); + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.ERROR); + log.error("Failed to start OPC UA client", e); + output.failStart(e.getCause(), "Failed to start OPC UA client"); + } } else { log.warn("Tried starting already started OPC UA protocol adapter {}", adapterId); } @@ -160,8 +165,6 @@ public void destroy() { final OpcUaClientConnection conn = opcUaClientConnection.getAndSet(null); if (conn != null) { try { - // Destroy synchronously to ensure all resources (threads, connections) are cleaned up - // before returning. This prevents resource leaks in tests and during adapter lifecycle. conn.destroy(); log.info("Destroyed OPC UA protocol adapter {}", adapterId); } catch (final Exception e) { From 1b35b411773e73d99726fbc1e94a95779ef794fd Mon Sep 17 00:00:00 2001 From: marregui Date: Wed, 29 Oct 2025 16:14:16 +0100 Subject: [PATCH 50/50] Changes by jmader --- SESSION_0_FSM_REVIEW.md | 8 ++++++++ .../src/main/java/com/hivemq/fsm/state-machine.md | 12 ++++++++++-- .../src/main/java/com/hivemq/fsm/transitions.md | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 SESSION_0_FSM_REVIEW.md diff --git a/SESSION_0_FSM_REVIEW.md b/SESSION_0_FSM_REVIEW.md new file mode 100644 index 0000000000..bb014dd720 --- /dev/null +++ b/SESSION_0_FSM_REVIEW.md @@ -0,0 +1,8 @@ + +## Discussions for later +- We need to take care of modifications of the configuration file (Global lock) + +## Tasks +- the state machine to functions within transitions (fully unit tested) +- (?) Add a testing protocol adapter for testing (let's see if we need it, mockito might be) +- new PR Epic!! -> diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/state-machine.md b/hivemq-edge/src/main/java/com/hivemq/fsm/state-machine.md index 1596dff640..f72e8f6ee9 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/state-machine.md +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/state-machine.md @@ -26,20 +26,28 @@ Four states control adapter lifecycle. 2. **STARTING** - Initialization in progress - Calls `onStarting()` hook - - Transitions: → STARTED (success), → STOPPED (failure) + - Transitions: → STARTED (success), → STOPPED (failure), → ERROR (non recoverable error) 3. **STARTED** - Adapter operational - - Transitions: → STOPPING + - Transitions: → STOPPING, → ERROR (non recoverable error) 4. **STOPPING** - Cleanup in progress - Calls `onStopping()` hook - Transitions: → STOPPED +5. **ERROR** + - Non-recoverable error, adapter is dead + - This is a terminal state + - Transitions: → STARTING + ### Transition Rules ``` + + ERROR + STOPPED → STARTING → STARTED → STOPPING → STOPPED ↑ ↓ └────────────────────────────────┘ diff --git a/hivemq-edge/src/main/java/com/hivemq/fsm/transitions.md b/hivemq-edge/src/main/java/com/hivemq/fsm/transitions.md index a25f02090b..bb9ac9ac39 100644 --- a/hivemq-edge/src/main/java/com/hivemq/fsm/transitions.md +++ b/hivemq-edge/src/main/java/com/hivemq/fsm/transitions.md @@ -22,8 +22,9 @@ graph TB subgraph Adapter Lifecycle STOPPED -->|startAdapter| STARTING STARTING -->|success| STARTED - STARTING -->|failure| STOPPED + STARTING -->|non recoverable error| ERROR STARTED -->|stopAdapter| STOPPING + STARTED -->|non recoverable error| ERROR STOPPING --> STOPPED end ```