diff --git a/hivemq-edge/src/main/java/com/hivemq/common/i18n/StringTemplate.java b/hivemq-edge/src/main/java/com/hivemq/common/i18n/StringTemplate.java new file mode 100644 index 0000000000..19158bb39a --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/common/i18n/StringTemplate.java @@ -0,0 +1,50 @@ +/* + * 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.common.i18n; + +import freemarker.template.Configuration; +import freemarker.template.Template; +import org.jetbrains.annotations.NotNull; + +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Map; + +public final class StringTemplate { + private final static @NotNull Configuration CONFIGURATION = new Configuration(Configuration.VERSION_2_3_22); + + static { + CONFIGURATION.setDefaultEncoding(StandardCharsets.UTF_8.name()); + CONFIGURATION.setLocale(Locale.US); + } + + private StringTemplate() { + } + + public static @NotNull String format( + final @NotNull String stringTemplate, + final @NotNull Map arguments) { + try (final StringWriter stringWriter = new StringWriter();) { + final Template template = new Template(stringTemplate, stringTemplate, CONFIGURATION); + template.process(arguments, stringWriter); + return stringWriter.toString(); + } catch (final Exception e) { + return e.getMessage(); + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ClassLoaderUtils.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ClassLoaderUtils.java new file mode 100644 index 0000000000..ea3f68d560 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ClassLoaderUtils.java @@ -0,0 +1,38 @@ +/* + * 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.fsm; + +import org.jetbrains.annotations.NotNull; + +import java.util.function.Supplier; + +public final class ClassLoaderUtils { + private ClassLoaderUtils() { + } + + public static @NotNull T runWithContextLoader( + final @NotNull ClassLoader contextLoader, + final @NotNull Supplier wrapperSupplier) { + final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(contextLoader); + return wrapperSupplier.get(); + } finally { + Thread.currentThread().setContextClassLoader(contextClassLoader); + } + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/I18nProtocolAdapterMessage.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/I18nProtocolAdapterMessage.java new file mode 100644 index 0000000000..8761420f17 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/I18nProtocolAdapterMessage.java @@ -0,0 +1,62 @@ +/* + * 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.fsm; + +import com.hivemq.common.i18n.I18nError; +import com.hivemq.common.i18n.I18nErrorTemplate; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public enum I18nProtocolAdapterMessage implements I18nError { + FSM_TRANSITION_FAILURE_UNABLE_TO_TRANSITION_FROM_STATE_TO_STATE, + FSM_TRANSITION_SUCCESS_STATE_IS_UNCHANGED, + FSM_TRANSITION_SUCCESS_TRANSITIONED_FROM_STATE_TO_STATE, + FSM_CONNECTION_TRANSITION_FAILURE_UNABLE_TO_TRANSITION_FROM_STATE_TO_STATE, + FSM_CONNECTION_TRANSITION_SUCCESS_STATE_IS_UNCHANGED, + FSM_CONNECTION_TRANSITION_SUCCESS_TRANSITIONED_FROM_STATE_TO_STATE, + PROTOCOL_ADAPTER_MANAGER_PROTOCOL_ADAPTER_DELETED, + PROTOCOL_ADAPTER_MANAGER_PROTOCOL_ADAPTER_NOT_FOUND, + ; + + private static final @NotNull String RESOURCE_NAME_PREFIX = "templates/protocol-adapter-messages-"; + private static final @NotNull String RESOURCE_NAME_SUFFIX = ".properties"; + private static final @NotNull I18nErrorTemplate TEMPLATE = + new I18nErrorTemplate(locale -> RESOURCE_NAME_PREFIX + locale + RESOURCE_NAME_SUFFIX, + I18nProtocolAdapterMessage.class.getClassLoader()); + + private final @NotNull String key; + + I18nProtocolAdapterMessage() { + key = name().toLowerCase().replace("_", "."); + } + + @Override + public @NotNull String get(final @NotNull Map map) { + return TEMPLATE.get(this, map); + } + + @Override + public @NotNull String getKey() { + return key; + } + + @Override + public @NotNull String getName() { + return name(); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionState.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionState.java new file mode 100644 index 0000000000..6f75ea87a5 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionState.java @@ -0,0 +1,158 @@ +/* + * 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.fsm; + +import org.jetbrains.annotations.NotNull; + +import java.util.function.Function; + +public enum ProtocolAdapterConnectionState { + Closed(ProtocolAdapterConnectionState::transitionFromClosed), + Closing(ProtocolAdapterConnectionState::transitionFromClosing), + Connected(ProtocolAdapterConnectionState::transitionFromConnected), + Connecting(ProtocolAdapterConnectionState::transitionFromConnecting), + Disconnected(ProtocolAdapterConnectionState::transitionFromDisconnected), + Disconnecting(ProtocolAdapterConnectionState::transitionFromDisconnecting), + Error(ProtocolAdapterConnectionState::transitionFromError), + ErrorClosing(ProtocolAdapterConnectionState::transitionFromErrorClosing), + ; + + private final @NotNull Function + transitionFunction; + + ProtocolAdapterConnectionState(@NotNull final Function transitionFunction) { + this.transitionFunction = transitionFunction; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromClosed( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Closed; + return switch (toState) { + case Closed -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Disconnected -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromClosing( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Closing; + return switch (toState) { + case Closing -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Closed -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromConnected( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Connected; + return switch (toState) { + case Connected -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Disconnecting, Closing, ErrorClosing -> + ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromConnecting( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Connecting; + return switch (toState) { + case Connecting -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Connected, Error -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromDisconnected( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Disconnected; + return switch (toState) { + case Disconnected -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Connecting -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromDisconnecting( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Disconnecting; + return switch (toState) { + case Disconnecting -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Connecting, Disconnected -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromError( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.Error; + return switch (toState) { + case Error -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Disconnected -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterConnectionTransitionResponse transitionFromErrorClosing( + final @NotNull ProtocolAdapterConnectionState toState) { + final ProtocolAdapterConnectionState fromState = ProtocolAdapterConnectionState.ErrorClosing; + return switch (toState) { + case ErrorClosing -> ProtocolAdapterConnectionTransitionResponse.notChanged(fromState); + case Error -> ProtocolAdapterConnectionTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterConnectionTransitionResponse.failure(fromState, toState); + }; + } + + public @NotNull ProtocolAdapterConnectionTransitionResponse transition( + final @NotNull ProtocolAdapterConnectionState toState) { + return transitionFunction.apply(toState); + } + + public boolean isClosed() { + return this == Closed; + } + + public boolean isClosing() { + return this == Closing; + } + + public boolean isConnected() { + return this == Connected; + } + + public boolean isConnecting() { + return this == Connecting; + } + + public boolean isDisconnecting() { + return this == Disconnecting; + } + + public boolean isDisconnected() { + return this == Disconnected; + } + + public boolean isError() { + return this == Error; + } + + public boolean isErrorClosing() { + return this == ErrorClosing; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionTransitionResponse.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionTransitionResponse.java new file mode 100644 index 0000000000..884d19c785 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionTransitionResponse.java @@ -0,0 +1,65 @@ +/* + * 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.fsm; + +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public record ProtocolAdapterConnectionTransitionResponse(ProtocolAdapterConnectionState fromState, + ProtocolAdapterConnectionState toState, + ProtocolAdapterTransitionStatus status, String message, + Throwable error) { + + public static final String FROM_STATE = "fromState"; + public static final String TO_STATE = "toState"; + public static final String STATE = "state"; + + public static ProtocolAdapterConnectionTransitionResponse success( + final @NotNull ProtocolAdapterConnectionState fromState, + final @NotNull ProtocolAdapterConnectionState toState) { + return new ProtocolAdapterConnectionTransitionResponse(fromState, + toState, + ProtocolAdapterTransitionStatus.Success, + I18nProtocolAdapterMessage.FSM_CONNECTION_TRANSITION_SUCCESS_TRANSITIONED_FROM_STATE_TO_STATE.get(Map.of( + FROM_STATE, + fromState.name(), + TO_STATE, + toState.name())), + null); + } + + public static ProtocolAdapterConnectionTransitionResponse notChanged(final @NotNull ProtocolAdapterConnectionState state) { + return new ProtocolAdapterConnectionTransitionResponse(state, + state, + ProtocolAdapterTransitionStatus.NotChanged, + I18nProtocolAdapterMessage.FSM_CONNECTION_TRANSITION_SUCCESS_STATE_IS_UNCHANGED.get(Map.of(STATE, + state.name())), + null); + } + + public static ProtocolAdapterConnectionTransitionResponse failure( + final @NotNull ProtocolAdapterConnectionState fromState, + final @NotNull ProtocolAdapterConnectionState toState) { + return new ProtocolAdapterConnectionTransitionResponse(fromState, + toState, + ProtocolAdapterTransitionStatus.Failure, + I18nProtocolAdapterMessage.FSM_CONNECTION_TRANSITION_FAILURE_UNABLE_TO_TRANSITION_FROM_STATE_TO_STATE.get( + Map.of(FROM_STATE, fromState.name(), TO_STATE, toState.name())), + null); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManager2.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManager2.java new file mode 100644 index 0000000000..208a0a47b6 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManager2.java @@ -0,0 +1,351 @@ +/* + * 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.fsm; + +import com.codahale.metrics.MetricRegistry; +import com.google.common.base.Preconditions; +import com.google.common.collect.Sets; +import com.hivemq.adapter.sdk.api.ProtocolAdapter; +import com.hivemq.adapter.sdk.api.events.EventService; +import com.hivemq.adapter.sdk.api.events.model.Event; +import com.hivemq.adapter.sdk.api.exceptions.ProtocolAdapterException; +import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; +import com.hivemq.adapter.sdk.api.services.ProtocolAdapterMetricsService; +import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; +import com.hivemq.configuration.reader.ProtocolAdapterExtractor; +import com.hivemq.edge.HiveMQEdgeRemoteService; +import com.hivemq.edge.VersionProvider; +import com.hivemq.edge.modules.adapters.data.TagManager; +import com.hivemq.edge.modules.adapters.impl.ModuleServicesImpl; +import com.hivemq.edge.modules.adapters.impl.ModuleServicesPerModuleImpl; +import com.hivemq.edge.modules.adapters.impl.ProtocolAdapterStateImpl; +import com.hivemq.edge.modules.adapters.metrics.ProtocolAdapterMetricsServiceImpl; +import com.hivemq.edge.modules.api.adapters.ProtocolAdapterPollingService; +import com.hivemq.protocols.InternalProtocolAdapterWritingService; +import com.hivemq.protocols.ProtocolAdapterConfig; +import com.hivemq.protocols.ProtocolAdapterConfigConverter; +import com.hivemq.protocols.ProtocolAdapterFactoryManager; +import com.hivemq.protocols.ProtocolAdapterInputImpl; +import com.hivemq.protocols.ProtocolAdapterMetrics; +import com.hivemq.protocols.northbound.NorthboundConsumerFactory; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import java.util.stream.Collectors; + + +public class ProtocolAdapterManager2 { + public static final String ADAPTER_ID = "adapterId"; + private static final Logger LOGGER = LoggerFactory.getLogger(ProtocolAdapterManager2.class); + private final @NotNull Map protocolAdapterMap; + private final @NotNull MetricRegistry metricRegistry; + private final @NotNull ModuleServicesImpl moduleServices; + private final @NotNull HiveMQEdgeRemoteService remoteService; + private final @NotNull EventService eventService; + private final @NotNull ProtocolAdapterConfigConverter configConverter; + private final @NotNull VersionProvider versionProvider; + private final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService; + private final @NotNull ProtocolAdapterMetrics protocolAdapterMetrics; + private final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService; + private final @NotNull ProtocolAdapterFactoryManager protocolAdapterFactoryManager; + private final @NotNull NorthboundConsumerFactory northboundConsumerFactory; + private final @NotNull TagManager tagManager; + private final @NotNull ProtocolAdapterExtractor protocolAdapterConfig; + private final @NotNull ExecutorService executorService; + private final @NotNull ReentrantReadWriteLock readWriteLock; + + public ProtocolAdapterManager2( + final @NotNull MetricRegistry metricRegistry, + final @NotNull ModuleServicesImpl moduleServices, + final @NotNull HiveMQEdgeRemoteService remoteService, + final @NotNull EventService eventService, + final @NotNull ProtocolAdapterConfigConverter configConverter, + final @NotNull VersionProvider versionProvider, + final @NotNull ProtocolAdapterPollingService protocolAdapterPollingService, + final @NotNull ProtocolAdapterMetrics protocolAdapterMetrics, + final @NotNull InternalProtocolAdapterWritingService protocolAdapterWritingService, + final @NotNull ProtocolAdapterFactoryManager protocolAdapterFactoryManager, + final @NotNull NorthboundConsumerFactory northboundConsumerFactory, + final @NotNull TagManager tagManager, + final @NotNull ProtocolAdapterExtractor protocolAdapterConfig) { + this.protocolAdapterMap = new HashMap<>(); + this.metricRegistry = metricRegistry; + this.moduleServices = moduleServices; + this.remoteService = remoteService; + this.eventService = eventService; + this.configConverter = configConverter; + this.versionProvider = versionProvider; + this.protocolAdapterPollingService = protocolAdapterPollingService; + this.protocolAdapterMetrics = protocolAdapterMetrics; + this.protocolAdapterWritingService = protocolAdapterWritingService; + this.protocolAdapterFactoryManager = protocolAdapterFactoryManager; + this.northboundConsumerFactory = northboundConsumerFactory; + this.tagManager = tagManager; + this.protocolAdapterConfig = protocolAdapterConfig; + this.executorService = Executors.newSingleThreadExecutor(); + this.readWriteLock = new ReentrantReadWriteLock(); + Runtime.getRuntime().addShutdownHook(new Thread(executorService::shutdown)); + protocolAdapterWritingService.addWritingChangedCallback(() -> protocolAdapterFactoryManager.writingEnabledChanged( + protocolAdapterWritingService.writingEnabled())); + } + + public boolean isBusy() { + return readWriteLock.isWriteLocked(); + } + + public void register() { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Starting adapters"); + } + protocolAdapterConfig.registerConsumer(this::refresh); + } + + public @NotNull Optional getProtocolAdapterWrapperByAdapterId(final @NotNull String adapterId) { + Preconditions.checkNotNull(adapterId); + final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock(); + try { + readLock.lock(); + return Optional.ofNullable(protocolAdapterMap.get(adapterId)); + } finally { + readLock.unlock(); + } + } + + protected @NotNull Set getProtocolAdapterIdSet() { + final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock(); + try { + readLock.lock(); + return new HashSet<>(protocolAdapterMap.keySet()); + } finally { + readLock.unlock(); + } + } + + protected void createProtocolAdapter(final @NotNull ProtocolAdapterConfig config, final @NotNull String version) { + Preconditions.checkNotNull(config); + final String adapterId = config.getAdapterId(); + final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock(); + try { + writeLock.lock(); + if (!protocolAdapterMap.containsKey(adapterId)) { + final String configProtocolId = config.getProtocolId(); + // legacy handling, hardcoded here, to not add legacy stuff into the adapter-sdk + final String adapterType = switch (configProtocolId) { + case "ethernet-ip" -> "eip"; + case "opc-ua-client" -> "opcua"; + case "file_input" -> "file"; + default -> configProtocolId; + }; + + final Optional> maybeFactory = protocolAdapterFactoryManager.get(adapterType); + if (maybeFactory.isEmpty()) { + throw new IllegalArgumentException("Protocol adapter for config " + adapterType + " not found."); + } + final ProtocolAdapterFactory factory = maybeFactory.get(); + + LOGGER.info("Found configuration for adapter {} / {}", config.getAdapterId(), adapterType); + config.missingTags().ifPresent(missingTag -> { + throw new IllegalArgumentException("Tags used in mappings but not configured in adapter " + + adapterType + + ": " + + missingTag); + }); + + final ProtocolAdapterWrapper2 wrapper = + ClassLoaderUtils.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, + tagManager); + final ProtocolAdapter protocolAdapter = factory.createAdapter(factory.getInformation(), + new ProtocolAdapterInputImpl(configProtocolId, + config.getAdapterId(), + config.getAdapterConfig(), + config.getTags(), + config.getNorthboundMappings(), + version, + state, + perModule, + metricsService)); + // hen-egg problem. Rather solve this here as have not final fields in the adapter. + perModule.setAdapter(protocolAdapter); + protocolAdapterMetrics.increaseProtocolAdapterMetric(configProtocolId); + return new ProtocolAdapterWrapper2(protocolAdapter); + }); + protocolAdapterMap.put(adapterId, wrapper); + } + } finally { + writeLock.unlock(); + } + } + + protected @NotNull Optional deleteProtocolAdapterWrapperByAdapterId(final @NotNull String adapterId) { + Preconditions.checkNotNull(adapterId); + final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock(); + try { + writeLock.lock(); + return Optional.ofNullable(protocolAdapterMap.remove(adapterId)); + } finally { + writeLock.unlock(); + } + } + + public void start(final @NotNull String adapterId) throws ProtocolAdapterException { + final var optionalWrapper = getProtocolAdapterWrapperByAdapterId(adapterId); + if (optionalWrapper.isEmpty()) { + throw new ProtocolAdapterException(I18nProtocolAdapterMessage.PROTOCOL_ADAPTER_MANAGER_PROTOCOL_ADAPTER_NOT_FOUND.get( + Map.of(ADAPTER_ID, adapterId))); + } + optionalWrapper.get().start(); + } + + public void stop(final @NotNull String adapterId, final boolean destroy) throws ProtocolAdapterException { + final var optionalWrapper = getProtocolAdapterWrapperByAdapterId(adapterId); + if (optionalWrapper.isEmpty()) { + throw new ProtocolAdapterException(I18nProtocolAdapterMessage.PROTOCOL_ADAPTER_MANAGER_PROTOCOL_ADAPTER_NOT_FOUND.get( + Map.of(ADAPTER_ID, adapterId))); + } + optionalWrapper.get().stop(destroy); + } + + protected void deleteProtocolAdapterByAdapterId(final @NotNull String adapterId) { + deleteProtocolAdapterWrapperByAdapterId(adapterId).ifPresentOrElse(wrapper -> { + final String protocolId = wrapper.getProtocolAdapterInformation().getProtocolId(); + protocolAdapterMetrics.decreaseProtocolAdapterMetric(protocolId); + eventService.createAdapterEvent(adapterId, protocolId) + .withSeverity(Event.SEVERITY.WARN) + .withMessage(I18nProtocolAdapterMessage.PROTOCOL_ADAPTER_MANAGER_PROTOCOL_ADAPTER_DELETED.get(Map.of( + ADAPTER_ID, + adapterId))) + .fire(); + }, () -> LOGGER.warn("Tried to delete adapter '{}' but it was not found in the system.", adapterId)); + } + + protected void refresh(final @NotNull List configs) { + executorService.submit(() -> { + LOGGER.info("Refreshing adapters"); + try { + final Map protocolAdapterConfigs = configs.stream() + .map(configConverter::fromEntity) + .collect(Collectors.toMap(ProtocolAdapterConfig::getAdapterId, Function.identity())); + + final Set oldProtocolAdapterIdSet = getProtocolAdapterIdSet(); + final Set newProtocolAdapterIdSet = new HashSet<>(protocolAdapterConfigs.keySet()); + + final Set toBeDeletedProtocolAdapterIdSet = + new HashSet<>(Sets.difference(oldProtocolAdapterIdSet, newProtocolAdapterIdSet)); + final Set toBeCreatedProtocolAdapterIdSet = + new HashSet<>(Sets.difference(newProtocolAdapterIdSet, oldProtocolAdapterIdSet)); + final Set toBeUpdatedProtocolAdapterIdSet = + new HashSet<>(Sets.intersection(newProtocolAdapterIdSet, oldProtocolAdapterIdSet)); + + final Set failedAdapterSet = new HashSet<>(); + + CompletableFuture.allOf(toBeDeletedProtocolAdapterIdSet.stream() + .map(adapterId -> CompletableFuture.runAsync(() -> { + try { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Deleting adapter '{}'", adapterId); + } + stop(adapterId, true); + deleteProtocolAdapterByAdapterId(adapterId); + } catch (final Exception e) { + failedAdapterSet.add(adapterId); + LOGGER.error("Failed deleting adapter {}", adapterId, e); + } + })) + .toList() + .toArray(new CompletableFuture[0])).get(); + + CompletableFuture.allOf(toBeCreatedProtocolAdapterIdSet.stream() + .map(adapterId -> CompletableFuture.runAsync(() -> { + try { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Creating adapter '{}'", adapterId); + } + createProtocolAdapter(protocolAdapterConfigs.get(adapterId), + versionProvider.getVersion()); + start(adapterId); + } catch (final Exception e) { + failedAdapterSet.add(adapterId); + LOGGER.error("Failed creating adapter {}", adapterId, e); + } + })) + .toList() + .toArray(new CompletableFuture[0])).get(); + + CompletableFuture.allOf(toBeUpdatedProtocolAdapterIdSet.stream() + .map(adapterId -> CompletableFuture.runAsync(() -> { + try { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Updating adapter '{}'", adapterId); + } + stop(adapterId, true); + deleteProtocolAdapterByAdapterId(adapterId); + createProtocolAdapter(protocolAdapterConfigs.get(adapterId), + versionProvider.getVersion()); + start(adapterId); + } catch (final Exception e) { + failedAdapterSet.add(adapterId); + LOGGER.error("Failed updating adapter {}", adapterId, e); + } + })) + .toList() + .toArray(new CompletableFuture[0])).get(); + + if (failedAdapterSet.isEmpty()) { + eventService.configurationEvent() + .withSeverity(Event.SEVERITY.INFO) + .withMessage("Configuration has been successfully updated") + .fire(); + } else { + eventService.configurationEvent() + .withSeverity(Event.SEVERITY.CRITICAL) + .withMessage("Reloading of configuration failed") + .fire(); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.error("Interrupted while refreshing adapters", e); + } catch (final ExecutionException e) { + LOGGER.error("Failed refreshing adapters", e); + } + }); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManagerState.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManagerState.java new file mode 100644 index 0000000000..f50007376d --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterManagerState.java @@ -0,0 +1,23 @@ +/* + * 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.fsm; + +public enum ProtocolAdapterManagerState { + Idle, + Running, + ; +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterState.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterState.java new file mode 100644 index 0000000000..7f8c742fe6 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterState.java @@ -0,0 +1,111 @@ +/* + * 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.fsm; + +import org.jetbrains.annotations.NotNull; + +import java.util.function.Function; + +public enum ProtocolAdapterState { + Starting(ProtocolAdapterState::transitionFromStarting), + Started(ProtocolAdapterState::transitionFromStarted), + Stopping(ProtocolAdapterState::transitionFromStopping), + Stopped(ProtocolAdapterState::transitionFromStopped), + Error(ProtocolAdapterState::transitionFromError), + ; + + private final @NotNull Function transitionFunction; + + ProtocolAdapterState(@NotNull final Function transitionFunction) { + this.transitionFunction = transitionFunction; + } + + public static @NotNull ProtocolAdapterTransitionResponse transitionFromStarting( + final @NotNull ProtocolAdapterState toState) { + final ProtocolAdapterState fromState = ProtocolAdapterState.Starting; + return switch (toState) { + case Starting -> ProtocolAdapterTransitionResponse.notChanged(fromState); + case Started, Stopping, Error -> ProtocolAdapterTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterTransitionResponse transitionFromStarted( + final @NotNull ProtocolAdapterState toState) { + final ProtocolAdapterState fromState = ProtocolAdapterState.Started; + return switch (toState) { + case Started -> ProtocolAdapterTransitionResponse.notChanged(fromState); + case Stopping, Error -> ProtocolAdapterTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterTransitionResponse transitionFromStopping( + final @NotNull ProtocolAdapterState toState) { + final ProtocolAdapterState fromState = ProtocolAdapterState.Stopping; + return switch (toState) { + case Stopping -> ProtocolAdapterTransitionResponse.notChanged(fromState); + case Stopped, Error -> ProtocolAdapterTransitionResponse.success(fromState, toState); + default -> ProtocolAdapterTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterTransitionResponse transitionFromStopped( + final @NotNull ProtocolAdapterState toState) { + final ProtocolAdapterState fromState = ProtocolAdapterState.Stopped; + return switch (toState) { + case Starting -> ProtocolAdapterTransitionResponse.success(fromState, toState); + case Stopped -> ProtocolAdapterTransitionResponse.notChanged(fromState); + default -> ProtocolAdapterTransitionResponse.failure(fromState, toState); + }; + } + + public static @NotNull ProtocolAdapterTransitionResponse transitionFromError( + final @NotNull ProtocolAdapterState toState) { + final ProtocolAdapterState fromState = ProtocolAdapterState.Error; + return switch (toState) { + case Starting -> ProtocolAdapterTransitionResponse.success(fromState, toState); + case Error -> ProtocolAdapterTransitionResponse.notChanged(fromState); + default -> ProtocolAdapterTransitionResponse.failure(fromState, toState); + }; + } + + public @NotNull ProtocolAdapterTransitionResponse transition( + final @NotNull ProtocolAdapterState toState) { + return transitionFunction.apply(toState); + } + + public boolean isStarting() { + return this == Starting; + } + + public boolean isStarted() { + return this == Started; + } + + public boolean isStopping() { + return this == Stopping; + } + + public boolean isStopped() { + return this == Stopped; + } + + public boolean isError() { + return this == Error; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionResponse.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionResponse.java new file mode 100644 index 0000000000..e81d7afb77 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionResponse.java @@ -0,0 +1,66 @@ +/* + * 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.fsm; + +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public record ProtocolAdapterTransitionResponse(ProtocolAdapterState fromState, ProtocolAdapterState toState, + ProtocolAdapterTransitionStatus status, String message, + Throwable error) { + + public static final String FROM_STATE = "fromState"; + public static final String TO_STATE = "toState"; + public static final String STATE = "state"; + + public static ProtocolAdapterTransitionResponse success( + final @NotNull ProtocolAdapterState fromState, + final @NotNull ProtocolAdapterState toState) { + return new ProtocolAdapterTransitionResponse(fromState, + toState, + ProtocolAdapterTransitionStatus.Success, + I18nProtocolAdapterMessage.FSM_TRANSITION_SUCCESS_TRANSITIONED_FROM_STATE_TO_STATE.get(Map.of(FROM_STATE, + fromState.name(), + TO_STATE, + toState.name())), + null); + } + + public static ProtocolAdapterTransitionResponse notChanged(final @NotNull ProtocolAdapterState state) { + return new ProtocolAdapterTransitionResponse(state, + state, + ProtocolAdapterTransitionStatus.NotChanged, + I18nProtocolAdapterMessage.FSM_TRANSITION_SUCCESS_STATE_IS_UNCHANGED.get(Map.of(STATE, + state.name())), + null); + } + + public static ProtocolAdapterTransitionResponse failure( + final @NotNull ProtocolAdapterState fromState, + final @NotNull ProtocolAdapterState toState) { + return new ProtocolAdapterTransitionResponse(fromState, + toState, + ProtocolAdapterTransitionStatus.Failure, + I18nProtocolAdapterMessage.FSM_TRANSITION_FAILURE_UNABLE_TO_TRANSITION_FROM_STATE_TO_STATE.get(Map.of( + FROM_STATE, + fromState.name(), + TO_STATE, + toState.name())), + null); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionStatus.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionStatus.java new file mode 100644 index 0000000000..e957722713 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterTransitionStatus.java @@ -0,0 +1,28 @@ +/* + * 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.fsm; + +public enum ProtocolAdapterTransitionStatus { + Success, + Failure, + NotChanged, + ; + + public boolean isSuccess() { + return this == Success; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapper2.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapper2.java new file mode 100644 index 0000000000..c27a95cb6d --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapper2.java @@ -0,0 +1,206 @@ +/* + * 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.fsm; + +import com.hivemq.adapter.sdk.api.ProtocolAdapter; +import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ProtocolAdapterWrapper2 { + private static final Logger LOGGER = LoggerFactory.getLogger(ProtocolAdapterWrapper2.class); + protected final @NotNull ProtocolAdapter adapter; + protected volatile @NotNull ProtocolAdapterState state; + protected volatile @NotNull ProtocolAdapterConnectionState northboundConnectionState; + protected volatile @NotNull ProtocolAdapterConnectionState southboundConnectionState; + + public ProtocolAdapterWrapper2(final @NotNull ProtocolAdapter adapter) { + this.adapter = adapter; + northboundConnectionState = ProtocolAdapterConnectionState.Disconnected; + southboundConnectionState = ProtocolAdapterConnectionState.Disconnected; + state = ProtocolAdapterState.Stopped; + } + + public @NotNull ProtocolAdapterConnectionState getSouthboundConnectionState() { + return southboundConnectionState; + } + + public @NotNull ProtocolAdapterState getState() { + return state; + } + + public @NotNull ProtocolAdapterConnectionState getNorthboundConnectionState() { + return northboundConnectionState; + } + + public @NotNull String getAdapterId() { + return adapter.getId(); + } + + public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { + return adapter.getProtocolAdapterInformation(); + } + + public boolean start() { + LOGGER.info("Starting protocol adapter {}.", getAdapterId()); + ProtocolAdapterTransitionResponse response = transitionTo(ProtocolAdapterState.Starting); + if (response.status().isSuccess()) { + boolean success = startNorthbound(); + success = success && startSouthbound(); + if (success) { + response = transitionTo(ProtocolAdapterState.Started); + } else { + response = transitionTo(ProtocolAdapterState.Error); + } + } + return response.status().isSuccess(); + } + + public boolean stop(final boolean destroy) { + LOGGER.info("Stopping protocol adapter {}.", getAdapterId()); + ProtocolAdapterTransitionResponse response = transitionTo(ProtocolAdapterState.Stopping); + if (response.status().isSuccess()) { + final boolean southboundSuccess = stopSouthbound(); + final boolean northboundSuccess = stopNorthbound(); + if (northboundSuccess && southboundSuccess) { + response = transitionTo(ProtocolAdapterState.Stopped); + } else { + response = transitionTo(ProtocolAdapterState.Error); + } + } + return response.status().isSuccess(); + } + + protected boolean startNorthbound() { + LOGGER.info("Starting northbound for protocol adapter {}.", getAdapterId()); + ProtocolAdapterConnectionTransitionResponse response = + transitionNorthboundConnectionTo(ProtocolAdapterConnectionState.Connecting); + if (response.status().isSuccess()) { + response = transitionNorthboundConnectionTo(ProtocolAdapterConnectionState.Connected); + } + return response.status().isSuccess(); + } + + protected boolean startSouthbound() { + LOGGER.info("Starting southbound for protocol adapter {}.", getAdapterId()); + ProtocolAdapterConnectionTransitionResponse response = + transitionSouthboundConnectionTo(ProtocolAdapterConnectionState.Connecting); + if (response.status().isSuccess()) { + response = transitionSouthboundConnectionTo(ProtocolAdapterConnectionState.Connected); + } + return response.status().isSuccess(); + } + + protected boolean stopNorthbound() { + LOGGER.info("Stopping northbound for protocol adapter {}.", getAdapterId()); + ProtocolAdapterConnectionTransitionResponse response = + transitionNorthboundConnectionTo(ProtocolAdapterConnectionState.Disconnecting); + if (response.status().isSuccess()) { + response = transitionNorthboundConnectionTo(ProtocolAdapterConnectionState.Disconnected); + } + return response.status().isSuccess(); + } + + protected boolean stopSouthbound() { + LOGGER.info("Stopping southbound for protocol adapter {}.", getAdapterId()); + ProtocolAdapterConnectionTransitionResponse response = + transitionSouthboundConnectionTo(ProtocolAdapterConnectionState.Disconnecting); + if (response.status().isSuccess()) { + response = transitionSouthboundConnectionTo(ProtocolAdapterConnectionState.Disconnected); + } + return response.status().isSuccess(); + } + + public synchronized @NotNull ProtocolAdapterTransitionResponse transitionTo(final @NotNull ProtocolAdapterState newState) { + final ProtocolAdapterState fromState = state; + final ProtocolAdapterTransitionResponse response = fromState.transition(newState); + state = response.toState(); + switch (response.status()) { + case Success -> { + LOGGER.debug("Protocol adapter '{}' transitioned from {} to {} successfully.", + getAdapterId(), + fromState, + state); + } + case Failure -> { + LOGGER.error("Protocol adapter '{}' failed to transition from {} to {}.", + getAdapterId(), + fromState, + state); + } + case NotChanged -> { + LOGGER.warn("Protocol adapter '{}' state {} is unchanged.", getAdapterId(), state); + } + } + return response; + } + + public synchronized @NotNull ProtocolAdapterConnectionTransitionResponse transitionSouthboundConnectionTo( + final @NotNull ProtocolAdapterConnectionState newState) { + final ProtocolAdapterConnectionState fromState = southboundConnectionState; + final ProtocolAdapterConnectionTransitionResponse response = fromState.transition(newState); + southboundConnectionState = response.toState(); + switch (response.status()) { + case Success -> { + LOGGER.debug("Protocol adapter '{}' southbound connection transitioned from {} to {} successfully.", + getAdapterId(), + fromState, + southboundConnectionState); + } + case Failure -> { + LOGGER.error("Protocol adapter '{}' southbound connection failed to transition from {} to {}.", + getAdapterId(), + fromState, + southboundConnectionState); + } + case NotChanged -> { + LOGGER.warn("Protocol adapter '{}' southbound connection state {} is unchanged.", + getAdapterId(), + southboundConnectionState); + } + } + return response; + } + + public synchronized @NotNull ProtocolAdapterConnectionTransitionResponse transitionNorthboundConnectionTo( + final @NotNull ProtocolAdapterConnectionState newState) { + final ProtocolAdapterConnectionState fromState = northboundConnectionState; + final ProtocolAdapterConnectionTransitionResponse response = fromState.transition(newState); + northboundConnectionState = response.toState(); + switch (response.status()) { + case Success -> { + LOGGER.debug("Protocol adapter '{}' northbound connection transitioned from {} to {} successfully.", + getAdapterId(), + fromState, + northboundConnectionState); + } + case Failure -> { + LOGGER.error("Protocol adapter '{}' northbound connection failed to transition from {} to {}.", + getAdapterId(), + fromState, + northboundConnectionState); + } + case NotChanged -> { + LOGGER.warn("Protocol adapter '{}' northbound connection state {} is unchanged.", + getAdapterId(), + northboundConnectionState); + } + } + return response; + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/state-machine.md b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/state-machine.md new file mode 100644 index 0000000000..0ab83e444c --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/state-machine.md @@ -0,0 +1,534 @@ +# Protocol Adapter Finite State Machine Specification + +## Overview + +`ProtocolAdapterFSM` manages three concurrent state machines: + +1. **Adapter State** - Lifecycle control (start/stop) +2. **Northbound State** - Connection to HiveMQ broker +3. **Southbound State** - Connection to physical device + +All three must coordinate. The adapter controls overall lifecycle. Northbound handles MQTT publishing. Southbound handles device communication. + +--- + +## Adapter State Machine + +Four states control adapter lifecycle. + +### States + +1. **STOPPED** + - Default state + - Adapter inactive + - Transitions: → STARTING + +2. **STARTING** + - Initialization in progress + - Calls `onStarting()` hook + - Transitions: → STARTED (success), → STOPPED (failure), → ERROR (non recoverable error) + +3. **STARTED** + - Adapter operational + - 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 + ↑ ↓ + └────────────────────────────────┘ +``` + +**Constraint:** Must transition through intermediate states. Cannot jump STOPPED → STARTED. + +--- + +## Connection State Machines + +Nine states apply to both northbound and southbound connections. Each connection state machine operates independently but coordinates via the adapter lifecycle. + +### States + +**Note:** The state-machine.png diagram visualizes these states with color coding for clarity. + +1. **DISCONNECTED** + - No connection established + - Initial state + - Transitions: → CONNECTING, → CONNECTED (legacy), → CLOSED (testing) + +2. **CONNECTING** + - Connection attempt in progress + - Transitions: → CONNECTED, → ERROR, → DISCONNECTED + +3. **CONNECTED** + - Active connection established + - Data flow operational + - Transitions: → DISCONNECTING, → CONNECTING, → CLOSING, → ERROR_CLOSING, → DISCONNECTED + +4. **DISCONNECTING** + - Graceful connection teardown + - Transitions: → DISCONNECTED, → CLOSING + +5. **CLOSING** + - Permanent closure in progress + - Transitions: → CLOSED + +6. **CLOSED** + - Connection permanently closed + - Transitions: → DISCONNECTED (restart), → CLOSING (verification) + +7. **ERROR** + - Connection failure + - Transitions: → CONNECTING (recovery), → DISCONNECTED (abort) + +8. **ERROR_CLOSING** + - Error during closure + - Transitions: → ERROR + +9. **NOT_SUPPORTED** + - Stateless operation mode + - No transitions allowed + +### Transition Graph + +![State Machine Diagram](state-machine.png) + +The diagram shows: +- **Left side:** Complete connection state transition graph with all possible states and transitions +- **Right side:** Ideal operational sequence (steps 1-8) showing coordinated northbound and southbound transitions +- **Color coding:** + - Orange (DISCONNECTED) - Initial/inactive state + - Blue (CONNECTING, CONNECTED, DISCONNECTING, CLOSING) - Normal operational states + - Red (ERROR, ERROR_CLOSING, CLOSED) - Error or terminal states + +**Ideal Transition Sequence (Right side of diagram, steps 1-8):** +1. **Step 1:** Both connections DISCONNECTED (initial state) +2. **Step 2:** Northbound transitions to CONNECTING while southbound remains DISCONNECTED +3. **Step 3:** Northbound reaches CONNECTED (automatically triggers `startSouthbound()`), southbound still DISCONNECTED +4. **Step 4:** Southbound transitions to CONNECTING, northbound maintains CONNECTED +5. **Step 5:** Southbound transitions to CLOSING (shutdown initiated), northbound maintains CONNECTED +6. **Step 6:** Southbound reaches CLOSED (terminal state), northbound maintains CONNECTED +7. **Step 7:** Northbound transitions to CLOSING, southbound remains CLOSED +8. **Step 8:** Both connections reach CLOSED (shutdown complete) + +**ASCII Alternative:** +``` + ┌──────┐ + │ERROR │←──────┐ + └───┬──┘ │ + │ │ + ┌──DISCONNECTED──┐ ↓ │ + │ │ │ │ + ↓ │ ↓ ┌────────────┐ +CONNECTING ←─────────┴─→CONNECTED→ERROR_CLOSING + │ │ │ + ↓ ↓ ↓ +DISCONNECTED DISCONNECTING ERROR + ↑ │ + │ ↓ + │ DISCONNECTED + │ │ +CLOSED←─CLOSING←─────────┘ + │ ↑ + └───────┘ +``` + +--- + +## Operational Sequences + +### Startup Sequence + +1. Initial state: Adapter STOPPED, both connections DISCONNECTED +2. Call `startAdapter()` + - Adapter → STARTING + - Execute `onStarting()` + - Adapter → STARTED (success) or STOPPED (failure) +3. Transition northbound: DISCONNECTED → CONNECTING → CONNECTED +4. Automatic southbound start when northbound reaches CONNECTED + - `startSouthbound()` called automatically + - Override to control behavior + +### Normal Operation + +``` +Adapter: STARTED +Northbound: CONNECTED +Southbound: CONNECTED +``` + +### Connection Failure (Northbound) + +``` +Adapter: STARTED +Northbound: ERROR +Southbound: DISCONNECTED +``` + +Southbound never starts because northbound failed to reach CONNECTED. + +### Connection Failure (Southbound) + +``` +Adapter: STARTED +Northbound: CONNECTED +Southbound: ERROR +``` + +Valid state. Adapter can communicate with broker but not with device. + +### Shutdown Sequence + +**Option 1: Adapter Stop Only** +1. Call `stopAdapter()` + - Adapter → STOPPING + - Execute `onStopping()` + - Adapter → STOPPED +2. Connection states preserved + - Connections maintain current state after adapter stops + - Northbound and southbound remain in their current states (e.g., CONNECTED) + +**Option 2: Full Connection Closure (Ideal Sequence)** +1. Close southbound connection + - Southbound: CONNECTED → CLOSING → CLOSED +2. Close northbound connection + - Northbound: CONNECTED → CLOSING → CLOSED +3. Stop adapter + - Adapter → STOPPING → STOPPED + +The diagram's "Ideal State Transition" (steps 5-8) demonstrates Option 2, where connections are explicitly closed before stopping the adapter. This ensures clean resource cleanup and proper connection termination. + +--- + +## API Methods + +### Adapter Lifecycle + +- `startAdapter()` - Transition STOPPED → STARTING → STARTED +- `stopAdapter()` - Transition STARTED → STOPPING → STOPPED +- `onStarting()` - Override for initialization logic +- `onStopping()` - Override for cleanup logic + +### Northbound Control + +- `transitionNorthboundState(ConnectionStatus)` - Manual state transition +- `accept(ConnectionStatus)` - Transition + trigger southbound on CONNECTED +- `startDisconnecting()` - Begin graceful disconnect +- `startClosing()` - Begin permanent closure +- `startErrorClosing()` - Error-state closure +- `markAsClosed()` - Confirm CLOSED state +- `recoverFromError()` - Attempt recovery from ERROR +- `restartFromClosed()` - Restart from CLOSED state + +### Southbound Control + +- `transitionSouthboundState(ConnectionStatus)` - Manual state transition +- `startSouthbound()` - Override to implement southbound startup logic +- `startSouthboundDisconnecting()` +- `startSouthboundClosing()` +- `startSouthboundErrorClosing()` +- `markSouthboundAsClosed()` +- `recoverSouthboundFromError()` +- `restartSouthboundFromClosed()` + +### State Queries + +- `getNorthboundConnectionStatus()` - Current northbound state +- `getSouthboundConnectionStatus()` - Current southbound state +- `getAdapterState()` - Current adapter state + +--- + +## Concurrency + +### Thread Safety + +- State transitions use `AtomicReference` with `compareAndSet` +- Multiple threads can safely call transition methods +- CAS loop ensures atomic state updates +- Return value indicates success/failure + +### Example + +```java +boolean success = fsm.transitionNorthboundState(StateEnum.CONNECTING); +if (!success) { + // Another thread changed state concurrently + // Handle race condition +} +``` + +### State Listeners + +- Register listeners via `registerAdapterStateListener()` or `registerConnectionStateListener()` +- Listeners use `CopyOnWriteArrayList` - thread-safe during iteration +- Can add/remove listeners during state transitions + +--- + +## Implementation Requirements + +### Adapter Implementation + +```java +public class MyAdapter extends ProtocolAdapterFSM { + + @Override + protected boolean onStarting() { + // Initialize resources + // Return true on success, false on failure + return initializeConnection(); + } + + @Override + protected void onStopping() { + // Clean up resources + closeConnection(); + } + + @Override + public boolean startSouthbound() { + // Called automatically when northbound reaches CONNECTED + return transitionSouthboundState(StateEnum.CONNECTING); + } +} +``` + +### State Transition Logic + +```java +// Start adapter +fsm.startAdapter(); + +// Connect northbound +fsm.transitionNorthboundState(StateEnum.CONNECTING); +fsm.transitionNorthboundState(StateEnum.CONNECTED); + +// Southbound starts automatically via startSouthbound() + +// Stop adapter +fsm.stopAdapter(); +``` + +--- + +## Valid State Combinations + +| Adapter State | Northbound | Southbound | Valid | Notes | +|---------------|----------------|----------------|-------|-------| +| STOPPED | DISCONNECTED | DISCONNECTED | Yes | Initial state | +| STARTING | DISCONNECTED | DISCONNECTED | Yes | Startup in progress | +| STARTED | CONNECTED | CONNECTED | Yes | Normal operation | +| STARTED | CONNECTED | ERROR | Yes | Device communication failed | +| STARTED | ERROR | DISCONNECTED | Yes | Broker communication failed | +| STARTED | DISCONNECTED | CONNECTED | No | Invalid - northbound must connect first | +| STOPPING | * | * | Yes | Any connection state during shutdown | + +--- + +## Validation Rules + +1. **Adapter must be STARTED** before northbound/southbound transitions +2. **Transitions must follow** `possibleTransitions` map +3. **Cannot skip intermediate states** in adapter lifecycle +4. **Southbound activation** requires northbound CONNECTED state +5. **Return values** indicate transition success - must be checked + +--- + +## Error Handling + +### Transition Failures + +```java +// Check return value +if (!fsm.transitionNorthboundState(StateEnum.CONNECTED)) { + log.error("Transition failed - state changed concurrently"); + // Retry or handle error +} +``` + +### Illegal Transitions + +```java +// Throws IllegalStateException +try { + fsm.transitionNorthboundState(StateEnum.CLOSING); // From DISCONNECTED +} catch (IllegalStateException e) { + log.error("Invalid transition: " + e.getMessage()); +} +``` + +### Startup Failures + +```java +@Override +protected boolean onStarting() { + try { + initializeResources(); + return true; + } catch (Exception e) { + log.error("Startup failed", e); + return false; // Adapter transitions to STOPPED + } +} +``` + +--- + +## Testing Scenarios + +### Test 1: Legacy Connection + +```java +fsm.startAdapter(); +fsm.accept(StateEnum.CONNECTED); // Direct jump to CONNECTED +// Southbound: DISCONNECTED (startSouthbound() not implemented) +``` + +### Test 2: Standard Connection Flow + +```java +fsm.startAdapter(); +fsm.accept(StateEnum.CONNECTING); +fsm.accept(StateEnum.CONNECTED); +// Southbound: Starts automatically +``` + +### Test 3: Northbound Error + +```java +fsm.startAdapter(); +fsm.transitionNorthboundState(StateEnum.CONNECTING); +fsm.transitionNorthboundState(StateEnum.ERROR); +// Southbound: DISCONNECTED (never started) +``` + +### Test 4: Southbound Error + +```java +fsm.startAdapter(); +fsm.accept(StateEnum.CONNECTED); // Northbound CONNECTED +// startSouthbound() called automatically +fsm.transitionSouthboundState(StateEnum.CONNECTING); +fsm.transitionSouthboundState(StateEnum.ERROR); +// Northbound: Still CONNECTED +// Southbound: ERROR +``` + +--- + +## State Transition Tables + +### Adapter State Transitions + +| From | To | Method | Condition | +|----------|----------|-------------------|-----------| +| STOPPED | STARTING | `startAdapter()` | Always | +| STARTING | STARTED | Internal | `onStarting()` returns true | +| STARTING | STOPPED | Internal | `onStarting()` returns false | +| STARTED | STOPPING | `stopAdapter()` | Always | +| STOPPING | STOPPED | Internal | After `onStopping()` | + +### Connection State Transitions + +| From | To | Allowed | +|-----------------|-----------------|---------| +| DISCONNECTED | CONNECTING | Yes | +| DISCONNECTED | CONNECTED | Yes (legacy) | +| DISCONNECTED | CLOSED | Yes (testing) | +| CONNECTING | CONNECTED | Yes | +| CONNECTING | ERROR | Yes | +| CONNECTING | DISCONNECTED | Yes | +| CONNECTED | DISCONNECTING | Yes | +| CONNECTED | CONNECTING | Yes | +| CONNECTED | CLOSING | Yes | +| CONNECTED | ERROR_CLOSING | Yes | +| CONNECTED | DISCONNECTED | Yes | +| DISCONNECTING | DISCONNECTED | Yes | +| DISCONNECTING | CLOSING | Yes | +| CLOSING | CLOSED | Yes | +| CLOSED | DISCONNECTED | Yes | +| CLOSED | CLOSING | Yes | +| ERROR | CONNECTING | Yes | +| ERROR | DISCONNECTED | Yes | +| ERROR_CLOSING | ERROR | Yes | +| NOT_SUPPORTED | * | No | + +--- + +## Common Implementation Errors + +### Error 1: Adapter Not Started + +```java +// Incorrect +fsm.transitionNorthboundState(StateEnum.CONNECTING); +// Throws IllegalStateException - adapter not started + +// Correct +fsm.startAdapter(); +fsm.transitionNorthboundState(StateEnum.CONNECTING); +``` + +### Error 2: Ignoring Return Values + +```java +// Incorrect +fsm.transitionNorthboundState(StateEnum.CONNECTING); +// May fail silently due to concurrent modification + +// Correct +boolean success = fsm.transitionNorthboundState(StateEnum.CONNECTING); +if (!success) { + // Handle race condition +} +``` + +### Error 3: Missing startSouthbound Implementation + +```java +// Incorrect +@Override +public boolean startSouthbound() { + log.info("Starting southbound"); + return true; // Doesn't actually transition state +} + +// Correct +@Override +public boolean startSouthbound() { + return transitionSouthboundState(StateEnum.CONNECTING); +} +``` + +--- + +## Key Constraints + +1. Adapter state transitions are sequential - no state skipping +2. Northbound must reach CONNECTED before southbound starts +3. Connection states allow multiple valid transition paths +4. All transitions are atomic via CAS operations +5. State listeners execute synchronously during transitions +6. Adapter ID included in all log messages for debugging + +--- + +## Summary + +The `ProtocolAdapterFSM` coordinates three state machines with defined transition rules. Adapter state controls lifecycle. Northbound handles broker communication. Southbound handles device communication. All operations are thread-safe. State transitions must follow defined paths. Return values indicate success. Override `onStarting()`, `onStopping()`, and `startSouthbound()` to implement adapter-specific behavior. diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/transitions.md b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/transitions.md new file mode 100644 index 0000000000..f1c3a6d48c --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsm/transitions.md @@ -0,0 +1,539 @@ +# Understanding Protocol Adapter State Machines: A Test-Driven Journey + +*5 min read • Learn how three coordinated state machines manage adapter lifecycle and connections* + +--- + +## What Are We Building? + +Protocol adapters connect HiveMQ Edge to physical devices. They need to manage: +1. **Adapter lifecycle** (starting/stopping) +2. **Northbound connection** (to MQTT broker) +3. **Southbound connection** (to physical device) + +These three state machines coordinate to ensure reliable communication. Let's explore them through their tests. + +--- + +## The Three State Machines + +```mermaid +graph TB + subgraph Adapter Lifecycle + STOPPED -->|startAdapter| STARTING + STARTING -->|success| STARTED + STARTING -->|non recoverable error| ERROR + STARTED -->|stopAdapter| STOPPING + STARTED -->|non recoverable error| ERROR + STOPPING --> STOPPED + end +``` + +```mermaid +graph TB + subgraph Connection States - Northbound & Southbound + DISCONNECTED -->|connect| CONNECTING + DISCONNECTED -->|legacy| CONNECTED + CONNECTING -->|success| CONNECTED + CONNECTING -->|fail| ERROR + CONNECTING -->|abort| DISCONNECTED + CONNECTED -->|reconnect| CONNECTING + CONNECTED -->|graceful| DISCONNECTING + CONNECTED -->|terminate| CLOSING + CONNECTED -->|error| ERROR_CLOSING + CONNECTED -->|instant| DISCONNECTED + DISCONNECTING --> DISCONNECTED + DISCONNECTING --> CLOSING + ERROR -->|recover| CONNECTING + ERROR -->|give up| DISCONNECTED + ERROR_CLOSING --> ERROR + CLOSING --> CLOSED + CLOSED -->|restart| DISCONNECTED + CLOSED -->|verify| CLOSING + end +``` + +--- + +## Adapter Lifecycle: The Boss + +The adapter lifecycle controls everything. No connections can transition without the adapter being STARTED. + +### Test: Successful Startup + +```java +@Test +void adapter_successfulStartup() { + fsm.startAdapter(); + assertState(fsm, STARTED, DISCONNECTED, DISCONNECTED); +} +``` + +**What happens:** +- Initial: `STOPPED → STARTING → STARTED` +- Connections stay `DISCONNECTED` +- Ready to establish connections + +### Test: Failed Startup + +```java +@Test +void adapter_failedStartup_returnsToStopped() { + // onStarting() returns false + fsm.startAdapter(); + assertState(fsm, STOPPED, DISCONNECTED, DISCONNECTED); +} +``` + +**What happens:** +- Attempted: `STOPPED → STARTING → STOPPED` +- Initialization failed, rolled back +- Connections never activated + +### Test: Stop Preserves Connections + +```java +@Test +void adapter_stopPreservesConnectionStates() { + fsm.startAdapter(); + fsm.transitionNorthboundState(CONNECTED); + fsm.stopAdapter(); + + assertState(fsm, STOPPED, CONNECTED, DISCONNECTED); +} +``` + +**Key insight:** Stopping the adapter doesn't disconnect connections. They maintain their state. + +--- + +## Northbound: Connection to MQTT Broker + +Northbound manages the connection to HiveMQ broker. It has the most complex state graph. + +### Test: Standard Connection Flow + +```java +@Test +void northbound_standardConnectFlow() { + fsm.startAdapter(); + fsm.accept(CONNECTING); + fsm.accept(CONNECTED); +} +``` + +```mermaid +sequenceDiagram + participant A as Adapter + participant N as Northbound + A->>A: STOPPED → STARTED + N->>N: DISCONNECTED → CONNECTING + N->>N: CONNECTING → CONNECTED +``` + +### Test: Legacy Direct Connect + +```java +@Test +void northbound_legacyDirectConnect() { + fsm.startAdapter(); + fsm.accept(CONNECTED); // Skip CONNECTING +} +``` + +**Why:** Backward compatibility. Old code could jump directly to CONNECTED. + +### Test: Error Handling + +```java +@Test +void northbound_errorRecovery() { + fsm.startAdapter(); + fsm.transitionNorthboundState(CONNECTING); + fsm.transitionNorthboundState(ERROR); + + fsm.recoverFromError(); // ERROR → CONNECTING +} +``` + +**The error recovery cycle:** +```mermaid +graph LR + CONNECTING -->|connection failed| ERROR + ERROR -->|retry| CONNECTING + ERROR -->|give up| DISCONNECTED +``` + +### Test: Graceful Shutdown + +```java +@Test +void northbound_closingSequence() { + fsm.transitionNorthboundState(CONNECTED); + fsm.startClosing(); // CONNECTED → CLOSING + fsm.markAsClosed(); // CLOSING → CLOSED +} +``` + +### Test: Emergency Shutdown + +```java +@Test +void northbound_errorClosingSequence() { + fsm.transitionNorthboundState(CONNECTED); + fsm.startErrorClosing(); // CONNECTED → ERROR_CLOSING + // ERROR_CLOSING → ERROR +} +``` + +**Two shutdown paths:** +```mermaid +graph TB + CONNECTED -->|graceful| CLOSING + CONNECTED -->|error while closing| ERROR_CLOSING + CLOSING --> CLOSED + ERROR_CLOSING --> ERROR +``` + +### Test: Reconnection + +```java +@Test +void northbound_reconnectFromConnected() { + fsm.transitionNorthboundState(CONNECTED); + fsm.transitionNorthboundState(CONNECTING); // Reconnect +} +``` + +**Use case:** Connection degraded, attempt reconnection without full disconnect. + +--- + +## Southbound: Connection to Physical Device + +Southbound mirrors northbound but has a special behavior: **automatic activation**. + +### Test: Automatic Start + +```java +@Test +void southbound_startsWhenNorthboundConnects() { + fsm = createFSMWithAutoSouthbound(); + fsm.startAdapter(); + fsm.accept(CONNECTED); // Northbound connects + + // Southbound automatically starts CONNECTING +} +``` + +**Why:** Device connection only makes sense when broker connection is established. + +```mermaid +sequenceDiagram + participant N as Northbound + participant S as Southbound + N->>N: CONNECTING → CONNECTED + N->>S: Trigger startSouthbound() + S->>S: DISCONNECTED → CONNECTING +``` + +### Test: Southbound Error While Northbound Connected + +```java +@Test +void southbound_errorWhileNorthboundConnected() { + fsm.accept(CONNECTED); // Northbound OK + // Southbound transitions to ERROR asynchronously + + assertState(fsm, STARTED, CONNECTED, ERROR); +} +``` + +**This is valid!** Broker connection works, device doesn't. Adapter can receive commands but can't execute them. + +### Test: Full Lifecycle + +```java +@Test +void southbound_fullLifecycle() { + fsm.accept(CONNECTED); // Auto-start + fsm.transitionSouthboundState(CONNECTED); // Connection succeeds + fsm.startSouthboundClosing(); // Begin shutdown + fsm.markSouthboundAsClosed(); // Complete shutdown +} +``` + +```mermaid +graph LR + DISCONNECTED -->|auto| CONNECTING + CONNECTING --> CONNECTED + CONNECTED --> CLOSING + CLOSING --> CLOSED +``` + +--- + +## Coordination: The Ideal Shutdown Sequence + +The most complex test demonstrates all three state machines coordinating. + +```java +@Test +void diagramSequence_idealShutdown() { + // Step 1: Initial state + assertState(fsm, STOPPED, DISCONNECTED, DISCONNECTED); + + // Step 2: Start adapter, begin northbound connection + fsm.startAdapter(); + fsm.transitionNorthboundState(CONNECTING); + + // Step 3: Northbound connects (triggers southbound) + fsm.accept(CONNECTED); + + // Step 4: Southbound connects + fsm.transitionSouthboundState(CONNECTED); + + // Step 5: Close southbound first + fsm.startSouthboundClosing(); + + // Step 6: Southbound closed + fsm.markSouthboundAsClosed(); + + // Step 7: Close northbound + fsm.startClosing(); + + // Step 8: Northbound closed + fsm.markAsClosed(); +} +``` + +```mermaid +sequenceDiagram + participant A as Adapter + participant N as Northbound + participant S as Southbound + + Note over A,S: Step 1: Initial State + A->>A: STOPPED + + Note over A,S: Step 2-3: Startup + A->>A: STOPPED → STARTED + N->>N: DISCONNECTED → CONNECTING → CONNECTED + N->>S: Trigger startSouthbound() + + Note over A,S: Step 4: Southbound Connects + S->>S: CONNECTING → CONNECTED + + Note over A,S: Step 5-8: Graceful Shutdown + S->>S: CONNECTED → CLOSING → CLOSED + N->>N: CONNECTED → CLOSING → CLOSED +``` + +**Why this order?** Close device connection before broker connection. Ensures clean resource cleanup. + +--- + +## Error Scenarios + +### Test: Northbound Fails, Southbound Never Starts + +```java +@Test +void northbound_errorState() { + fsm.startAdapter(); + fsm.accept(CONNECTING); + fsm.accept(ERROR); + + assertState(fsm, STARTED, ERROR, DISCONNECTED); +} +``` + +**Southbound stays DISCONNECTED** because northbound never reached CONNECTED. + +### Test: Concurrent State Changes + +```java +@Test +void concurrentTransition_casFailure() { + fsm.transitionNorthboundState(CONNECTING); + fsm.transitionNorthboundState(CONNECTED); // Succeeds + fsm.transitionNorthboundState(CONNECTING); // Also succeeds (reconnect) +} +``` + +**Thread-safe:** Uses atomic compare-and-set operations. Multiple threads can safely transition states. + +--- + +## Invalid Transitions + +Not all transitions are allowed. Tests verify enforcement: + +```java +@Test +void invalidTransition_disconnectedToClosing() { + fsm.startAdapter(); + assertThatThrownBy(() -> fsm.transitionNorthboundState(CLOSING)) + .isInstanceOf(IllegalStateException.class); +} +``` + +**Rule:** Cannot close what isn't connected. Must go through CONNECTED first. + +```java +@Test +void invalidTransition_connectingToErrorClosing() { + fsm.transitionNorthboundState(CONNECTING); + assertThatThrownBy(() -> fsm.transitionNorthboundState(ERROR_CLOSING)) + .isInstanceOf(IllegalStateException.class); +} +``` + +**Rule:** ERROR_CLOSING only valid from CONNECTED state. + +--- + +## State Observers + +### Test: Notifications + +```java +@Test +void stateListener_multipleNotifications() { + fsm.registerStateTransitionListener(state -> stateCount.incrementAndGet()); + + fsm.startAdapter(); // +2 (STOPPED→STARTING→STARTED) + fsm.transitionNorthboundState(CONNECTING); // +1 + fsm.transitionNorthboundState(CONNECTED); // +1 + + assertThat(stateCount.get()).isEqualTo(4); +} +``` + +**Key insight:** `startAdapter()` triggers **two** transitions internally. + +```mermaid +graph LR + STOPPED -->|1️⃣| STARTING + STARTING -->|2️⃣| STARTED +``` + +### Test: Unregister + +```java +@Test +void stateListener_unregister() { + fsm.registerStateTransitionListener(listener); + fsm.startAdapter(); // Notified + + fsm.unregisterStateTransitionListener(listener); + fsm.transitionNorthboundState(CONNECTING); // NOT notified +} +``` + +--- + +## State Machine Properties + +From analyzing the tests, we can identify key properties: + +### 1. **Thread-Safety** +All transitions use atomic operations. Concurrent calls are safe. + +### 2. **Separation of Concerns** +Three independent state machines coordinate via triggers, not tight coupling. + +### 3. **Fail-Safe** +- Startup failure returns to STOPPED +- Error states have recovery paths +- Invalid transitions throw exceptions + +### 4. **Flexibility** +- Legacy direct-connect supported +- Multiple shutdown paths (graceful, error, instant) +- Reconnection without full disconnect + +### 5. **Observability** +State listeners enable monitoring and reactive behavior. + +--- + +## Complete State Graphs + +### Adapter States + +```mermaid +stateDiagram-v2 + [*] --> STOPPED + STOPPED --> STARTING: startAdapter() + STARTING --> STARTED: onStarting() = true + STARTING --> STOPPED: onStarting() = false + STARTED --> STOPPING: stopAdapter() + STOPPING --> STOPPED + STOPPED --> [*] +``` + +### Connection States (Both Northbound & Southbound) + +```mermaid +stateDiagram-v2 + [*] --> DISCONNECTED + DISCONNECTED --> CONNECTING: connect attempt + DISCONNECTED --> CONNECTED: legacy direct connect + DISCONNECTED --> CLOSED: testing + + CONNECTING --> CONNECTED: success + CONNECTING --> ERROR: failure + CONNECTING --> DISCONNECTED: abort + + CONNECTED --> CONNECTING: reconnect + CONNECTED --> DISCONNECTING: graceful disconnect + CONNECTED --> CLOSING: terminate + CONNECTED --> ERROR_CLOSING: error during close + CONNECTED --> DISCONNECTED: instant disconnect + + DISCONNECTING --> DISCONNECTED + DISCONNECTING --> CLOSING + + CLOSING --> CLOSED + + CLOSED --> DISCONNECTED: restart + CLOSED --> CLOSING: verification + + ERROR --> CONNECTING: recovery + ERROR --> DISCONNECTED: give up + + ERROR_CLOSING --> ERROR + + DISCONNECTED --> [*] +``` + +--- + +## Key Takeaways + +1. **Three coordinated state machines** manage adapter lifecycle and bi-directional connections +2. **Adapter must be STARTED** before connections can transition +3. **Northbound triggers southbound** when reaching CONNECTED state +4. **Error states have recovery paths** - systems can self-heal +5. **Multiple shutdown paths** handle graceful and emergency scenarios +6. **Thread-safe by design** using atomic operations +7. **State preservation** when adapter stops - connections maintain state +8. **Observability built-in** via state transition listeners + +--- + +## Test Coverage + +**32 tests** verify: +- ✅ Adapter lifecycle (startup, failure, stop) +- ✅ Northbound transitions (13 scenarios) +- ✅ Southbound transitions (7 scenarios) +- ✅ Invalid transition rejection (3 guards) +- ✅ State listeners (register, notify, unregister) +- ✅ Concurrent modifications (CAS validation) +- ✅ Ideal shutdown sequence (8-step coordination) + +--- + +*Read the code: [`ProtocolAdapterFSMTest.java`](src/test/java/com/hivemq/fsm/ProtocolAdapterFSMTest.java)* diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/FsmExperiment.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/FsmExperiment.java new file mode 100644 index 0000000000..f5357417db --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/FsmExperiment.java @@ -0,0 +1,83 @@ +/* + * 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.fsmjochen; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public class FsmExperiment { + + private volatile State currentState = State.Stopped; + + private final ProtocolAdapterFsmJochen protocolAdapterFsmJochen; + + public enum State { + Stopped, + Starting, + Started, + Stopping, + Error + } + + final Map> stateToStatesMap = Map.of( + State.Stopped, List.of(State.Starting), + State.Starting, List.of(State.Started, State.Stopping, State.Error), + State.Started, List.of(State.Stopping, State.Error), + State.Stopping, List.of(State.Error, State.Stopped), + State.Error, List.of(State.Starting) + ); + + final Map> transitionsMap = Map.of( + State.Stopped, pa -> State.Stopped, + State.Stopping, pa -> { + try { + pa.stop(); + return State.Stopping; + } catch (Exception e) { + return State.Error; + } + }, + State.Started, pa -> State.Started, + State.Starting, pa -> { + pa.start(); + return State.Stopping; + }, + State.Error, pa -> State.Error + ); + + + public FsmExperiment(ProtocolAdapterFsmJochen protocolAdapterFsmJochen) { + this.protocolAdapterFsmJochen = protocolAdapterFsmJochen; + } + + public synchronized boolean transitionTo(State targetState) { + if(stateToStatesMap.get(currentState).contains(targetState)) { + currentState = transitionsMap.get(targetState).apply(protocolAdapterFsmJochen); + return true; + } else { + return false; + } + } + + + public State getCurrentState() { + return currentState; + } + +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/PATest.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/PATest.java new file mode 100644 index 0000000000..6cea7675f7 --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/PATest.java @@ -0,0 +1,50 @@ +/* + * 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.fsmjochen; + +import java.util.concurrent.CompletableFuture; + +public class PATest implements ProtocolAdapterFsmJochen { + FsmExperiment fsmExperiment; + + public PATest(FsmExperiment fsmExperiment) { + this.fsmExperiment = fsmExperiment; + } + + public void start() { + CompletableFuture.runAsync(() -> { + if (true) { + fsmExperiment.transitionTo(FsmExperiment.State.Started); + } else{ + fsmExperiment.transitionTo(FsmExperiment.State.Error); + } + }); + }; + + public void stop() { + CompletableFuture.runAsync(() -> { + + //DO ALL THE WORK + + if (true) { + fsmExperiment.transitionTo(FsmExperiment.State.Stopped); + } else{ + fsmExperiment.transitionTo(FsmExperiment.State.Error); + } + }); + } +} diff --git a/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/ProtocolAdapterFsmJochen.java b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/ProtocolAdapterFsmJochen.java new file mode 100644 index 0000000000..3435326fbf --- /dev/null +++ b/hivemq-edge/src/main/java/com/hivemq/protocols/fsmjochen/ProtocolAdapterFsmJochen.java @@ -0,0 +1,22 @@ +/* + * 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.fsmjochen; + +public interface ProtocolAdapterFsmJochen { + void start(); + void stop(); +} diff --git a/hivemq-edge/src/main/resources/templates/protocol-adapter-messages-en_US.properties b/hivemq-edge/src/main/resources/templates/protocol-adapter-messages-en_US.properties new file mode 100644 index 0000000000..0685db5287 --- /dev/null +++ b/hivemq-edge/src/main/resources/templates/protocol-adapter-messages-en_US.properties @@ -0,0 +1,8 @@ +fsm.transition.failure.unable.to.transition.from.state.to.state=Unable to transition from ${fromState} to ${toState}. +fsm.transition.success.state.is.unchanged=${state} is unchanged. +fsm.transition.success.transitioned.from.state.to.state=Transitioned from ${fromState} to ${toState}. +fsm.connection.transition.failure.unable.to.transition.from.state.to.state=Unable to transition connection from ${fromState} to ${toState}. +fsm.connection.transition.success.state.is.unchanged=Connection ${state} is unchanged. +fsm.connection.transition.success.transitioned.from.state.to.state=Connection transitioned from ${fromState} to ${toState}. +protocol.adapter.manager.protocol.adapter.deleted=Adapter '${adapterId}' was deleted from the system permanently. +protocol.adapter.manager.protocol.adapter.not.found=Adapter '${adapterId}' not found. diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionStateTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionStateTest.java new file mode 100644 index 0000000000..6b839c6ee6 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterConnectionStateTest.java @@ -0,0 +1,87 @@ +/* + * 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.fsm; + +import com.hivemq.common.i18n.StringTemplate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ProtocolAdapterConnectionStateTest { + private static final Map> + PROTOCOL_ADAPTER_CONNECTION_STATE_MAP = Map.of(ProtocolAdapterConnectionState.Closed, + Set.of(ProtocolAdapterConnectionState.Disconnected), + ProtocolAdapterConnectionState.Closing, + Set.of(ProtocolAdapterConnectionState.Closed), + ProtocolAdapterConnectionState.Connected, + Set.of(ProtocolAdapterConnectionState.Disconnecting, + ProtocolAdapterConnectionState.Closing, + ProtocolAdapterConnectionState.ErrorClosing), + ProtocolAdapterConnectionState.Connecting, + Set.of(ProtocolAdapterConnectionState.Connected, ProtocolAdapterConnectionState.Error), + ProtocolAdapterConnectionState.Disconnected, + Set.of(ProtocolAdapterConnectionState.Connecting), + ProtocolAdapterConnectionState.Disconnecting, + Set.of(ProtocolAdapterConnectionState.Connecting, ProtocolAdapterConnectionState.Disconnected), + ProtocolAdapterConnectionState.Error, + Set.of(ProtocolAdapterConnectionState.Disconnected), + ProtocolAdapterConnectionState.ErrorClosing, + Set.of(ProtocolAdapterConnectionState.Error)); + + @Test + public void whenEverythingWorks_thenTransitionShouldWork() { + final List states = List.of(ProtocolAdapterConnectionState.values()); + states.forEach(fromState -> { + final Set possibleToStates = + PROTOCOL_ADAPTER_CONNECTION_STATE_MAP.get(fromState); + assertThat(possibleToStates).isNotNull(); + states.forEach(toState -> { + final ProtocolAdapterConnectionTransitionResponse response = fromState.transition(toState); + switch (response.status()) { + case Success -> { + assertThat(possibleToStates).contains(toState); + assertThat(response.message()).isEqualTo(StringTemplate.format( + "Connection transitioned from ${fromState} to ${toState}.", + Map.of("fromState", fromState, "toState", toState))); + } + case Failure -> { + assertThat(possibleToStates).doesNotContain(toState); + assertThat(response.message()).isEqualTo(StringTemplate.format( + "Unable to transition connection from ${fromState} to ${toState}.", + Map.of("fromState", fromState, "toState", toState))); + } + case NotChanged -> { + assertThat(toState).isEqualTo(fromState); + assertThat(response.message()).isEqualTo(StringTemplate.format( + "Connection ${state} is unchanged.", + Map.of("state", fromState))); + } + } + }); + }); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterStateTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterStateTest.java new file mode 100644 index 0000000000..48b2b953e0 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterStateTest.java @@ -0,0 +1,77 @@ +/* + * 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.fsm; + +import com.hivemq.common.i18n.StringTemplate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ProtocolAdapterStateTest { + private static final Map> PROTOCOL_ADAPTER_STATE_MAP_MAP = Map.of( + ProtocolAdapterState.Starting, + Set.of(ProtocolAdapterState.Started, ProtocolAdapterState.Stopping, ProtocolAdapterState.Error), + ProtocolAdapterState.Started, + Set.of(ProtocolAdapterState.Stopping, ProtocolAdapterState.Error), + ProtocolAdapterState.Stopping, + Set.of(ProtocolAdapterState.Stopped, ProtocolAdapterState.Error), + ProtocolAdapterState.Stopped, + Set.of(ProtocolAdapterState.Starting), + ProtocolAdapterState.Error, + Set.of(ProtocolAdapterState.Starting)); + + @Test + public void whenEverythingWorks_thenTransitionShouldWork() { + final List states = List.of(ProtocolAdapterState.values()); + states.forEach(fromState -> { + final Set possibleToStates = PROTOCOL_ADAPTER_STATE_MAP_MAP.get(fromState); + assertThat(possibleToStates).isNotNull(); + states.forEach(toState -> { + final ProtocolAdapterTransitionResponse response = fromState.transition(toState); + switch (response.status()) { + case Success -> { + assertThat(possibleToStates).contains(toState); + assertThat(response.message()).isEqualTo(StringTemplate.format( + "Transitioned from ${fromState} to ${toState}.", + Map.of("fromState", fromState, "toState", toState))); + } + case Failure -> { + assertThat(possibleToStates).doesNotContain(toState); + assertThat(response.message()).isEqualTo(StringTemplate.format( + "Unable to transition from ${fromState} to ${toState}.", + Map.of("fromState", fromState, "toState", toState))); + } + case NotChanged -> { + assertThat(toState).isEqualTo(fromState); + assertThat(response.message()).isEqualTo(StringTemplate.format("${state} is unchanged.", + Map.of("state", fromState))); + } + } + }); + }); + } +} diff --git a/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapperTest.java b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapperTest.java new file mode 100644 index 0000000000..9342d85516 --- /dev/null +++ b/hivemq-edge/src/test/java/com/hivemq/protocols/fsm/ProtocolAdapterWrapperTest.java @@ -0,0 +1,58 @@ +/* + * 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.fsm; + +import com.hivemq.adapter.sdk.api.ProtocolAdapter; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ProtocolAdapterWrapperTest { + @Mock + private @NotNull ProtocolAdapter protocolAdapter; + + @BeforeEach + public void setUp() { + when(protocolAdapter.getId()).thenReturn("test"); + } + + @Test + public void whenAdapterIsValid_thenStartAndStopWork() { + final ProtocolAdapterWrapper2 wrapper = new ProtocolAdapterWrapper2(protocolAdapter); + assertThat(wrapper.getState()).isEqualTo(ProtocolAdapterState.Stopped); + assertThat(wrapper.getNorthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Disconnected); + assertThat(wrapper.getSouthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Disconnected); + assertThat(wrapper.start()).isTrue(); + assertThat(wrapper.getState()).isEqualTo(ProtocolAdapterState.Started); + assertThat(wrapper.getNorthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Connected); + assertThat(wrapper.getSouthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Connected); + assertThat(wrapper.stop(true)).isTrue(); + assertThat(wrapper.getState()).isEqualTo(ProtocolAdapterState.Stopped); + assertThat(wrapper.getNorthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Disconnected); + assertThat(wrapper.getSouthboundConnectionState()).isEqualTo(ProtocolAdapterConnectionState.Disconnected); + } +}